DEV Community

Cover image for Capstone: Dockerize Your Own App End to End
Shubham Sharma
Shubham Sharma

Posted on Originally published at techdevmantra.com

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:

# ---- 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"]
Enter fullscreen mode Exit fullscreen mode

Build it and check the size:

docker build -t tdm-capstone:local .
docker images tdm-capstone:local
Enter fullscreen mode Exit fullscreen mode
tdm-capstone:local  233MB
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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}}"
Enter fullscreen mode Exit fullscreen mode
SERVICE   STATUS
app       Up 23 seconds (healthy)
caddy     Up 18 seconds
db        Up 28 seconds (healthy)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
<!doctype html>...<p>This page has been served <strong>1</strong> times.</p>...
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
uid=1000(node) gid=1000(node) groups=1000(node)
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode
touch: /oops.txt: Read-only file system
Enter fullscreen mode Exit fullscreen mode

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

ReadonlyRootfs=true  Memory=134217728  NanoCpus=500000000  CapDrop=[ALL]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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...
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
docker compose -f compose.prod.yml up -d
curl -k https://<your-server-ip-with-dashes>.sslip.io
Enter fullscreen mode Exit fullscreen mode
<!doctype html>...<p>This page has been served <strong>1</strong> times.</p>...
Enter fullscreen mode Exit fullscreen mode

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:

docker compose -f compose.prod.yml down -v
Enter fullscreen mode Exit fullscreen mode

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)