Frontend Labs
⋯

Chapter 63 -- Project 2: containerize a real application properly

In one sentence

Take an application with a real dependency (a database, or at least a config file and a port) and containerize it following every practice from Part 15 -- non-root user, multi-stage build, pinned base image, health check, .dockerignore, and a docker-compose setup with a second service.

Why this matters

Nearly every backend and SRE job posting today lists "Docker" as a requirement, and nearly every take-home interview exercise or first-90-days onboarding task is some version of "containerize this." The gap between "I can write a Dockerfile that works" and "I can write a Dockerfile a security-conscious team would approve in code review" is exactly the content of Chapters 47-49 -- this project is where that content becomes a reflex instead of a checklist you look up each time.

How it actually works

   STEP 1 -- Pick or write a small app with a real dependency:
     A web API (any language) that reads/writes to a Postgres or Redis
     instance is ideal -- it forces you to deal with networking between
     containers, not just a single standalone process.

   STEP 2 -- Write a Dockerfile that gets every practice right:
     - Multi-stage build: a "builder" stage with full build tools,
       a final stage with only the runtime and the built artifact
     - Pinned, minimal base image (e.g. python:3.12-slim, not
       python:latest)
     - A dedicated, non-root user (USER appuser)
     - A .dockerignore excluding .git, node_modules/venv, secrets
     - A HEALTHCHECK instruction that hits a real readiness endpoint
     - Layers ordered so dependency installation is cached separately
       from application code copying (Chapter 48)

   STEP 3 -- Write docker-compose.yml with two services:
     the app, and its dependency (postgres or redis), using
     depends_on, a named volume for the database's data directory,
     and environment variables (not hardcoded secrets) for connection
     details.

   STEP 4 -- Verify the security properties, don't just assume them:
     docker inspect <container> --format '{{.Config.User}}'
       -- confirm it is NOT empty/root
     docker exec <container> whoami
       -- confirm the actual running user
     docker history <your-image>
       -- confirm no secrets baked into a layer
     docker images <your-image> --format '{{.Size}}'
       -- compare against a naive single-stage build's size

   STEP 5 -- Break the dependency on purpose and observe:
     docker compose stop postgres
     -- does your app crash hard, retry gracefully, or hang forever?
     Fix it to fail predictably (clear error, healthcheck reflects it)
     rather than hanging or crash-looping without useful logs.

Real-world example

The size and security difference between a naive and a properly built image is not a minor stylistic detail -- it is routinely a 5-10x size difference and the difference between an image with zero known non-root-exploitable paths and one where a container escape immediately grants root on the host. A commonly cited real comparison: a naive FROM node + npm install + copy-everything Dockerfile for a typical Node.js service often produces an 900MB-1.2GB image running as root; the equivalent multi-stage build on node:20-alpine, with a non-root user and a .dockerignore excluding node_modules and .git, commonly lands under 150MB. Snyk's annual container security reports have repeatedly found that a large fraction of the most-pulled public images on Docker Hub run as root by default and contain known-vulnerable packages baked into unnecessary build tooling that a multi-stage build would have discarded -- which is precisely the gap this project is designed to close for you personally, on a project small enough to fully understand.

Try it yourself

This chapter's project is the "try it yourself" -- work through Steps 1 through 5 above. If you don't have an existing app handy, a small Flask or Express API with a single /health and a single /items endpoint backed by Postgres is enough; the goal is the containerization discipline, not the application's business logic.

Common mistakes

  • Declaring victory once docker build succeeds, without checking image size, running user, or what docker history reveals about layer contents.
  • Using latest tags for the base image or the dependency image in compose, making the "same" setup produce different results days apart.
  • Forgetting depends_on does not wait for Postgres to be ready -- only for its container to have started (Chapter 49) -- and being surprised by intermittent connection failures on docker compose up.
  • Putting real credentials in docker-compose.yml committed to git, instead of a .env file that is itself gitignored.

Check yourself

  1. Why does a multi-stage build typically produce a dramatically smaller final image than a single-stage build, even though both install the same build tools at some point?
  2. docker compose up starts your app before Postgres is actually ready to accept connections, even with depends_on configured. Why, and what are two ways to fix it?
  3. You confirm your Dockerfile has a USER appuser line, but docker exec <container> whoami still prints root. What's a plausible explanation?

Further reading

  • Snyk, "State of Open Source Security" / container image reports (annual, search current year's edition)
  • Docker official documentation, "Best practices for writing Dockerfiles"
  • [[Chapter 48]] and [[Chapter 49]] of this guide (multi-stage builds, volumes and networking)

Generated from real-life-os-guide.md · 8 Oct 2026 16:36