Project: Containerize and Ship a Service

Take an application from a bare repository to a hardened, multi-stage, scanned image running as a Compose stack behind a proxy — with a written rollback that you have actually executed.

advanced 45 min lesson hands-on task included

Everything in the previous thirteen lessons, applied once, end to end. Pick a real application — one of your own, or something like a Spring Boot service, a Node API, or a Go service with a Postgres backend. The specific stack does not matter; the discipline does.

Work through the phases in order. Each has an explicit exit criterion, because “it seems to work” is not one.


Phase 1: Get It Running By Hand First

Before writing any Dockerfile, write down the steps you would take on a fresh VM to run this application:

  1. Which runtime and which version?
  2. Which system packages?
  3. How is it built, and what artefact does that produce?
  4. Which port does it listen on?
  5. What is the exact command that starts it in the foreground?
  6. Which configuration does it read, and from where — environment, file, or both?
  7. Which paths does it write to at run time?

That last question is the one people skip and then fight for an hour in Phase 4. Answer it now.

Exit criterion: you have seven written answers, and you have run the application manually once with those steps.


Phase 2: A Working Single-Stage Build

Write the naive Dockerfile. Do not optimise yet — the goal is a container that starts and serves traffic.

FROM <full runtime image, pinned tag>
WORKDIR /app
COPY . .
RUN <build commands>
EXPOSE <port>
CMD ["<binary>", "<args>"]
docker build -t myapp:0.1 .
docker run -d -p 8080:<port> --name myapp myapp:0.1
curl localhost:8080/health
docker images myapp:0.1        # record this size — it is your baseline

Exit criterion: the container serves a request, and you have written down its size.


Phase 3: Multi-Stage, .dockerignore, and Cache Ordering

Now optimise, and measure each change.

Write the .dockerignore first — .git, dependency directories, build output, .env, logs. Rebuild and note the drop in “transferring context”.

Split into build and runtime stages (lesson 7). The final stage gets a runtime base and the artefact, nothing else.

Order for cache hits (lesson 6): dependency manifest and install above the source copy.

docker build -t myapp:0.2 .
docker images myapp                      # compare against your 0.1 baseline
docker history myapp:0.2                 # confirm no toolchain layers survived
docker run --rm myapp:0.2 sh -c 'which mvn || which go || which npm || echo "clean"'

touch src/<some-file> && time docker build -t myapp:0.2 .   # dependency layer must say CACHED

Exit criterion: the final image contains no build toolchain, a source-only change reuses the dependency layer, and you can state the percentage size reduction from Phase 2.


Phase 4: Harden It

Apply lesson 12, one control at a time, confirming each before adding the next.

RUN addgroup -g 10001 -S app && adduser -u 10001 -S -G app app
COPY --from=builder --chown=10001:10001 /src/out/app /app/app
USER 10001:10001
HEALTHCHECK --interval=30s --timeout=3s --start-period=30s \
  CMD wget -qO- http://localhost:8080/health || exit 1
ENTRYPOINT ["/app/app"]
docker run -d --name hardened \
  --user 10001:10001 \
  --read-only --tmpfs /tmp:rw,noexec,nosuid,size=64m \
  --cap-drop ALL \
  --security-opt no-new-privileges \
  --memory 512m --cpus 1 --pids-limit 200 \
  -p 8080:8080 myapp:0.3

docker exec hardened id                          # must not be uid=0
docker exec hardened sh -c 'touch /etc/x'        # must fail
docker inspect hardened --format '{{.State.Health.Status}}'

Then scan, and act on what you find:

docker scout cves myapp:0.3       # or: trivy image --severity HIGH,CRITICAL myapp:0.3

If the count is high, try a slimmer base before you try patching packages — that is usually the larger win.

Exit criterion: every control demonstrated failing an operation it should fail, a recorded HIGH/CRITICAL count, and a note on what you did about it.


Phase 5: The Compose Stack

Turn it into a stack (lesson 11): your service, its database, and a reverse proxy.

Requirements, all of which have appeared earlier in this path:

  • Named volume for the database; no published database port.
  • Two networks, so the proxy cannot reach the database.
  • A real healthcheck on the database, and depends_on: condition: service_healthy on the API.
  • restart: unless-stopped everywhere.
  • Resource limits on every service.
  • logging with max-size and max-file.
  • Secrets via Compose secrets or an injected file — not in environment.
  • A compose.override.yaml carrying the development-only bits: source bind mount, debug logging, debugger port.
docker compose config                  # read the resolved output before running anything
docker compose up -d
docker compose ps                      # every service healthy
docker compose logs -f api

Then break it deliberately and watch:

docker compose stop db                 # what does the API do? Should it survive this?
docker compose start db                # does it recover on its own?

Exit criterion: the stack comes up clean from docker compose down in one command, and you can describe — from observation, not theory — what happens to the API when its database disappears and returns.


Phase 6: Ship It, Then Roll It Back

Tag properly (lesson 10) and push.

VERSION=1.0.0
GIT_SHA=$(git rev-parse --short HEAD)

docker build -t "$REGISTRY/myapp:$VERSION" -t "$REGISTRY/myapp:$GIT_SHA" .
docker scout cves "$REGISTRY/myapp:$VERSION"
echo "$REGISTRY_TOKEN" | docker login "$REGISTRY" -u "$REGISTRY_USER" --password-stdin
docker push --all-tags "$REGISTRY/myapp"

docker images --digests "$REGISTRY/myapp"     # record the digest for 1.0.0

Now make a change, ship 1.0.1, deploy it, and roll back to 1.0.0 — by digest, not by tag.

API_VERSION=1.0.1 docker compose up -d api    # deploy
API_VERSION=1.0.0 docker compose up -d api    # roll back
docker compose ps

Time it. Write down how long it took from “we should roll back” to “the old version is serving traffic”. That number is the one that matters during an incident, and most people have never measured it.

Exit criterion: two versions in the registry, a rollback executed and timed, and a one-page runbook containing the build command, the deploy command, the rollback command, the health endpoint, and the log command.


What “Finished” Looks Like

DeliverableCheck
DockerfileMulti-stage, pinned base, non-root USER, HEALTHCHECK, exec-form ENTRYPOINT
.dockerignoreExcludes .git, dependencies, build output, .env
compose.yamlTwo networks, named volume, health-gated depends_on, limits, logging caps, no published DB port
compose.override.yamlDevelopment conveniences only
Build scriptVersion + SHA tags, scan step, stdin login, --all-tags push
Scan reportHIGH/CRITICAL counts with a written decision on each
RunbookBuild, deploy, rollback, health check, logs — one page
RollbackExecuted at least once, with a measured duration

Where This Path Goes Next

You now have one host running a small stack, with an image good enough to run anywhere. The things Compose does not give you are exactly the things that push teams to an orchestrator:

  • Scheduling across many machines, and rescheduling when one dies.
  • Rolling updates with health gating and automatic rollback.
  • Horizontal autoscaling on real metrics.
  • Service discovery and load balancing across hosts.
  • Declarative desired state that something continuously reconciles.

Docker Swarm addresses some of this with overlay networks and services (lesson 9), and Kubernetes addresses all of it. The good news is that almost nothing you learned here is wasted: the image is the interface. A Kubernetes pod spec’s command and args are the ENTRYPOINT and CMD you just wrote. Its resources are the cgroup limits. Its securityContext is the non-root user and dropped capabilities. Its livenessProbe is the HEALTHCHECK. Its volumes are the volumes.

Which is a reasonable moment to open the Kubernetes path on this site.


Common mistake: Stopping at Phase 3 because the image is small and the build is fast. Phases 4 and 5 are where the project earns its keep: a container that runs as root with a writable filesystem is a smaller version of the same problem you started with, and a Compose stack gated on depends_on rather than a healthcheck starts the app before the database is listening — which works on your laptop and fails in CI.