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.

ItemPathPurpose
Web serviceapp/index.jsSimple HTTP server with /health endpoint
App manifestapp/package.jsonDeclares dependencies and start command
Container build fileapp/DockerfileDefines how the image is built
Orchestration filedocker-compose.ymlRuns the container with a mapped port

Once these exist, you will be able to build the image, run the container, and confirm it responds.

Cloud architecture checkpoint illustration for this section.

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 app
cat > 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 app
npm install
npm 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-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm install --production
COPY . .
EXPOSE 3000
CMD ["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: ./app
ports:
- "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 containers
docker logs -f <container-id> # tail logs
docker exec -it <container-id> sh # shell into a running container
docker image ls # list built images
docker system prune -f # remove stopped containers and dangling images
docker compose down # stop and remove containers from a compose file
docker compose logs -f # tail logs for all services in the compose file

For Kubernetes, the equivalent set:

kubectl get pods # list running pods
kubectl describe pod <name> # inspect a pod's events and status
kubectl logs -f <pod-name> # tail logs
kubectl exec -it <pod-name> -- sh # shell into a pod
kubectl apply -f deployment.yaml # create or update resources from a manifest
kubectl scale deployment app --replicas=3 # scale horizontally
kubectl 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: ./app
ports:
- "3000:3000"
deploy:
resources:
limits:
cpus: "0.5"
memory: 256M
reservations:
memory: 128M
restart: 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:

  1. 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.
  2. Health checks matter. A container can be "running" but the app inside can be hung. Add a HEALTHCHECK instruction to the Dockerfile or a healthcheck block in Compose so the orchestrator knows when to restart it.
  3. Build context size affects build speed. If your build context includes node_modules or a .git directory, every build ships that data to the daemon. Add a .dockerignore file with node_modules, .git, and dist at minimum.

Common failure points

SymptomLikely causeFix
Cannot find module expressDependencies not installedRun npm install in app and rebuild image
Container exits immediatelyStart command missingEnsure scripts.start exists in package.json
curl connection refusedPort mapping missing or container not runningConfirm 3000:3000 in compose and container logs
Docker build fails on lockfilepackage-lock.json missingRun npm install before building image
Cloud operations checkpoint illustration for this section.

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.

Keep reading