Chapter 42Lesson 02~360 minutes

Production Capstone: Build, Secure, Publish, Operate, Observe, and Recover a Complete Dockerized Application: Implementation and Automation Build-Out

Build the capstone from an exact source revision, publish one multi-platform digest with SBOM/provenance, scan and sign it, deploy by digest with least privilege, and capture evidence continuously.

BuildxRegistryAttestationsComposeAutomation

Learning objectives

  • Create the complete synthetic source tree, Compose runtime, disposable registry, and isolated Buildx builder.
  • Build one multi-platform image index from an exact source SHA and pinned base index, with SBOM and max SLSA provenance attached at publication.
  • Scan and sign the immutable release digest, then create a release alias pointing to the same bytes instead of rebuilding.
  • Deploy by digest with non-root/read-only/capability/resource/logging/health controls and verify persistent data across container replacement.
  • Produce source-to-runtime evidence continuously and keep every mutation scoped to exact lab identities.

1. Create the capstone workspace

mkdir ch42-capstone
cd ch42-capstone
mkdir -p config secrets evidence/backups
printf 'ch42-fake-runtime-token-v1
' > secrets/api_token.txt
chmod 600 secrets/api_token.txt
printf 'secrets/
evidence/
cosign.key
' > .gitignore
printf 'APP_GREETING=Hello-from-Chapter-42
' > config/app.env
printf 'seed
' > data-sentinel

The fake token is intentionally not committed. The committed configuration is non-secret. Do not replace the fake token with a production credential.

2. Application: health, state, config, secret, graceful shutdown

cat > app.py <<'PYAPP'
import json, os, signal, sqlite3, threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path

DATA = Path('/data')
DB = DATA / 'state.db'
UNHEALTHY = DATA / 'unhealthy'
CONFIG = Path('/run/config/app.env')
SECRET = Path('/run/secrets/api_token')

def read_config():
    values = {}
    if CONFIG.exists():
        for line in CONFIG.read_text().splitlines():
            if '=' in line and not line.lstrip().startswith('#'):
                k, v = line.split('=', 1); values[k] = v
    return values

def token():
    return SECRET.read_text().strip() if SECRET.exists() else ''

def db():
    c = sqlite3.connect(DB)
    c.execute('create table if not exists kv (k text primary key, v integer not null)')
    c.execute("insert or ignore into kv values ('counter', 0)")
    c.commit(); return c

class H(BaseHTTPRequestHandler):
    def send_json(self, code, obj):
        body = json.dumps(obj, sort_keys=True).encode()
        self.send_response(code)
        self.send_header('content-type', 'application/json')
        self.send_header('content-length', str(len(body)))
        self.end_headers(); self.wfile.write(body)
    def log_message(self, fmt, *args):
        print(json.dumps({'remote': self.client_address[0], 'message': fmt % args}), flush=True)
    def do_GET(self):
        if self.path == '/health':
            if UNHEALTHY.exists(): return self.send_json(503, {'status':'unhealthy'})
            try:
                with db() as c: c.execute("select v from kv where k='counter'").fetchone()
                return self.send_json(200, {'status':'healthy'})
            except Exception as e:
                return self.send_json(503, {'status':'unhealthy','error':type(e).__name__})
        if self.path == '/':
            cfg = read_config()
            with db() as c: value = c.execute("select v from kv where k='counter'").fetchone()[0]
            return self.send_json(200, {'greeting':cfg.get('APP_GREETING','hello'), 'counter':value})
        return self.send_json(404, {'error':'not found'})
    def do_POST(self):
        if self.path != '/increment': return self.send_json(404, {'error':'not found'})
        if self.headers.get('X-API-Token','') != token(): return self.send_json(403, {'error':'forbidden'})
        with db() as c:
            c.execute("update kv set v=v+1 where k='counter'")
            value = c.execute("select v from kv where k='counter'").fetchone()[0]
        return self.send_json(200, {'counter':value})

server = ThreadingHTTPServer(('0.0.0.0', 8080), H)
def stop(*_):
    threading.Thread(target=server.shutdown, daemon=True).start()
signal.signal(signal.SIGTERM, stop)
signal.signal(signal.SIGINT, stop)
print(json.dumps({'event':'ready','uid':os.getuid(),'gid':os.getgid()}), flush=True)
server.serve_forever(); server.server_close()
print(json.dumps({'event':'stopped'}), flush=True)
PYAPP

3. Dockerfile: no target-platform execution, numeric least privilege

cat > Dockerfile <<'EOF'
# syntax=docker/dockerfile:1.27
ARG BASE_IMAGE
FROM ${BASE_IMAGE}
WORKDIR /app
COPY --chown=10001:10001 app.py /app/app.py
COPY --chown=10001:10001 data-sentinel /data/.seed
USER 10001:10001
EXPOSE 8080
STOPSIGNAL SIGTERM
ENTRYPOINT ["python", "/app/app.py"]
EOF

There is no RUN instruction. Numeric --chown avoids user-name resolution at build time, and the seeded /data directory gives a new named volume a writable UID/GID 10001 ownership during Docker’s initial copy-up. The runtime root filesystem can therefore remain read-only while the named volume is writable.

4. Compose production-like runtime contract

cat > compose.yaml <<'EOF'
name: ch42cap
services:
  app:
    image: ${APP_REF:?set APP_REF to immutable registry digest}
    user: "10001:10001"
    read_only: true
    cap_drop: [ALL]
    security_opt: ["no-new-privileges:true"]
    tmpfs:
      - /tmp:rw,noexec,nosuid,size=16m
    configs:
      - source: app_config
        target: /run/config/app.env
    secrets:
      - source: api_token
        target: api_token
    volumes:
      - type: volume
        source: state
        target: /data
    networks: [frontend, ops]
    ports:
      - "127.0.0.1:18080:8080"
    healthcheck:
      test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/health', timeout=2).read()"]
      interval: 10s
      timeout: 3s
      retries: 3
      start_period: 5s
      start_interval: 2s
    restart: unless-stopped
    stop_grace_period: 15s
    cpus: 0.75
    mem_limit: 256m
    pids_limit: 128
    logging:
      driver: local
      options:
        max-size: "5m"
        max-file: "3"
  observer:
    image: ${APP_REF:?set APP_REF to immutable registry digest}
    user: "10001:10001"
    read_only: true
    cap_drop: [ALL]
    security_opt: ["no-new-privileges:true"]
    tmpfs:
      - /tmp:rw,noexec,nosuid,size=8m
    networks: [ops]
    depends_on:
      app:
        condition: service_healthy
    entrypoint: ["python", "-c"]
    command:
      - "import time,urllib.request; [(print(urllib.request.urlopen('http://app:8080/health',timeout=2).read().decode(),flush=True),time.sleep(10)) for _ in iter(int,1)]"
    restart: unless-stopped
    logging:
      driver: local
      options:
        max-size: "2m"
        max-file: "2"
configs:
  app_config:
    file: ./config/app.env
secrets:
  api_token:
    file: ./secrets/api_token.txt
volumes:
  state:
    name: ${STATE_VOLUME:-ch42cap_state}
networks:
  frontend: {}
  ops:
    internal: true
EOF

The companion service has no host port and no secret. The internal ops network prevents external egress through that network; the app also joins frontend for loopback publishing. This is a teaching contract, not a substitute for production ingress TLS or cluster network policy.

5. Commit exact source revision

git init
git config user.name "DevOps Academy Lab"
git config user.email "lab@example.invalid"
git add app.py Dockerfile compose.yaml config/app.env data-sentinel .gitignore
git commit -m "capstone source"
SOURCE_SHA="$(git rev-parse HEAD)"
SOURCE_DATE_EPOCH="$(git show -s --format=%ct HEAD)"
printf 'source_sha=%s
source_date_epoch=%s
' "$SOURCE_SHA" "$SOURCE_DATE_EPOCH" | tee evidence/source.txt
test -z "$(git status --porcelain)"

6. Resolve external image identities before use

PYTHON_TAG=python:3.13-alpine
REGISTRY_TAG=registry:2
ALPINE_TAG=alpine:3.22

docker buildx imagetools inspect "$PYTHON_TAG" > evidence/python-base-imagetools.txt
PYTHON_DIGEST="$(awk '/^Digest:/ {print $2; exit}' evidence/python-base-imagetools.txt)"
BASE_REF="$PYTHON_TAG@$PYTHON_DIGEST"

docker pull "$REGISTRY_TAG"
REGISTRY_REF="$(docker image inspect "$REGISTRY_TAG" --format '{{index .RepoDigests 0}}')"
docker pull "$ALPINE_TAG"
ALPINE_REF="$(docker image inspect "$ALPINE_TAG" --format '{{index .RepoDigests 0}}')"
printf 'base=%s
registry=%s
alpine=%s
' "$BASE_REF" "$REGISTRY_REF" "$ALPINE_REF" | tee evidence/external-images.txt

Never copy example digests from course text. Resolve the registry’s current immutable subject, store it, and then use that exact reference in the run.

7. Start a loopback-only disposable registry

docker rm -f ch42-registry 2>/dev/null || true
docker volume inspect ch42_registry_data >/dev/null 2>&1 || docker volume create ch42_registry_data >/dev/null
docker run -d --name ch42-registry   --label devops.academy.lab=ch42   -p 127.0.0.1:5000:5000   -v ch42_registry_data:/var/lib/registry   "$REGISTRY_REF"
curl -fsS http://127.0.0.1:5000/v2/ | tee evidence/registry-v2.txt
Safety boundary. The registry is plain HTTP and bound only to loopback for this disposable lab. Do not copy this architecture to a reachable production interface. Production registries should use trusted TLS and authenticated authorization.

8. Create a builder with registry trust scoped to the builder

cat > buildkitd.toml <<'EOF'
[registry."127.0.0.1:5000"]
  http = true
  insecure = true
EOF

docker buildx rm ch42-builder 2>/dev/null || true
docker buildx create --name ch42-builder --driver docker-container   --buildkitd-config ./buildkitd.toml --use
docker buildx inspect --bootstrap | tee evidence/builder.txt
docker buildx version | tee evidence/buildx-version.txt

The insecure registry setting belongs only to this disposable BuildKit daemon. The host Docker daemon is not globally reconfigured.

9. Build once and publish the candidate with attestations

REPO=127.0.0.1:5000/ch42/app
BUILD_TAG="$REPO:build-$SOURCE_SHA"

docker buildx build   --builder ch42-builder   --platform linux/amd64,linux/arm64   --build-arg BASE_IMAGE="$BASE_REF"   --build-arg SOURCE_DATE_EPOCH="$SOURCE_DATE_EPOCH"   --label "org.opencontainers.image.revision=$SOURCE_SHA"   --label "org.opencontainers.image.source=devops-academy-ch42"   --provenance=mode=max,version=v1   --sbom=true   --metadata-file evidence/build-metadata.json   --progress=plain   --tag "$BUILD_TAG"   --push . 2>&1 | tee evidence/build.log

docker buildx imagetools inspect "$BUILD_TAG" | tee evidence/imagetools.txt
DIGEST="$(awk '/^Digest:/ {print $2; exit}' evidence/imagetools.txt)"
SUBJECT="$REPO@$DIGEST"
printf 'subject=%s
' "$SUBJECT" | tee evidence/subject.txt

The internal registry push is the single build output. Subsequent testing, scanning, signing, promotion, deployment, rollback, and restore all refer to SUBJECT; no deployment rebuild is allowed.

10. Extract SBOM and provenance from the same subject

docker buildx imagetools inspect "$SUBJECT"   --format '{{json .SBOM}}' > evidence/sbom.json
docker buildx imagetools inspect "$SUBJECT"   --format '{{json .Provenance}}' > evidence/provenance.json
python - <<'PY'
import json
for p in ['evidence/sbom.json','evidence/provenance.json']:
    x=json.load(open(p)); print(p, 'top_keys=', sorted(x)[:10])
PY

11. Test the published candidate by digest

docker pull "$SUBJECT"
docker rm -f ch42-candidate-test 2>/dev/null || true
docker run -d \
  --name ch42-candidate-test \
  --label devops.academy.lab=ch42 \
  --user 10001:10001 \
  --read-only \
  --cap-drop ALL \
  --security-opt no-new-privileges:true \
  --tmpfs /tmp:rw,noexec,nosuid,size=8m \
  --tmpfs /data:rw,nosuid,size=32m   -p 127.0.0.1:18079:8080 "$SUBJECT"
for i in 1 2 3 4 5; do curl -fsS http://127.0.0.1:18079/health && break || sleep 1; done
curl -fsS http://127.0.0.1:18079/ | tee evidence/candidate-response.json
docker rm -f ch42-candidate-test >/dev/null

This tests the current host platform manifest from the already-published index. The capstone verifies the second platform’s manifest and attestations structurally; execute both platforms only when authorized native/emulated workers are available.

12. Scan and sign the immutable subject

trivy --version | tee evidence/trivy-version.txt
trivy image --insecure --format json --output evidence/scan-amd64.json   --platform linux/amd64 "$SUBJECT"
trivy image --insecure --format json --output evidence/scan-arm64.json   --platform linux/arm64 "$SUBJECT"

export COSIGN_PASSWORD='ch42-lab-only'
cosign generate-key-pair >/dev/null
cosign sign --yes --key cosign.key --allow-insecure-registry "$SUBJECT"
cosign verify --key cosign.pub --allow-insecure-registry "$SUBJECT"   > evidence/signature-verify.json
unset COSIGN_PASSWORD
Safety boundary. The password and key are disposable lab material. Do not reuse this pattern for production signing. Production signing should use protected identity/key infrastructure and an organization verification policy.

13. Promote the same digest, not a rebuild

RELEASE_TAG="$REPO:release-1"
docker buildx imagetools create --tag "$RELEASE_TAG" "$SUBJECT"
docker buildx imagetools inspect "$RELEASE_TAG" | tee evidence/release-imagetools.txt
RELEASE_DIGEST="$(awk '/^Digest:/ {print $2; exit}' evidence/release-imagetools.txt)"
test "$RELEASE_DIGEST" = "$DIGEST"
printf 'promotion_digest_equal=true
' | tee evidence/promotion.txt

If a specific local tool build refuses plain-HTTP imagetools operations, secure the local registry with TLS or perform the alias through the local Distribution API. Do not “solve” it by making a non-loopback registry globally insecure.

14. Normalize and deploy by digest

APP_REF="$SUBJECT" docker compose -p ch42cap config > evidence/compose.normalized.yaml
APP_REF="$SUBJECT" docker compose -p ch42cap up -d
APP_ID="$(APP_REF="$SUBJECT" docker compose -p ch42cap ps -q app)"
OBS_ID="$(APP_REF="$SUBJECT" docker compose -p ch42cap ps -q observer)"
printf 'app_id=%s
observer_id=%s
' "$APP_ID" "$OBS_ID" | tee evidence/runtime-ids.txt
APP_REF="$SUBJECT" docker compose -p ch42cap ps | tee evidence/compose-ps.txt
docker inspect "$APP_ID" > evidence/app-inspect.json
docker volume inspect ch42cap_state > evidence/state-volume.json
docker network inspect ch42cap_frontend > evidence/frontend-network.json
docker network inspect ch42cap_ops > evidence/ops-network.json

15. Verify runtime contract and persistent state

curl -fsS http://127.0.0.1:18080/ | tee evidence/app-before.json
curl -fsS -X POST -H 'X-API-Token: ch42-fake-runtime-token-v1'   http://127.0.0.1:18080/increment | tee evidence/increment-1.json
curl -fsS -X POST -H 'X-API-Token: ch42-fake-runtime-token-v1'   http://127.0.0.1:18080/increment | tee evidence/increment-2.json
curl -fsS http://127.0.0.1:18080/ | tee evidence/app-state.json

docker inspect "$APP_ID" \
  --format 'User={{.Config.User}} ReadonlyRootfs={{.HostConfig.ReadonlyRootfs}} CapDrop={{json .HostConfig.CapDrop}} SecurityOpt={{json .HostConfig.SecurityOpt}} Image={{.Image}}'
docker stats --no-stream "$APP_ID" | tee evidence/stats.txt
APP_REF="$SUBJECT" docker compose -p ch42cap logs --no-color --timestamps --tail=100 > evidence/runtime.log

# Replace only the app container; volume must survive.
APP_REF="$SUBJECT" docker compose -p ch42cap up -d --force-recreate --no-deps app
curl -fsS http://127.0.0.1:18080/ | tee evidence/app-after-replace.json

The counter after replacement is persistent-data evidence. The container ID should change while the subject digest and named volume remain the intended identities.

16. Implementation evidence checkpoint

Evidence file Minimum review
source.txt Exact Git SHA and commit timestamp
external-images.txt Pinned base/registry/helper identities
builder.txt + build.log Builder platforms, driver, BuildKit behavior, build graph
build-metadata.json + subject.txt Release digest and BuildKit metadata
sbom.json + provenance.json Supply-chain statements attached to subject
scan-*.json Scanner output bound to platform + subject
signature-verify.json Signature verifies against public test key
promotion.txt Release alias digest equals build digest
compose.normalized.yaml Rendered runtime contract without secret value
app-inspect.json + state-volume.json + networks Runtime/security/data/network IDs
runtime.log + stats.txt Bounded runtime observations
app-state / app-after-replace State survives container replacement

Knowledge check

Where did the capstone “build once” happen?

Why does the builder have its own HTTP registry config?

Why can the multi-platform Dockerfile build without installing QEMU in this lab?

What proves promotion did not rebuild?

Why is APP_REF set to REPO@DIGEST instead of release-1?

Next lesson

Next: Production Capstone: Build, Secure, Publish, Operate, Observe, and Recover a Complete Dockerized Application: Security, Governance, and Reliability Validation

Continue with the next lesson in the course sequence and carry forward the evidence-first Docker operating model.

Official references and version notes

Baseline checked:

2026-09-22. The capstone records the learner’s actual versions before execution. Reference baselines used for compatibility discussion are Docker Engine/CLI 29.8.1, BuildKit 0.33.0, Buildx 0.37.1, Docker Compose 5.5.1, Trivy 0.74.0, and Cosign 3.1.3. Packaged Docker Desktop/Engine installations may expose different bundled containerd/runc versions, so the evidence packet records what the active daemon actually reports.

Keep the academy open

Support free, practical DevOps education.

Every lesson is designed to remain readable in a browser, downloadable from GitHub, and usable without a paid learning platform. Contributions help expand and maintain the curriculum.

Ethereum / ERC-20
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0Send only Ethereum/ERC-20 compatible assets to this address.