Docker & Containers — Level by Level›11 · Capstone: ship an app through a private registry

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.

Practitioner
Key wordscapstonemulti-stage buildprivate registryTLSdocker loginpushpulldocker composedaemon.jsondigest pinning

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

  1. sudo systemctl restart docker: with live-restore, the containers keep running (docker compose ps shows the same uptime).
  2. sudo reboot: after boot, everything comes back on its own (restart policies + enabled Docker service).
  3. 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 .dockerignore protects.
  • 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.json sets 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.