Take a small Express app, write a Dockerfile from scratch, build it locally, push it to a private registry of your choice, then pull and run it on a cloud VM. The container must respond on port 3000 with a JSON health check, accept a `LOG_LEVEL` env var, and exit gracefully on SIGTERM within 5 seconds.
Start FROM node:20-alpine for both stages, or use gcr.io/distroless/nodejs20-debian12 for the runner stage to drop the shell from production. Copy package*.json first and run npm ci --omit=dev before copying source — this is the single biggest cache win. Handle SIGTERM in your Express app by calling server.close() and clearing any intervals; otherwise Docker will kill the process at the 10-second mark and you will see exit 137 instead of 0. For the registry, ECR is fastest if you are already on AWS; DOCR is the simplest to get running for free-tier experiments.
$ docker build -t express-demo:v1 .
[+] Building 42.3s (12/12) FINISHED
=> exporting to image
=> => writing image sha256:8a3c...
$ docker images express-demo:v1
REPOSITORY TAG IMAGE ID CREATED SIZE
express-demo v1 8a3c1d2f9b4e 5 seconds ago 142MB
$ docker run -d --name app -p 3000:3000 -e LOG_LEVEL=debug express-demo:v1
$ curl -s localhost:3000/health
{"status":"ok","uptime":2.41}
$ docker stop app && docker inspect app --format '{{.State.ExitCode}}'
0
HEALTHCHECK instruction to the Dockerfile that hits /health every 30 seconds and verify docker ps shows (healthy) after a minute. 2. Build a multi-arch image with docker buildx build --platform linux/amd64,linux/arm64 --push and confirm both manifests exist in your registry. 3. Wire a GitHub Actions workflow that builds and pushes on every commit to main, using a registry-scoped deploy token. 4. Replace the runner stage with gcr.io/distroless/nodejs20-debian12 and measure the final image size delta. 5. Add a non-root USER directive and verify the container fails fast if mounted volumes have wrong ownership.