Containerization and Orchestration: A Small Lab You Can Run
Containerization is only confusing when the example is too big. If you keep the stack small, the ideas become obvious: build an image, run it, confirm it responds. Orchestration at this scale is just a repeatable run command written down so you do not have to remember it.
This post is a start-to-finish lab. Every file and command is real and copy-paste ready. If you follow it exactly, you will end with a running container, started through Compose, responding to a health check. No placeholders. No skipped steps. No abstract explanation without a working result.
What you are building
You will create a tiny Node web service with one health endpoint, package it into a Docker image, and run it using Docker Compose. The goal is not to build an app. The goal is to understand the minimum moving parts of containerization and a simple orchestration layer.
Before touching anything, here is the end state so the steps make sense.
| Item | Path | Purpose |
|---|---|---|
| Web service | app/index.js | Simple HTTP server with /health endpoint |
| App manifest | app/package.json | Declares dependencies and start command |
| Container build file | app/Dockerfile | Defines how the image is built |
| Orchestration file | docker-compose.yml | Runs the container with a mapped port |
Once these exist, you will be able to build the image, run the container, and confirm it responds.
Prerequisites
You need Docker Desktop installed and Node.js available on your machine. No other tooling is required. If Docker is not installed, install Docker Desktop first and confirm this works before continuing:
docker version
If that command returns a version, Docker is ready.
Step 1: Create the web service
The service will expose one route: /health. This keeps verification simple. If /health returns JSON, the container is working.
Run the following commands to create the app folder and files.
mkdir -p appcat > app/index.js <<'EOF'const express = require("express");const app = express();app.get("/health", (_req, res) => {res.json({ ok: true });});app.listen(3000, () => {console.log("server on 3000");});EOF
Now create the package manifest.
cat > app/package.json <<'EOF'{"name": "lab-app","version": "1.0.0","main": "index.js","scripts": {"start": "node index.js"},"dependencies": {"express": "^4.19.2"}}EOF
Install dependencies and verify the service runs locally.
cd appnpm installnpm start
Expected output:
server on 3000
In another terminal, verify the health endpoint.
curl http://localhost:3000/health
Expected response:
{"ok":true}
Stop the local server with Ctrl+C. At this point the service works outside a container.
Step 2: Build a container image
Now you will package the service into a container image using a Dockerfile.
Create the Dockerfile.
cat > app/Dockerfile <<'EOF'FROM node:18-alpineWORKDIR /appCOPY package.json package-lock.json ./RUN npm install --productionCOPY . .EXPOSE 3000CMD ["npm", "start"]EOF
Build the image.
docker build -t lab-app ./app
If the build completes without errors, the image is ready. You can verify it exists with:
docker images | grep lab-app
Step 3: Run the image with Compose
Running containers manually is fine once. Orchestration starts when the run command becomes a file you can repeat. Docker Compose provides that layer.
Create the Compose file in the project root.
cat > docker-compose.yml <<'EOF'services:app:build: ./appports:- "3000:3000"EOF
Start the service through Compose.
docker compose up --build
You should see output showing the image building and the server starting.
server on 3000
In another terminal, verify the running container.
curl http://localhost:3000/health
Expected response:
{"ok":true}
Stop Compose with Ctrl+C when finished.
What just happened
A Node service was created. A Docker image packaged it. Compose ran the image with a mapped port. The /health endpoint confirmed the container responded correctly. That is the complete containerization and basic orchestration loop in its smallest useful form.
Containerization vs orchestration, concretely
People blur these two terms together, but they are different stages with different tools. Containerization is the act of packaging your app and its dependencies into a single immutable image. The command that proves it worked is docker build and docker run. Orchestration is the act of running and managing that container at scale — starting it, restarting it when it crashes, spreading it across machines, and scaling it up or down. The command that proves orchestration worked is docker compose up (for a single host) or kubectl apply (for a cluster).
Concrete example: in this lab, docker build -t lab-app ./app is containerization. You now have an image. docker compose up --build is orchestration — Compose reads the YAML, starts the container, maps the port, and if you add restart: always it will restart the container if it exits. The line between them is: containerization produces the artifact, orchestration decides how it runs.
Useful Docker and Kubernetes commands
Beyond the build and run commands above, these are the ones I use most:
docker ps # list running containersdocker logs -f <container-id> # tail logsdocker exec -it <container-id> sh # shell into a running containerdocker image ls # list built imagesdocker system prune -f # remove stopped containers and dangling imagesdocker compose down # stop and remove containers from a compose filedocker compose logs -f # tail logs for all services in the compose file
For Kubernetes, the equivalent set:
kubectl get pods # list running podskubectl describe pod <name> # inspect a pod's events and statuskubectl logs -f <pod-name> # tail logskubectl exec -it <pod-name> -- sh # shell into a podkubectl apply -f deployment.yaml # create or update resources from a manifestkubectl scale deployment app --replicas=3 # scale horizontallykubectl rollout status deployment/app # watch a rolling update
When to use containers and when not to
Containers are worth it when the environment matters — when your app has specific dependency versions, system libraries, or runtime requirements that differ from the host. They are also worth it when you need parity between development and production, or when you are deploying to a platform that expects images (ECS, GKE, Fly.io).
They are not worth it when the app is a single static file with no dependencies, or when the deployment target is a static host like Netlify or Vercel that already handles the environment for you. Containerizing a static blog adds build time, image storage, and a container runtime for zero benefit. I do not containerize this blog for exactly that reason.
Resource requirements and scaling
The lab app above runs fine with no resource limits because it is a single Express server doing almost nothing. In production, you want to set limits so one container cannot starve the host:
services:app:build: ./appports:- "3000:3000"deploy:resources:limits:cpus: "0.5"memory: 256Mreservations:memory: 128Mrestart: unless-stopped
In Kubernetes, the equivalent is resources.requests and resources.limits in the pod spec. The scheduler uses requests to decide which node to place the pod on, and limits enforces the ceiling. Scaling horizontally means increasing the replica count — in Compose you do docker compose up --scale app=3, in Kubernetes you run kubectl scale. The load balancer in front (Compose does not give you one by default; Kubernetes Services with an Ingress do) distributes traffic across the replicas.
What I learned running containers in production
Three things that bit me in real deployments:
- Images without tags are a trap. If you always build
lab-app:latest, you cannot roll back. Tag images with the git SHA or a semantic version.docker build -t lab-app:$(git rev-parse --short HEAD) .is the minimum. - Health checks matter. A container can be "running" but the app inside can be hung. Add a
HEALTHCHECKinstruction to the Dockerfile or ahealthcheckblock in Compose so the orchestrator knows when to restart it. - Build context size affects build speed. If your build context includes
node_modulesor a.gitdirectory, every build ships that data to the daemon. Add a.dockerignorefile withnode_modules,.git, anddistat minimum.
Common failure points
| Symptom | Likely cause | Fix |
|---|---|---|
Cannot find module express | Dependencies not installed | Run npm install in app and rebuild image |
| Container exits immediately | Start command missing | Ensure scripts.start exists in package.json |
curl connection refused | Port mapping missing or container not running | Confirm 3000:3000 in compose and container logs |
| Docker build fails on lockfile | package-lock.json missing | Run npm install before building image |
Closing
Containerization is just packaging a working service into an image. Orchestration is just running that image through a repeatable configuration. Keeping the example small removes the noise and makes the mechanics clear.
Once this lab runs on your machine, you have a real baseline. From here you can add databases, reverse proxies, or multiple services, but the core loop remains the same.