Lesson 11 of 13 · Level 5 — Production habits
Capstone: ship an app through a private registry
Put the whole track together: build a small image, push it over verified TLS to your own registry, then pull it on a second host that has log rotation and a registry mirror configured, and run the full stack there with Compose, pinned by digest.
What you'll build
Two lab VMs (the registry from lesson 7 can live on either):
| Host | Role |
|---|---|
build |
Builds the image and pushes it |
registry.lab.local |
The private registry with TLS and login (lesson 7) |
run |
Pulls and runs the stack with Compose |
build ──docker push (TLS + login)──► registry.lab.local:5000 ◄──docker pull (TLS + login)── run
└─ compose: web + api + db
Resources needed: two or three small Linux VMs (2 vCPU, 2–4 GB RAM each) on one network, Docker Engine with the Compose plugin on each, and /etc/hosts entries for registry.lab.local.
Step 1: build a small, safe image
On build, use a tiny API (any language) with a /healthz endpoint and a multi-stage Dockerfile like lesson 2's:
FROM python:3.12-slim AS base
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py .
RUN useradd --uid 10001 --no-create-home appuser
USER 10001
EXPOSE 8000
HEALTHCHECK --interval=10s --timeout=3s CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/healthz')"
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "app:app"]
$ docker build -t registry.lab.local:5000/shop/api:1.0 .
$ trivy image --severity HIGH,CRITICAL registry.lab.local:5000/shop/api:1.0
Check: the image runs as UID 10001 (docker run --rm --entrypoint id registry.lab.local:5000/shop/api:1.0), and .dockerignore keeps .env and .git out.
Step 2: push over verified TLS
$ sudo mkdir -p /etc/docker/certs.d/registry.lab.local:5000
$ sudo cp domain.crt /etc/docker/certs.d/registry.lab.local:5000/ca.crt
$ docker login registry.lab.local:5000
$ docker push registry.lab.local:5000/shop/api:1.0
1.0: digest: sha256:<digest> size: 1789
Check: curl -u ci-bot https://registry.lab.local:5000/v2/shop/api/tags/list lists 1.0. Write the digest down.
Step 3: prepare the run host
On run, trust the registry (same certs.d step), then set host defaults:
{
"log-driver": "json-file",
"log-opts": { "max-size": "10m", "max-file": "3" },
"live-restore": true,
"default-address-pools": [ { "base": "10.200.0.0/16", "size": 24 } ]
}
$ sudo dockerd --validate --config-file /etc/docker/daemon.json
$ sudo systemctl restart docker
$ docker info | grep -E 'Logging Driver|Live Restore'
$ docker login registry.lab.local:5000
If you built the pull-through cache in lesson 7, add "registry-mirrors" too, and watch the postgres and nginx pulls go through it.
Check: docker info shows no insecure registries apart from 127.0.0.0/8.
Step 4: run the stack, pinned by digest
# compose.yaml
services:
web:
image: nginx:1.27
ports: [ "8080:80" ]
volumes: [ "./nginx.conf:/etc/nginx/conf.d/default.conf:ro" ]
depends_on:
api: { condition: service_healthy }
api:
image: registry.lab.local:5000/shop/api:1.0@sha256:<digest>
environment:
DB_HOST: db
DB_PASSWORD_FILE: /run/secrets/db_password
secrets: [ db_password ]
depends_on:
db: { condition: service_healthy }
db:
image: postgres:16
environment:
POSTGRES_USER: app
POSTGRES_DB: shop
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
secrets: [ db_password ]
volumes: [ "db-data:/var/lib/postgresql/data" ]
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d shop"]
interval: 5s
retries: 10
volumes:
db-data:
secrets:
db_password:
file: ./secrets/db_password.txt
# compose.prod.yaml
services:
web: { restart: unless-stopped }
api:
restart: unless-stopped
read_only: true
tmpfs: [ /tmp ]
cap_drop: [ ALL ]
security_opt: [ "no-new-privileges:true" ]
deploy: { resources: { limits: { cpus: "1.0", memory: 256M } } }
db:
restart: unless-stopped
deploy: { resources: { limits: { memory: 512M } } }
$ docker compose -f compose.yaml -f compose.prod.yaml up -d --wait
$ docker compose ps
$ curl -s localhost:8080/healthz
Check: all three services are (healthy) or running, the API answers through nginx, and docker inspect -f '{{.HostConfig.LogConfig.Config}}' <api-container> shows max-size:10m.
Step 5: prove it survives
sudo systemctl restart docker: withlive-restore, the containers keep running (docker compose psshows the same uptime).sudo reboot: after boot, everything comes back on its own (restart policies + enabled Docker service).docker compose down && docker compose -f compose.yaml -f compose.prod.yaml up -d: the database data is still there.
Step 6: ship version 1.1
Change the API, build and push 1.1, update the digest in compose.yaml, and run up -d again. Only api is recreated. To roll back, put the old digest back and run up -d.
Interview talking points
- Why the image is multi-stage, non-root and scanned, and what
.dockerignoreprotects. - How Docker decides which registry an image name points to, and how the registry is trusted (certs.d, not
insecure-registries), and what the bypass would have cost. - What
daemon.jsonsets on the run host and why log rotation needs containers recreated. - Why the deployment is pinned by digest, and how you'd roll back.
- Where Compose stops being enough, and what Kubernetes adds (multi-host scheduling, self-healing across nodes, rolling updates).
Clean-up
$ docker compose -f compose.yaml -f compose.prod.yaml down -v
$ docker logout registry.lab.local:5000
$ docker image prune -a
Command summary
# build host
$ docker build -t registry.lab.local:5000/shop/api:1.0 .
$ trivy image --severity HIGH,CRITICAL registry.lab.local:5000/shop/api:1.0
$ sudo cp domain.crt /etc/docker/certs.d/registry.lab.local:5000/ca.crt
$ docker login registry.lab.local:5000
$ docker push registry.lab.local:5000/shop/api:1.0
# run host
$ sudo cp domain.crt /etc/docker/certs.d/registry.lab.local:5000/ca.crt
$ sudoedit /etc/docker/daemon.json
$ sudo dockerd --validate --config-file /etc/docker/daemon.json && sudo systemctl restart docker
$ docker login registry.lab.local:5000
$ docker compose -f compose.yaml -f compose.prod.yaml up -d --wait
$ docker compose ps && curl -s localhost:8080/healthz
Recap
You built a small non-root image, pushed it over verified TLS to a private registry, configured the run host with daemon.json, and ran a healthy, limited, digest-pinned stack with Docker Compose that survives daemon restarts and reboots. Next step: the same app on Kubernetes, in "Kubernetes Administration — Level by Level".
This site is a public version of my personal engineering knowledge hub. It intentionally excludes confidential company information and internal operational details.