One Dockerfile, one compose file, migrations on boot, a real healthcheck. Your project now starts on a machine that has never seen it — no cloud account involved.
In Module 00 you used Docker to get Postgres without understanding a line of it. That was deliberate. This module is where the black box opens.
Dockerfile describing the layers, run docker build, and get an image you can start anywhere Docker exists.Say the goal out loud before you build anything. The skill this module gives you is: your project starts on a machine that has never seen your code. Not a machine with the right Python version. Not a machine where someone already installed Postgres. A machine with Docker and nothing else. Once you have that, pushing the same image to a hosting provider is a small, vendor-specific step — a form to fill in, not an engineering problem. That is why this module has no cloud account in it and loses nothing by not having one.
Image versus container. If you have met classes and instances, this is the same relationship: an image is the class, a container is an instance. One image can have zero containers running from it, or forty. Each container gets its own writable layer stacked on top of the image's read-only ones, so two containers from the same image cannot see each other's scribbles — and when a container is deleted, that writable layer goes with it. This is exactly why databases live in volumes and not in containers.
Why "works on my machine" stops being a sentence anyone says. That sentence was always shorthand for "my machine has some state I cannot enumerate" — a Python version, a system library, an environment variable set two years ago. An image is that state, enumerated, in a file you committed. If it runs from the image on your laptop, it runs from the image on mine, because it is not running on your laptop's filesystem at all.
docker pull postgres:16 in Module 00 quietly talked to a registry. You do not need one in this module — a locally built image runs perfectly well without ever being pushed.python:3.12-slim is a tag, and the image it points at changes when the maintainers publish a security patch. Convenient, and not reproducible.python@sha256:9b2e… identifies exactly one set of bytes forever. Production systems pin digests for the same reason Module 00 told you to pin fastapi==0.115.0 instead of fastapi.FROM python:3.12-slim store that base once on disk. This is also the mechanism that makes rebuilds fast — and the next section is entirely about not throwing it away.requirements.txt, named exactly Dockerfile with no extension.# Dockerfile
FROM python:3.12-slim
# logs go straight to stdout instead of sitting in a buffer
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1
WORKDIR /app
# dependencies FIRST, on their own, so this layer caches
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# source LAST, because it changes on every single edit
COPY . .
# run as a normal user, not root
RUN useradd --create-home --uid 1000 appuser \
&& chown -R appuser:appuser /app
USER appuser
EXPOSE 8000
CMD ["./entrypoint.sh"]
FROM python:3.12-slim — every image starts from another image. Three choices exist and only one is right here. python:latest is a moving target that will change under you on a Tuesday. python:3.12 is the full Debian image at roughly 1 GB, carrying compilers and tooling your running app never touches. python:3.12-slim is around 150 MB and has what an interpreter needs. Pick -slim, pin the minor version, move on.
WORKDIR /app creates the directory and makes it the working directory for every later instruction and for the container at runtime. Without it, relative paths in COPY and CMD resolve against /, and your app ends up scattered across the root of the filesystem.
The COPY ordering is the whole trick. Docker caches each layer and reuses it if that instruction and its inputs are unchanged. The moment one layer misses, every layer below it is rebuilt too — the cache is a prefix, not a set. You edit source constantly and requirements.txt almost never. So copy the file that rarely changes first, install against it, and copy the volatile source afterwards.
| Ordering | Edit one line of app/main.py, rebuild |
|---|---|
COPY requirements.txt → pip install → COPY . . | pip layer is a cache hit. Rebuild is 1–3 seconds. |
COPY . . → pip install | the copy layer changed, so pip reinstalls every dependency. 45–120 seconds, every time. |
You will do this by hand in the break step and time both. Do not take the numbers on trust — the felt difference between a three-second loop and a ninety-second loop is the actual lesson.
ENV PYTHONUNBUFFERED=1 matters more than it looks. When Python's stdout is not a terminal — and inside a container it is a pipe — Python buffers output and flushes it in blocks. The structured request logs you built in Module 10 would then appear in clumps, seconds late, or not at all if the container is killed before a flush. You would spend an hour convinced your logging is broken when it is merely late. One environment variable removes the entire failure mode.
USER appuser — by default everything in a container runs as root. Container root is not the same as host root, but it is one misconfiguration away from being interesting to an attacker, and it means a bug in your app can rewrite its own source. Create an ordinary user, hand it the app directory, and switch. Note the position: it comes after the pip install, because installing into system site-packages needs root, and before CMD, so the process starts unprivileged.
EXPOSE 8000 publishes nothing. It is documentation baked into the image metadata saying "this thing listens on 8000". Compose still needs a ports: entry to reach it from your laptop. Write it anyway — it is how the next person knows which port to map.
CMD has no --reload. The auto-reloading server you have run since Module 02 watches the filesystem and restarts on change. In an image the filesystem does not change, so reload buys nothing and costs a file-watcher process and a class of restart bugs. Development convenience, not a runtime feature.
Dockerfile. Same syntax as .gitignore, entirely separate file.COPY . . means "copy the build context", and the build context is whatever your machine sent. Without a .dockerignore that is your entire project folder, including things that are useless, enormous, or dangerous.
# .dockerignore
.venv/ # the wrong platform's compiled binaries, ~200MB
.git/ # every version of every file you ever committed
.env # SECRETS. never. see below.
__pycache__/
*.pyc
.pytest_cache/
.coverage
notes/
*.md
docker-compose.yml
.venv/ is the one that surprises people. It holds compiled extension modules built for your operating system and CPU. Copied into a Linux image built on an Apple Silicon Mac, they are megabytes of files that cannot even load — and they shadow nothing, because pip install already put the correct ones in the image. Pure cost.
.git/ is history the running process has no use for. On a project a year old it is often larger than the source. It also contains every secret anyone ever committed and removed.
.env in an image is a leaked secret, exactly like .env in a git commit. An image is a stack of files, and any file baked into a layer can be read back out by anyone who can pull that image — without running it, without the password, with one command: docker run --rm your-image cat /app/.env. Deleting the file in a later RUN does not help, because the earlier layer still contains it, the same way a later git commit does not erase an earlier one. Secrets are injected at run time through the environment, never copied in at build time. This is the single highest-consequence line in the file.
.venv and .git commonly turns a fifteen-second "sending build context" pause into an instant one..gitignore. They overlap heavily and diverge in both directions: .env belongs in both, but docker-compose.yml is committed to git and pointless inside the image.*.txt silently removes requirements.txt and your build fails at pip install with a file-not-found. It is in the debugging section for a reason.docker-compose.yml you already have from Module 00, now grown from one service to two or three.api service built from your Dockerfile, a healthcheck on db, and a depends_on condition tying them together.# docker-compose.yml
services:
db:
image: postgres:16
environment:
POSTGRES_USER: cityguide
POSTGRES_PASSWORD: localdevpassword
POSTGRES_DB: cityguide
ports:
- "5432:5432" # only so YOU can run psql from the host
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U cityguide -d cityguide"]
interval: 3s
timeout: 3s
retries: 10
api:
build: . # build from the Dockerfile in this folder
ports:
- "8000:8000"
environment:
# hostname is 'db', NOT localhost - see below
DATABASE_URL: postgresql://cityguide:localdevpassword@db:5432/cityguide
AUTH_JWKS_URL: http://auth:9000/.well-known/jwks.json
env_file:
- .env.docker # optional; 'environment' wins on conflicts
depends_on:
db:
condition: service_healthy
auth: # only if you built Module 08
build: ./auth_service
environment:
LOG_LEVEL: info
volumes:
pgdata:
Service-name DNS is the idea to take away. Compose puts every service on one private network and runs a DNS resolver on it, where each service name resolves to that container's address. So db is a hostname. Inside the api container, postgresql://…@db:5432/… works and postgresql://…@localhost:5432/… does not — because localhost inside a container means that container, and there is no Postgres in it.
This is the published-port lesson from Module 00 seen from the other side. Back then you asked "why can my laptop reach the container?" and the answer was the ports: line. Now the question is "how does one container reach another?" and the answer is: it already can, by name, on the container port, with no published port involved at all. Delete db's ports: block entirely and the API keeps working perfectly — you would only lose your own ability to run psql from the host. Publishing is for traffic from outside the network, never for traffic within it.
depends_on alone is a trap, and it produces the most confusing bug in this module: the stack fails on the first up and works on the second. Plain depends_on: [db] means "start the db container before the api container". It says nothing about Postgres being ready. Postgres takes a couple of seconds to initialise and begin accepting connections, and your API — which connects at import time — gets ConnectionRefusedError and exits during that window. The second time you run it the database is already warm, so it works, and you conclude the problem was transient. It was not.
The healthcheck is the fix. pg_isready is a small program shipped inside the Postgres image that returns exit code 0 only when the server is accepting connections. Compose runs it every 3 seconds, marks the container healthy on the first success, and condition: service_healthy holds api back until that happens. Now "started" means "usable", which is what you meant all along.
environment: is inline and committed; env_file: points at a file that is not. Use environment for values that are not secret (hostnames, log levels) and env_file for the ones that are. On a conflict, environment wins..env from Module 00 will not work unchanged — it says localhost. Keep it for running the app outside Docker, and keep a separate .env.docker for inside. The differing hostname is the whole reason two files exist.build: . versus image: — build makes the image from your Dockerfile, image pulls a finished one from a registry. db uses a published image; api is yours, so it builds.alembic upgrade head?head, it does nothing and exits 0.CMD, ending in exec uvicorn.Until now you ran alembic upgrade head by hand after writing a migration in Module 05. That worked because you were there. On a machine that has never seen your code, nobody is there — and an empty database plus an app expecting a places table produces a stack of UndefinedTable errors that look like a code bug and are not.
# entrypoint.sh
#!/bin/sh
set -e # stop on the first failure
echo "running migrations..."
alembic upgrade head # no-op if already up to date
echo "starting server..."
exec uvicorn app.main:app --host 0.0.0.0 --port 8000
chmod +x entrypoint.sh # do this on the host, BEFORE building
git update-index --chmod=+x entrypoint.sh # keep the bit in git
Three details in three lines. set -e means a failed migration aborts the script instead of starting a server against a half-migrated database — you want a container that dies loudly, not one that serves wrong answers. exec replaces the shell process with uvicorn rather than spawning it as a child, so uvicorn becomes PID 1 and receives the SIGTERM Docker sends on docker compose down; without exec the signal goes to the shell, uvicorn never hears it, and every shutdown takes a ten-second timeout before Docker gives up and kills it. And --host 0.0.0.0 has its own paragraph in the debugging section, because getting it wrong is the break step.
The honest trade-off. Migrate-on-boot is right for one instance and wrong for many. Run five copies of this image behind a load balancer and all five start simultaneously, all five run alembic upgrade head, and they race — Postgres will serialise some of it with locks, but you can still get a failed start or, worse, two instances briefly disagreeing about the schema. Larger systems therefore run migrations as a separate step that must finish before any app instance starts: a one-off job, a deploy pipeline stage, a human with a runbook.
| Entrypoint migrates | Separate migration step | |
|---|---|---|
| Setup cost | three lines | a pipeline or an orchestrator job |
| Instances | correct for exactly one | correct for any number |
| Failure mode | N instances race on startup | deploy stops before the app rolls out |
| Use it | here, and in this module | when you have more than one instance |
package.json and package-lock.json before npm ci, then the source; Rails copies Gemfile and Gemfile.lock before bundle install. Four lines differ. The entrypoint runs npx prisma migrate deploy or rails db:migrate instead of alembic upgrade head. Layer caching, the non-root USER, binding to 0.0.0.0, and readiness checks are identical in all three — they are properties of containers, not of languages. Learn them once.
docker compose build # build only, read the errors
docker compose build --no-cache # ignore the cache - proves a "cached" bug
docker compose up -d --build # rebuild AND start
docker compose ps # what is running, and is it healthy?
docker compose logs -f api # follow this service's output (Ctrl+C to stop)
docker compose exec api bash # a shell INSIDE the running container
docker compose exec api env # what environment did it actually get?
docker image ls # how big did my image get?
docker compose down -v # stop everything and delete the volumes
docker compose exec api bash is the one that changes how this feels. You are now standing inside the container's filesystem. ls /app shows what actually got copied. cat /app/.env tells you whether you leaked a secret. python -c "import fastapi" tells you whether the install worked. Stop reasoning about the image from outside and go look.
Failure one: the app binds to 127.0.0.1. You start the stack, everything says "Up", the logs say "Uvicorn running on http://127.0.0.1:8000", and curl localhost:8000/health from your laptop fails. Nothing is broken except one address. A container has its own network interfaces, and 127.0.0.1 inside it is that container's own loopback — reachable only by processes in the same container. Docker's port forwarding arrives on the container's external interface, finds nothing listening there, and the connection dies. --host 0.0.0.0 means "listen on every interface I have", which includes the one Docker forwards to. On your laptop this never mattered, because the forwarder and the server were the same machine.
Failure two: a file is missing because of .dockerignore. The build dies at COPY or pip install with a path that plainly exists on your disk. It exists on your disk and it was never sent to the builder. Confirm it in two seconds: docker compose exec api ls -la /app, or on a failing build, temporarily comment out the suspect line in .dockerignore and rebuild.
Failure three: a stale image. You changed app/main.py, ran docker compose up -d, and your change is not there. up does not rebuild — it starts whatever image already exists. This is the correct behaviour and it will still fool you a dozen times. Use docker compose up -d --build while iterating, and when a change refuses to appear even then, --no-cache tells you whether you are fighting the cache or fighting your own code.
docker compose ps distinguishes "Up" from "Up (healthy)". If db never reaches healthy, your pg_isready arguments are wrong and api will wait forever on a condition that cannot come true..dockerignore or a non-slim base. Check with docker image ls, then look inside with exec … du -sh /app/*.docker compose logs api holds the traceback. A container lives exactly as long as its main process; when uvicorn crashes on a bad DATABASE_URL, the container is simply gone.No signup, no vendor, no account. This is the shape of the thing, so that when you meet a real platform you recognise which problem each of its features is solving.
docker compose up on your laptop and a service the public can use.| On your laptop | In a real deployment |
|---|---|
build: . builds locally | you push the image to a registry; the server pulls it by tag or digest |
http://localhost:8000 | a reverse proxy in front, terminating TLS so the public sees https:// |
| Postgres in a container with a volume | a managed or backed-up database — a container volume has no backups and no failover |
env_file: .env.docker | secrets injected by the platform at start; no secret file exists on disk |
one api container | several instances behind a load balancer, which is what makes migrate-on-boot wrong |
you check /health with curl | the platform calls your readiness endpoint from Module 10, constantly, and pulls unhealthy instances out |
restart by rerunning up | rolling deploys — new instances proven healthy before old ones stop — and a one-command rollback to the previous image |
docker compose logs -f | logs shipped to a central system, because instances are ephemeral and their disks go with them |
Read that table as a claim about effort. Every row on the right is a configuration exercise: a YAML block, a dashboard field, a CLI flag. Not one of them asks you to restructure your application — provided the application already reads its config from the environment (Module 10), already exposes a readiness endpoint (Module 10), already logs to stdout (this module's PYTHONUNBUFFERED), and already starts from an image (this module). That is the actual reason this module exists, and the reason it does not need a cloud account to teach you the hard part.
docker push you now understand fully. Everything after it is one vendor's opinion about how to run a container, and there are perhaps six such opinions in the industry.docker compose down and nothing else — not a disk failure, not down -v, not a machine going away. This is the single most common way people lose real data.api containers on different published ports, put a reverse proxy container in front, and you have reproduced the load-balancer problem on your laptop for free. Optional, and genuinely instructive.docker compose up -d --build is the only command anyone needs. It builds your API into an image, waits for Postgres to be genuinely ready, runs the Module 05 migrations automatically, and serves the Module 10 health endpoint on port 8000. No virtual environment, no manual alembic, no local Python required.
.dockerignore first, before the Dockerfile. Confirm .env, .venv/ and .git/ are all in it.Dockerfile exactly as above, and entrypoint.sh with chmod +x. Build it alone with docker compose build before trying to run anything.docker-compose.yml with the api service, the db healthcheck, and depends_on: condition: service_healthy. Add the auth service too if you built Module 08.docker compose down -v then docker compose up -d --build. The migrations must run on their own.entrypoint.sh to --host 127.0.0.1, rebuild, and curl from your laptop — watch the connection die while the logs insist the server is running. Put it back. (b) Move COPY . . above the pip install line, then time docker compose build after a one-character edit to app/main.py, in both orderings..dockerignore, entrypoint.sh and the compose file. Write the two rebuild timings into notes/module-11.md.The last check is the one that matters: destroy everything and bring it back with one command. If that works, your project runs on a machine that has never seen it.
$ docker compose up -d --build
...
Container city-guide-db-1 Healthy # note: HEALTHY, not just Started
Container city-guide-api-1 Started # and it started after db was healthy
$ docker compose ps
NAME SERVICE STATUS
city-guide-api-1 api Up 20 seconds
city-guide-db-1 db Up 25 seconds (healthy)
$ curl -s -o /dev/null -w "%{http_code}\n" localhost:8000/health
200 # anything else -> docker compose logs api
$ docker compose logs api | head -3
api-1 | running migrations...
api-1 | INFO [alembic.runtime.migration] Running upgrade -> a1b2c3, create places
api-1 | starting server... # migrations ran without you
$ docker compose exec db psql -U cityguide -d cityguide -c "\dt"
Schema | Name | Type | Owner
--------+-----------------+-------+-----------
public | alembic_version | table | cityguide
public | places | table | cityguide
public | tags | table | cityguide # your migrated tables exist
$ docker compose exec api env | grep DATABASE_URL
DATABASE_URL=postgresql://cityguide:localdevpassword@db:5432/cityguide
# hostname 'db', NOT localhost
$ docker image ls city-guide-api
REPOSITORY TAG SIZE
city-guide-api latest 243MB # under ~400MB. Bigger = check .dockerignore
$ docker compose exec api whoami
appuser # not root
# --- the real test: from absolutely nothing ---
$ docker compose down -v && docker compose up -d --build
$ curl -s -o /dev/null -w "%{http_code}\n" localhost:8000/health
200 # empty database, migrated, serving
# --- the deliberate break: bind to 127.0.0.1, rebuild, then ---
$ curl localhost:8000/health
curl: (56) Recv failure: Connection reset by peer
# or "Empty reply from server" - either way, nothing
# is listening on the interface Docker forwards to
$ docker compose logs api | tail -1
api-1 | INFO: Uvicorn running on http://127.0.0.1:8000
# the server is fine. the address is not.
down -v followed by up does not work, you are not finished. A stack that only comes up on a machine where you already did something by hand is exactly the problem this module exists to remove. Fix it now, while you remember what you did.
Write your answers in notes/module-11.md before opening any of them. Where your answer differs from the one below, work out which of you is right — that is where the learning is.
1. Why is requirements.txt copied and installed before the application source, rather than copying everything at once?
Because Docker's layer cache is a prefix, not a set. Each instruction produces a layer, and a layer is reused only if that instruction and every layer above it are unchanged. Source files change constantly; dependencies almost never. Copying only requirements.txt first means the expensive pip install layer stays valid across every source edit, and rebuilds take seconds. Copy everything first and the copy layer changes on each edit, invalidating the install layer beneath it and reinstalling every dependency from scratch. The cost is not correctness — both orderings produce a working image — it is your iteration loop, which decides how often you are willing to rebuild.
2. Your API connects to db:5432, not localhost:5432. What is db, and why does localhost fail?
db is a hostname. Compose creates a private network for the project and runs a DNS resolver on it where every service name resolves to that container's address, so db is a real, resolvable name from inside api. localhost fails because each container has its own network namespace: inside api, localhost means the api container itself, which is not running Postgres. Note what this makes optional — db's published ports: line exists purely so you can run psql from your laptop. Delete it and the API is unaffected, because container-to-container traffic never crosses the host at all.
3. Why is depends_on: [db] on its own not sufficient, and what symptom does it produce?
Plain depends_on only orders container starts. It waits for the db container to exist, not for the Postgres process inside it to finish initialising and begin accepting connections — a gap of a couple of seconds. Your API connects during startup, gets a connection refused, and exits. The symptom is uniquely misleading: it fails the first time and works every time after, because the database is already warm on the second attempt. People conclude the failure was transient and ship it, and it then fails on every fresh machine and every CI run. condition: service_healthy plus a pg_isready healthcheck makes "started" mean "usable".
4. You forgot .env in .dockerignore and built the image. Who can read your secrets, and does deleting the file in a later layer help?
Anyone who can obtain the image — pull it from the registry, or copy the tar — can read it, without running the container and without any credential of yours: docker run --rm your-image cat /app/.env, or simply unpack the layers. Deleting it in a later RUN rm does not help at all. Layers are additive and immutable; the deletion adds a whitelist entry in a new layer while the earlier layer still contains the bytes. This is structurally identical to deleting a secret in a later git commit. The only correct response is the same one: treat the secret as compromised, rotate it at the source, and rebuild with .env excluded. Secrets belong to run time, injected as environment variables, never to build time.
5. Why does binding uvicorn to 127.0.0.1 inside a container make it unreachable, when that address works fine on your laptop?
Because the container has its own network namespace with its own loopback interface. Binding to 127.0.0.1 tells the kernel to accept connections only on that loopback — which, inside the container, only processes in that same container can use. Docker's published port forwards traffic to the container's external interface instead, finds no listener, and the connection is refused or reset. 0.0.0.0 means "every interface on this host", which includes the one Docker forwards to. On your laptop it never mattered because the forwarder and the server were the same machine. The giveaway is that the logs cheerfully report the server running while curl fails: the process is fine, the address is wrong.
6. Your teammate edits one line of app/main.py, runs docker compose up -d, and swears their change is not there. What happened?
up starts an image; it does not build one. Since an image already exists from the previous build, Compose uses it unchanged — the edit is on the host filesystem and was never copied into a layer. The fix is docker compose up -d --build. This is also the precise reason --reload is not in the CMD: the container's filesystem is a snapshot taken at build time, so there is nothing to watch. If they want the edit-and-see-it loop back for local work, that is a bind mount in a development compose override — a deliberate hole punched through the isolation, not the default the image should ship with.