This is the capstone of the Docker Foundations series. Instead of one new concept, it pulls the whole track together: you take a real app from source code to a live URL served over HTTPS, built into a lean image, hardened the way you would actually run it, pushed to a registry by CI, and deployed to a server. Every command below was run for real, first locally and then on an actual Ubuntu server.
Tip
Get the code: the app, the multi-stage Dockerfile, the production Compose file, the Caddy config, and the CI workflow are in the 10-capstone folder of the companion repo.
Info
Built and run on Docker Engine 29.8.0 with Compose v5.5.1, then deployed on a separate Ubuntu 24.04 server. The output shown is the genuine result.
The app
The app is deliberately small but real: an Express server backed by Postgres. It serves a page with a visit counter (so it has to read and write a database) and exposes a /healthz endpoint for health checks. The full source is in the repo; the only thing that matters here is that it is a normal app with a real dependency, not a toy that prints hello.
A lean image with a multi-stage build
The Dockerfile builds in two stages. The first installs production dependencies against the lockfile; the second copies just those dependencies and the source into a minimal runtime image that runs as a non-root user and declares a healthcheck:
# ---- deps: install production dependencies against the lockfile ----
FROM node:22-alpine AS deps
WORKDIR /app
COPY app/package.json app/package-lock.json ./
RUN npm ci --omit=dev
# ---- runtime: minimal image, non-root, with a healthcheck ----
FROM node:22-alpine AS runtime
ENV NODE_ENV=production
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY app/ ./
USER node
EXPOSE 3000
HEALTHCHECK --interval=10s --timeout=3s --start-period=5s --retries=3 \
CMD wget -q -O /dev/null http://localhost:3000/healthz || exit 1
CMD ["node", "server.js"]
Build it and check the size:
docker build -t tdm-capstone:local .
docker images tdm-capstone:local
tdm-capstone:local 233MB
Because the build tooling stays in the first stage, the final image carries only the Alpine base, the production node_modules, and the app. That is the multi-stage payoff from earlier in the series, applied to a real app.
The production stack
A single Compose file wires the app to Postgres and puts Caddy in front of it, and it turns on the operating and security practices from the rest of the series at once. The important parts:
app:
image: ${APP_IMAGE:-tdm-capstone:local}
depends_on:
db:
condition: service_healthy # do not start until Postgres is ready
read_only: true # the app writes nothing to its own filesystem
tmpfs:
- /tmp
cap_drop:
- ALL # it needs no Linux capabilities
security_opt:
- no-new-privileges:true
healthcheck:
test: ["CMD-SHELL", "wget -q -O /dev/null http://localhost:3000/healthz || exit 1"]
interval: 10s
retries: 3
start_period: 5s
deploy:
resources:
limits:
cpus: "0.5"
memory: 128M
Bring it up, and the health gating sequences the whole stack for you:
docker compose -f compose.prod.yml up -d
docker compose -f compose.prod.yml ps --format "table {{.Service}}\t{{.Status}}"
SERVICE STATUS
app Up 23 seconds (healthy)
caddy Up 18 seconds
db Up 28 seconds (healthy)
Postgres becomes healthy first, the app waits for it and then becomes healthy itself, and only then does Caddy start. The app answers over HTTPS through Caddy, and the counter proves it is really talking to Postgres:
curl -k https://localhost
<!doctype html>...<p>This page has been served <strong>1</strong> times.</p>...
Hit it again and the count goes to 2. Now confirm the hardening actually took effect, not just that it is written in the file. The container runs as an unprivileged user:
docker compose -f compose.prod.yml exec app id
uid=1000(node) gid=1000(node) groups=1000(node)
Its root filesystem is read-only, so a compromised process cannot rewrite the app, while the /tmp tmpfs stays writable for scratch space:
docker compose -f compose.prod.yml exec app sh -c "touch /oops.txt"
touch: /oops.txt: Read-only file system
And the limits and dropped capabilities are real, straight from docker inspect:
ReadonlyRootfs=true Memory=134217728 NanoCpus=500000000 CapDrop=[ALL]
That is 128MB of memory, half a CPU, every Linux capability dropped, and a read-only root, on a container that self-reports health. This one stack applies the Compose, volumes, operating, and security posts together.
Ship it: build in CI, push to GHCR
You do not build production images by hand on your laptop. A small GitHub Actions workflow builds the image on every push and pushes it to the GitHub Container Registry, authenticating with the token GitHub gives the job:
permissions:
contents: read
packages: write
jobs:
build-push:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v6
with:
context: ./10-capstone
push: true
tags: ghcr.io/<you>/tdm-capstone:latest
Pushing this to the repo ran the job green in about half a minute and published the image with its digest:
build-push in 26s
pushing manifest for ghcr.io/<you>/tdm-capstone:latest@sha256:d6e8d6b6...
Your server can now pull a known, immutable image by tag or digest instead of building on the box. New images published this way are private by default, so to pull one on a server you would first run docker login ghcr.io (or make the package public in your GitHub package settings).
Deploy it with automatic HTTPS
The last step is a real server. Copy this folder up, set the environment, and run the same Compose file. The one new idea is the hostname: sslip.io is a free wildcard DNS service where a name like 203-0-113-5.sslip.io resolves to 203.0.113.5, which gives you a real hostname for any IP without buying a domain. Caddy uses that hostname to request a certificate:
# .env on the server
SITE_ADDRESS=<your-server-ip-with-dashes>.sslip.io
docker compose -f compose.prod.yml up -d
curl -k https://<your-server-ip-with-dashes>.sslip.io
<!doctype html>...<p>This page has been served <strong>1</strong> times.</p>...
That is the app, built from the same Dockerfile, running behind Caddy and answering over HTTPS at a real hostname, on a real server.
Warning
About the certificate: the lab server here has a private IP, which Let's Encrypt cannot reach to validate, so this deploy used Caddy's
tls internaloption (a local certificate authority) to prove the HTTPS path end to end. On a real VPS with a public IP, you delete thetls internalline and Caddy fetches a genuine, browser-trusted Let's Encrypt certificate for your sslip.io hostname automatically, with no domain purchase and no manual certbot step.
Tear it down
Everything is disposable, which is the point:
docker compose -f compose.prod.yml down -v
That stops and removes the containers, the network, and the named volumes.
What you built
You took an app with a database and turned it into a lean, non-root, resource-limited, health-checked image; ran it as a hardened stack behind a reverse proxy with HTTPS; had CI build and publish it to a registry; and deployed it to a server reachable over HTTPS with no domain purchase. That is the entire Docker Foundations series in one project.
If you worked through the whole track, from installing Docker and running your first containers, through Compose, lean images, volumes and networks, operating, and security, you now have every piece it takes to ship a container you built yourself. That is a genuinely production-shaped skill set, and everything here is in the companion repo for you to clone and run.
Top comments (0)