← Back to GuidesGUIDESelf-Hosting & Homelab

Capstone: Dockerize Your Own App End to End

40 minutes ago
❤️ 0 likes
💬 0 comments
dockerself-hostingcaddycontainerslinux
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

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:

Dockerfile
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:

Bash
1docker build -t tdm-capstone:local .2docker images tdm-capstone:local
Plain Text
1tdm-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:

YAML
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: 128M

Bring it up, and the health gating sequences the whole stack for you:

Bash
1docker compose -f compose.prod.yml up -d2docker compose -f compose.prod.yml ps --format "table {{.Service}}\t{{.Status}}"
Plain Text
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:

Bash
1curl -k https://localhost
Plain Text
1<!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:

Bash
1docker compose -f compose.prod.yml exec app id
Plain Text
1uid=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:

Bash
1docker compose -f compose.prod.yml exec app sh -c "touch /oops.txt"
Plain Text
1touch: /oops.txt: Read-only file system

And the limits and dropped capabilities are real, straight from docker inspect:

Plain Text
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:

YAML
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:latest

Pushing this to the repo ran the job green in about half a minute and published the image with its digest:

Plain Text
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:

Plain Text
1# .env on the server2SITE_ADDRESS=<your-server-ip-with-dashes>.sslip.io
Bash
1docker compose -f compose.prod.yml up -d2curl -k https://<your-server-ip-with-dashes>.sslip.io
Plain Text
1<!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 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:

Bash
1docker 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.

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.

Comments (0)

Loading discussion...

Related guides