Stack: Docker + compose · Project: TicketFlow Status: Published — opening the deployment module Prerequisite: Lesson 39 — Pools, pagination and compression
Objectives
- Write TicketFlow's multi-stage Dockerfile: small image, no secrets, reproducible from the lockfile.
- Build the development compose with healthchecks and volumes that don't torture hot-reload.
- Apply 27's rule to the image: one image, three environments — no baked config.
1. The multi-stage Dockerfile, line by line
Multi-stage separates BUILD (compilers, headers, dev deps) from RUN (minimal runtime):
# ---- builder ----
FROM python:3.12-slim AS builder
ENV PIP_NO_CACHE_DIR=1 PIP_DISABLE_PIP_VERSION_CHECK=1
WORKDIR /app
COPY requirements.txt requirements.lock ./
RUN pip install --prefix=/install -r requirements.lock
# ---- runtime ----
FROM python:3.12-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
libpq5 curl && rm -rf /var/lib/apt/lists/* # only libpq's runtime, not the -dev
WORKDIR /app
COPY --from=builder /install /usr/local
COPY . .
RUN python manage.py collectstatic --noinput \
&& useradd -m 10001 && chown -R 10001 /app
USER 10001
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=3s CMD curl -fsS http://localhost:8000/healthz/ | grep -q ok
CMD ["gunicorn", "ticketflow.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "4"]The details separating a grown-up image from a tutorial: --prefix=/install copies ONLY the installed venv (no pip cache, no compilers); libpq5 without the -dev (compiling stayed in builder); non-root user (a compromised container writes nothing outside its own, 22); collectstatic at build (not on every boot); the HEALTHCHECK (43 uses it to know whether the container is alive). The lockfile (requirements.lock) pins the EXACT version of every dependency (with hashes if 22's paranoid side wins): the image is reproducible — the same hash 6 months ago or today.
2. The COPY layer rules and the.dockerignore
Every COPY layer invalidates the ones after it: the order goes from LEAST to MOST changing (requirements before code: code changes daily, deps weekly — the pip cache survives the daily deploy). And the .dockerignore is .gitignore's cousin (06): it excludes .git (240 MB of history inside the image: no), .env* (27: a secret baked into an image is a leaked secret — docker history and the registry keep it), tests/, docs/, the dev sqlite and the virtualenvs. The secret test: docker history ticketflow --no-trunc | grep -i secret must return empty — and trivy image ticketflow (41 puts it in CI) for the base layer's CVEs.
3. The development compose
The compose orchestrates TicketFlow's 5 processes with healthy waits:
services:
db:
image: postgres:16-alpine
environment: {POSTGRES_USER: tf, POSTGRES_PASSWORD: tf, POSTGRES_DB: tf}
volumes: [pgdata:/var/lib/postgresql/data]
healthcheck: {test: ["CMD-SHELL", "pg_isready -U tf"], interval: 5s, retries: 10}
redis:
image: redis:7-alpine
healthcheck: {test: ["CMD", "redis-cli", "ping"], interval: 5s, retries: 10}
web:
build: .
env_file: .env # environment config (27), never baked
command: python manage.py runserver 0.0.0.0:8000
volumes:
- ./site:/app/site # bind mount: hot-reload reads your editor
- ./ticketflow:/app/ticketflow
depends_on:
db: {condition: service_healthy} # waits for HEALTH, not just "started"
redis: {condition: service_healthy}
ports: ["8000:8000"]
worker:
build: .
command: celery -A ticketflow worker -l info
depends_on: {db: {condition: service_healthy}, redis: {condition: service_healthy}}
beat:
build: .
command: celery -A ticketflow beat -l info
volumes: {pgdata: {}}Two development nuances: the runserver and the bind mount give hot-reload (the editor's code reaches the container); and worker/beat WITHOUT the runserver use the SAME image (27: one image, different commands — the command belongs to the environment, not the image). Production does NOT use this compose to "deploy" (43 decides whether it's K8s/Cloud Run/prod compose), but the variables and healthchecks are the same: the dev compose is the deployment's prototype.
4. Migrations and boot: the order that doesn't corrupt
The classic mistake: command: python manage.py migrate && runserver in the compose — three replicas boot at once and three migrates compete for django_migrations (10's lock serializes them, but the warning and the slowness don't explain themselves). The grown-up pattern: migrations are a deployment JOB (41/43), not the container's boot: the pipeline runs migrate ONCE (11: expand-contract designed for deploying), then deploys the new replicas. The container's boot only waits for health (healthcheck) and serves. wait-for-it.sh and cousins are the patch over the badly-solved problem; the compose healthcheck + the migrate job in CI/CD solve it at the root.
5. Size, security and reproducibility
The size metric: TicketFlow's runtime image lands at ~180-220 MB (slim + venv + statics): docker images is the instrument; every 100 MB is 3 extra seconds of pull per deployment (55 times a day, 41). Security: the non-root user, the base image with a pinned digest (python:3.12-slim@sha256:... so "slim" doesn't change under your feet), and the scan in CI (41). And the final reproducibility proof — 27's one:
docker build -t ticketflow:latest . # once
docker run --env-file .env.dev -p 8000:8000 ticketflow:latest
docker run --env-file .env.stag -p 8001:8000 ticketflow:latest
# same image ID in all three environments: deployment is config, not buildSelf-assessment
- What stays in each multi-stage stage, and why does the runtime carry neither pip nor libpq's
-dev? - Why requirements before code in the COPY layers, and what invalidates what? What must the.dockerignore ALWAYS have?
- The compose healthcheck: what is the difference between "started" and "healthy", and which condition does web use?
- Why is
migrate && runserverin the compose the pattern that corrupts, and where do migrations live in a grown-up deployment? - The reproducibility proof: what does the same image ID with three.env files demonstrate, and how is the base pinned so it doesn't change under your feet?
Continue with the exercises. The solutions only after trying it yourself.