CityOps works on your laptop. The client doesn't have your laptop. This lesson closes that gap: package the whole system — API, database, worker — so it runs anywhere, identically, with one command.

You'll need: the FastAPI API from Building APIs with FastAPI, and the intake-pipeline picture from the CityOps API milestone. Install Docker Desktop (or Docker Engine plus the Compose plugin) — every command below assumes you have it.

The customer problem

Monday morning, City Hall. Dev walks into the demo room with his laptop, a projector cable, and the CityOps intake system. Complaints go in; validated records come out; the dashboard counts them. It is, genuinely, working.

Maria leans in: "Can we try it on our server?"

Tom, the city's IT administrator, copies the code over. Two hours later the scorecard reads: the server runs Python 3.9 and the app needs 3.12; one dependency refuses to compile on the server's OS; the API expects Postgres at localhost:5432 and there is no Postgres; and the database password lives in Dev's shell history, not in any file. The system that took two weeks to build cannot survive the twenty-minute walk from Dev's laptop to the server room.

Maria's summary will follow us through this entire stage: "So it works — on your machine. I need it to work on ours."

Dev's instinct is to fix the server: install Python 3.12, compile the dependency, stand up Postgres, export the env var. And that would work — this once, on this server. The next city, the next laptop, the next new hire's machine, and the whole archaeology project starts over. This is the trap of manual environments: the setup is the product, and it's stored in one person's head.

Myth: "The demo worked, so deployment will be easy."
A demo is a performance — one machine, one operator, one happy path. Deployment is a property: the system can be reproduced, on demand, on a machine you've never seen, by someone who wasn't in the room. Nothing about a successful demo proves that property.

So here is the FDE principle for this lesson, and you'll feel it every time you type docker compose up from now on:

"If it works on your machine, it doesn't work yet."

Clarify the ask

Before touching Docker, get precise about what Maria actually needs. She doesn't need a faster laptop or a heroic Tom. She needs a guarantee, in plain language:

  1. What runs? Three things: the FastAPI API, a Postgres database, and the intake worker that processes queued complaints.
  2. What does each piece need? The API needs Python 3.12 plus pinned dependencies. The database needs Postgres 16 plus its data directory. The worker needs the same Python environment as the API, plus credentials.
  3. Who starts it, and what do they need to know? Tom, at 2am, during an outage. Ideally: one command, and a document he has actually read.

That reframing changes the deliverable. You're not shipping code anymore; you're shipping a package: a frozen runtime per service, a file describing how the services connect, and a runbook Tom can follow half-asleep.

Must know

  • Reproducible beats portable. A zip file of the code is portable — it travels. It reproduces nothing: the machine still has to be right. A container image packages a reproducible application runtime instead of depending on the host's manually configured userspace.
  • The environment is part of the deliverable. If Maria pays for the system but not its environment, she bought half a system.

The minimum concept

Docker has four nouns, and the whole lesson hangs on keeping them straight:

TermWhat it isAnalogy
ImageA frozen, read-only bundle: filesystem layers plus a default command. The recipe, baked.A printed cookbook.
ContainerA running instance of an image: isolated processes sharing the host's kernel.Cooking the dish, tonight.
DockerfileThe text recipe: ordered instructions that build an image.The recipe card.
LayerEach Dockerfile instruction's filesystem diff. Cached and reusable across builds.Pre-chopped ingredients.

Two facts do most of the conceptual work:

1. Containers are not virtual machines. A VM carries its own kernel; a container shares the host's kernel while isolating the application's processes, filesystem/network view, and resources. Containers generally have less virtualization overhead than full VMs and can start quickly because they don't boot a separate guest kernel. It also means an image built for Linux won't run on a Windows host without a Linux kernel somewhere underneath — Docker Desktop provides one.

2. Layers make rebuilds cheap — if you order them right. When you change your code and rebuild, Docker reuses every cached layer up to the first changed instruction, then re-runs the rest. Put the things that change rarely (dependency installs) before the things that change constantly (your code), and a rebuild takes seconds instead of minutes. This one idea separates Dockerfiles that are pleasant from Dockerfiles that make developers avoid rebuilding.

flowchart LR D[Dockerfile<br/>the recipe] -->|docker build| I[Image<br/>frozen layers] I -->|docker run| C1[Container A<br/>running instance] I -->|docker run| C2[Container B<br/>another instance] I -->|docker run| C3[Container C<br/>yet another]

One image, many containers. Delete a container and the image is untouched; delete the image and you can rebuild it from the Dockerfile. That rebuildability is the whole point — it's what lets you promise Maria that the city can reproduce this system on demand.

Build

Let's package the CityOps API. Assume the repo looks like this: app/ holds the FastAPI code, requirements.txt pins the dependencies — for this lesson, assume it contains exact versions (e.g. fastapi==0.115.0); a later dependency-management lesson can cover lock files and hashes. (If you worked the FastAPI lesson and the Pydantic lesson, this is the API you already have.)

The Dockerfile

# syntax=docker/dockerfile:1
FROM python:3.12-slim-bookworm

ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1

WORKDIR /srv/cityops

# Dependencies FIRST: this layer only rebuilds when requirements.txt changes.
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# Application code LAST: editing code doesn't re-trigger pip install.
COPY app/ ./app/

# Run as a non-root user: a compromised process gets fewer privileges.
RUN useradd --create-home --shell /bin/bash appuser \
    && chown -R appuser:appuser /srv/cityops
USER appuser

EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

Walk through the decisions, because each one answers a question Maria or Lisa would ask:

  • python:3.12-slim-bookworm, not python:latest. The versioned tag narrows what you build from; an image digest is what pins the exact base-image content. latest is a moving nickname; 3.12-slim-bookworm constrains the version family; python:3.12-slim-bookworm@sha256:... is the immutable identity. (Post 2 will pin digests in CI.)
  • COPY requirements.txt before COPY app/. Layer-cache ordering, from the minimum-concept section. Dependencies install once; code changes rebuild in seconds.
  • Non-root appuser. Lisa's standing question: "What's the blast radius if this process is compromised?" Container boundaries reduce exposure, but running the application as non-root removes unnecessary privileges inside the container and reduces impact if the process is compromised. Cheap to add, hard to retrofit.
  • --host 0.0.0.0. Inside a container, localhost means the container, not your laptop. Binding to all interfaces is what makes the port reachable from outside the container.

The .dockerignore

The build context — everything Docker sends to the daemon — should not include your git history, your .env file, or your bytecode caches. Three layers to keep straight: the build context is what COPY can access during build; the container filesystem is what the built image contains; runtime mounts and env are what gets injected when the container starts. Note what's doing security work here: excluding .env prevents that file from accidentally entering the build context; it's one layer of secret hygiene, not proof that the image contains no secrets. Lisa will ask about this; you'll have an answer with evidence.

.git
.env
__pycache__/
*.pyc
.pytest_cache/
tests/
*.md

This runtime build doesn't need tests in its context; CI should run them before publishing the image — which connects directly to the next post on CI/CD.

Wiring services together: Compose

One container is a demo. We'll first compose API + database, then add the worker in the CityOps section below. Writing two docker run commands with the right flags is possible and is also how you end up with the setup living in someone's shell history. Compose replaces the archaeology with a file:

services:
  api:
    build: .
    ports:
      - "8000:8000"
    environment:
      DATABASE_URL: postgresql+psycopg://cityops:${DB_PASSWORD}@db:5432/cityops
    env_file:
      - .env
    depends_on:
      db:
        condition: service_healthy
    restart: unless-stopped

  db:
    image: postgres:16-bookworm
    volumes:
      - pgdata:/var/lib/postgresql/data
    environment:
      POSTGRES_USER: cityops
      POSTGRES_PASSWORD: ${DB_PASSWORD}
      POSTGRES_DB: cityops
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U cityops -d cityops"]
      interval: 10s
      timeout: 5s
      retries: 5
    restart: unless-stopped

volumes:
  pgdata:

Note on the Compose file: ${DB_PASSWORD} in the YAML is interpolated by Compose from your shell environment or .env file during config resolution. The env_file entry separately supplies variables into the running container. They're related but distinct — Compose interpolation happens before the container starts; env_file affects what the process sees at runtime. For this lesson, .env serves both roles; production environments may inject secrets through their approved secret-management mechanism instead.

Then, from the repo root:

$ docker compose up --build
# ... the API answers at http://localhost:8000, backed by Postgres

Three Compose concepts are doing quiet work here, and each maps to one of Maria's three questions:

  • Service names are hostnames. The API reaches the database at host db — that's the service name, resolved by Compose's private DNS. This is the networking concept: services talk to each other by name on an isolated network, and only the ports you publish (like 8000) are visible from outside. localhost inside the API container means the API container itself — a classic source of "works on my machine" bugs, which we'll break on purpose below.
  • volumes: pgdata is where the data lives. Containers are ephemeral by design; named volumes are persistent storage outside the container's writable layer. Delete every container and the complaints survive. This is the volumes concept, and the demo-room disaster's database problem, solved structurally.
  • depends_on with service_healthy. "The database container started" is not "the database is ready." The healthcheck runs pg_isready as a database-service readiness signal — it confirms Postgres accepts connections, though application readiness may require a real connection using application configuration and any required schema/migrations. The API waits for that signal, not just for the container to exist. Ordering by readiness, not by hope.
flowchart TB subgraph host["Tom's server — one command: docker compose up"] subgraph net["compose private network"] API[api<br/>FastAPI :8000] -->|host: db| DB[(db<br/>postgres:16)] end VOL[(pgdata<br/>named volume)] -.->|survives container restarts| DB end YOU[you: localhost:8000] -->|published port| API

Prove it runs

Before calling it done, verify the three properties the Compose file promises — readiness, reachability, durability:

$ docker compose ps
# db reports healthy; api reports running. curl /health verifies the API separately.

$ curl localhost:8000/health
# the API answers through the published port

$ docker compose down && docker compose up -d
# containers are recreated; pgdata persists, so the data is still there
# (note: docker compose down -v would also remove the named volume — don't run that unless you mean it)

If any of those fails, the Compose file is lying about something — fix the file, not your memory of what you typed. And keep this Dockerfile vocabulary handy; you'll read it in every image you ever audit:

InstructionWhat it does
FROMSets the base image. Pin the tag — this line decides your Python version.
WORKDIRSets the working directory; later instructions run relative to it.
COPYCopies files from the build context into the image. Order matters for caching.
RUNExecutes a command at build time; each one creates a layer.
USERSwitches the user for subsequent instructions and the running container.
EXPOSEDocuments the port; it doesn't publish it — Compose's ports: does that.
CMDThe default command when the container starts. Overridable per service, as the worker shows.

Break

It runs. Now let's break it three ways — each one a real failure mode Dev hit (or would have hit) on the way to the client's server.

Break 1: The dependency-reinstall trap

Dev's first Dockerfile copied the code first and installed dependencies second:

# THE SLOW WAY — do not do this
COPY app/ ./app/
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

Every keystroke in app/ invalidates the layer cache from that line down — including the pip install. Each rebuild re-downloads and reinstalls every dependency. On a decent connection that's a minute or two of thumb-twiddling per change; on the client's slow network, five. Developers respond rationally: they stop rebuilding, start editing code inside running containers, and the Dockerfile quietly stops describing reality. The ordering rule from the minimum-concept section isn't a style preference; it's what keeps the build loop honest: order the Dockerfile from slowest-changing to fastest-changing.

Break 2: The disappearing database

Dev's first Compose file had no volumes: entry for db. It worked for the demo. Then Tom ran docker compose down to change a port mapping, brought the stack back up, and the morning's complaints were gone. The container's filesystem died with the container — that's the design; containers are cattle.

Maria's version of this question is worth hearing in her voice: "We lost the morning's complaints? The ones residents actually filed?" The fix is the named volume in the Compose file above: pgdata lives outside any container's lifecycle. docker compose down removes containers and networks; the volume — and the data — survives. The productionize section will add the follow-up Tom needs: volumes need backups too, because "survives container restarts" is not "survives disk failure."

Break 3: Works in Compose, dies on the server

The subtlest one. Dev's code read the database host from a default:

# THE TRAP — a default that only works on a laptop
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql+psycopg://cityops:dev@localhost:5432/cityops")

On the laptop, with Postgres installed natively, localhost was correct. In Compose, localhost inside the API container means the API container — Postgres is at host db. The app boots, can't connect, and fails. The general rule: code reads config from the environment; the environment is supplied by whatever runs the code. On the laptop that's a shell export; in Compose it's the environment: block; on the client's server it'll be their secrets setup. The code itself stays identical — which is exactly the property Maria is buying.

And note what the .dockerignore plus env_file combination just proved for Lisa: the password is never in the repo, never in the image, never in the Dockerfile. It enters at runtime, from a file that isn't committed. Keeping credentials out of the image is different from protecting them at runtime — processes with appropriate access, container inspection, or misconfiguration can still expose runtime secrets. If the image leaks to a third-party registry, what's inside? Code and dependencies. Not the .env file.

Productionize

The stack runs. Now harden it for a server nobody babysits.

  • Pin everything, including the base. python:3.12-slim-bookworm is a version-family tag, not an immutable pin — it narrows the Python version but the base image can move with OS/security rebuilds. For exact reproducibility, production builds pin the resolved digest (image@sha256:...). The app's own image gets a version tag per release (Post 2 builds the pipeline that produces those tags). Treat an unpinned tag as a promise you can't keep.
  • Healthchecks on every service you depend on. We health-checked Postgres and gated the API on service_healthy. Add one to the API too — a /health endpoint that the orchestrator can poll. The healthcheck makes unhealthy state visible to operators and orchestrators; define what /health is intended to prove rather than assuming one endpoint covers process liveness, database readiness, and dependency health.
  • restart: unless-stopped. With the Docker service configured to start on boot, unless-stopped allows stopped or crashed containers to come back according to the restart policy. Note the distinction: restart policies recover processes that exit; a container that stays running but reports unhealthy won't be restarted by the policy alone — health status is visibility, not recovery. More advanced orchestration can react to failed health checks.
  • Non-root everywhere. Already done for the API. Check the worker image too when you add it in the CityOps section.
  • Keep secrets out of the image — verify, don't assume. After building, run a sanity check (not proof): docker run --rm your-image grep -r "DB_PASSWORD" /srv/cityops should find nothing. But a single grep searches for the variable name, not necessarily the secret value, and secrets can hide in layers or files you didn't expect. Inspect the built image and build process for unexpected secret material; automated secret scanning in CI is stronger than a manual grep. Trust the .dockerignore, then verify the .dockerignore.
  • Back up the volume. Named volumes are persistent storage outside the container's writable layer — they survive container restarts, not disk failure. Backups need a schedule, protected storage, retention, and restore testing. A backup you haven't restored is an assumption. Tom's runbook needs more than a pg_dump cron line; it needs a restore drill.

Useful later

  • Multi-stage builds — compile or install in one stage, copy only the artifacts into a slim runtime stage. Multi-stage builds can reduce what ends up in the runtime image, often reducing size, pull time and unnecessary attack surface. Reach for it when image size or pull time becomes the bottleneck.
  • Resource limits — cap CPU and memory once you know the workload's realistic requirements, so one service can't starve the host. (Compose deploy.resources syntax varies by version and mode; pin down the exact keys when you implement this in a production lesson, not here.)
  • Docker Swarm / Kubernetes — Compose runs one host well, which is what the city has today. Orchestrators solve multi-host scheduling; they're a migration, not a starting point.

Communicate

The technology is done; now it has to survive contact with two stakeholders who don't read Dockerfiles.

Tom asks: "So what do I actually type?" The runbook, in full:

git clone https://github.com/cityops/cityops.git
cd cityops
cp .env.example .env        # fill in DB_PASSWORD and friends
docker compose up -d --build
docker compose ps           # db "healthy", api "running"; curl /health to verify the API

Four application-specific commands once the documented host prerequisites are satisfied — Docker installed and running, Git and network access, repository access, .env values filled in, sufficient disk, ports available. If a step fails, the failure is in the open — a missing .env value errors loudly at startup instead of manifesting as a silent wrong-password connection failure three layers deep.

This is the Stage 1 deployment workflow: it builds the image on the customer's server, from base tags and dependencies that can move. Maria's question — is the image running the one you tested? — is exactly what the next lesson solves: CI builds and tests one immutable image, and Tom pulls that exact image instead of rebuilding on the server.

Maria asks the business version: "Does this mean every city gets the same software?" Yes — and now you can say why in one sentence: each release ships the same application image, and environment-specific configuration and data stay outside it. The same release image runs across compatible container hosts. "Works on my machine" has been replaced by "runs this image," and the image is versioned, so when Maria asks which version a city is running, there's an answer with a number in it. Reproducibility means the customer's server should not depend on remembering what the developer did last Tuesday.

Which raises her next question — the one that opens Post 2: "How do I know the image on the server is the one you tested?" Hold that thought. It's the whole next lesson.

CityOps

Let's assemble the full intake stack: the API, Postgres, and the intake worker from the CityOps API milestone — the pipeline that validates and stores incoming complaints. The worker shares the API's Python environment, so it builds from the same Dockerfile with a different command:

services:
  api:
    build: .
    ports:
      - "8000:8000"
    environment:
      DATABASE_URL: postgresql+psycopg://cityops:${DB_PASSWORD}@db:5432/cityops
    env_file:
      - .env
    depends_on:
      db:
        condition: service_healthy
    restart: unless-stopped

  worker:
    build: .
    command: ["python", "-m", "app.intake_worker"]
    environment:
      DATABASE_URL: postgresql+psycopg://cityops:${DB_PASSWORD}@db:5432/cityops
    env_file:
      - .env
    depends_on:
      db:
        condition: service_healthy
    restart: unless-stopped

  db:
    image: postgres:16-bookworm
    volumes:
      - pgdata:/var/lib/postgresql/data
    environment:
      POSTGRES_USER: cityops
      POSTGRES_PASSWORD: ${DB_PASSWORD}
      POSTGRES_DB: cityops
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U cityops -d cityops"]
      interval: 10s
      timeout: 5s
      retries: 5
    restart: unless-stopped

volumes:
  pgdata:

The worker polls the database-backed intake queue introduced in the CityOps API milestone, validates each complaint with the Pydantic models from the validation lesson, and writes clean records to the same database the API serves. API and worker use the same application image because they share the same codebase and runtime; Compose gives each service a different startup command. One docker compose up --build starts all three; one docker compose down stops them; the data persists in pgdata either way.

flowchart LR subgraph build["built once"] DF[Dockerfile] -->|docker build| IMG[cityops image<br/>versioned tag] end subgraph server["city server — docker compose up"] IMG --> API[api<br/>:8000] IMG --> WRK[worker<br/>intake queue] API --> DB[(db<br/>postgres)] WRK --> DB DB --- VOL[(pgdata<br/>named volume)] end

That "built once" box shows the target release model — the diagram is aspirational for this lesson. Right now Compose builds the image locally on Dev's machine via build: . in each service; the next lesson has CI produce one tagged image and both services reference that exact image instead of rebuilding. The diagram is where we're headed; the Compose file is where we are.

Field check

  1. Maria asks why she should care about Docker at all. In business terms — not technical ones — what breaks if Dev keeps shipping "works on my machine"?
  2. Your Dockerfile copies requirements.txt and runs pip install before copying app/. Why that order?
  3. You run docker compose down and both containers are gone. Where are yesterday's complaints, and why?
  4. The API connects to the database with host db. Why not localhost?
  5. Lisa asks: "This image is going to a third-party registry. Are any secrets inside it?" How do you answer — and what in this lesson is your evidence?
Answers

1. Every new machine — the client's server, the next city, a new hire's laptop — becomes a bespoke archaeology project: install the right Python, compile the right dependencies, stand up the database, rediscover the env vars. That costs Tom's hours per deployment, delays every rollout, and makes each environment subtly different, so bugs reproduce in one place and not another. Docker converts that recurring labor into a one-time package.

2. Docker caches image layers and only rebuilds from the first changed instruction down. Dependencies change rarely; code changes constantly. Installing first means code edits rebuild in seconds instead of re-running pip install every time — which is what keeps developers actually rebuilding instead of drifting away from the Dockerfile.

3. In the pgdata named volume, intact. Containers are ephemeral by design — down removes them — but named volumes live outside any container's lifecycle, so the data survives restarts, re-creates, and port remapping. (Disk failure is a different story: that's what Tom's pg_dump backups are for.)

4. Inside a container, localhost means that container itself. Compose gives each service a DNS name on its private network, so the database's hostname is the service name db. Hardcoding localhost is a laptop-ism: correct when Postgres runs natively beside the app, wrong the moment the app is containerized.

5. The design keeps the demonstrated database password out of the repo and image: .env is excluded from the build context, the Dockerfile doesn't embed it, and Compose injects it at runtime. That is evidence for this credential path — not a proof that an arbitrary image contains no secrets. A single grep for the variable name is a sanity check, not an audit; automated secret scanning in CI is the stronger control. And remember: keeping credentials out of the image is different from protecting them at runtime.