Capstone: Dockerize Your Own App End to End

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
Info
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:
1# ---- deps: install production dependencies against the lockfile ----2FROM node:22-alpine AS deps3WORKDIR /app4COPY app/package.json app/package-lock.json ./5RUN npm ci --omit=dev6 7# ---- runtime: minimal image, non-root, with a healthcheck ----8FROM node:22-alpine AS runtime9ENV NODE_ENV=production10WORKDIR /app11COPY --from=deps /app/node_modules ./node_modules12COPY app/ ./13USER node14EXPOSE 300015HEALTHCHECK --interval=10s --timeout=3s --start-period=5s --retries=3 \16 CMD wget -q -O /dev/null http://localhost:3000/healthz || exit 117CMD ["node", "server.js"]Build it and check the size:
1docker build -t tdm-capstone:local .2docker images tdm-capstone:local1tdm-capstone:local 233MBBecause 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:
1 app:2 image: ${APP_IMAGE:-tdm-capstone:local}3 depends_on:4 db:5 condition: service_healthy # do not start until Postgres is ready6 read_only: true # the app writes nothing to its own filesystem7 tmpfs:8 - /tmp9 cap_drop:10 - ALL # it needs no Linux capabilities11 security_opt:12 - no-new-privileges:true13 healthcheck:14 test: ["CMD-SHELL", "wget -q -O /dev/null http://localhost:3000/healthz || exit 1"]15 interval: 10s16 retries: 317 start_period: 5s18 deploy:19 resources:20 limits:21 cpus: "0.5"22 memory: 128MBring it up, and the health gating sequences the whole stack for you:
1docker compose -f compose.prod.yml up -d2docker compose -f compose.prod.yml ps --format "table {{.Service}}\t{{.Status}}"1SERVICE STATUS2app Up 23 seconds (healthy)3caddy Up 18 seconds4db 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:
1curl -k https://localhost1<!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:
1docker compose -f compose.prod.yml exec app id1uid=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:
1docker compose -f compose.prod.yml exec app sh -c "touch /oops.txt"1touch: /oops.txt: Read-only file systemAnd the limits and dropped capabilities are real, straight from docker inspect:
1ReadonlyRootfs=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:
1permissions:2 contents: read3 packages: write4jobs:5 build-push:6 runs-on: ubuntu-latest7 steps:8 - uses: actions/checkout@v49 - uses: docker/setup-buildx-action@v310 - uses: docker/login-action@v311 with:12 registry: ghcr.io13 username: ${{ github.actor }}14 password: ${{ secrets.GITHUB_TOKEN }}15 - uses: docker/build-push-action@v616 with:17 context: ./10-capstone18 push: true19 tags: ghcr.io/<you>/tdm-capstone:latestPushing this to the repo ran the job green in about half a minute and published the image with its digest:
1build-push in 26s2pushing 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:
1# .env on the server2SITE_ADDRESS=<your-server-ip-with-dashes>.sslip.io1docker compose -f compose.prod.yml up -d2curl -k https://<your-server-ip-with-dashes>.sslip.io1<!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
tls internal option (a local certificate authority) to prove the HTTPS path end to end. On a real VPS with a public IP, you delete the tls internal line 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:
1docker compose -f compose.prod.yml down -vThat 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.
Join the discussion on Capstone: Dockerize Your Own App End to End
Likes, comments, and replies are available for authenticated readers with verified email addresses.


