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 buildsucceeds, without checking image size, running user, or whatdocker historyreveals about layer contents. - Using
latesttags for the base image or the dependency image in compose, making the "same" setup produce different results days apart. - Forgetting
depends_ondoes not wait for Postgres to be ready -- only for its container to have started (Chapter 49) -- and being surprised by intermittent connection failures ondocker compose up. - Putting real credentials in
docker-compose.ymlcommitted to git, instead of a.envfile that is itself gitignored.
Check yourself
- 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?
docker compose upstarts your app before Postgres is actually ready to accept connections, even withdepends_onconfigured. Why, and what are two ways to fix it?- You confirm your Dockerfile has a
USER appuserline, butdocker exec <container> whoamistill printsroot. 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)