Phase 2 · CONTAINERS & CI/CD
Project 1, Day 3: compose the full stack locally
By the end of today
- Write a two-service compose.yaml wiring a FastAPI web app to Postgres
- Gate web startup on a db healthcheck with depends_on condition service_healthy
- Run the stack, shorten a URL, and follow the redirect end to end
One command to run the whole stack — web + Postgres, wired together
Day 41 you scoped and scaffolded linkstash; Day 42 you wrote its multi-stage Dockerfile. That image runs the FastAPI app — but the app needs a PostgreSQL database, and starting Postgres by hand next to your container every time is exactly the toil DevOps exists to kill.
Docker Compose replaces that ritual with one file. You declare each piece of the stack as a service in compose.yaml, and docker compose up starts them together on a private network Compose creates for you. Today you run two services: web (built from your Dockerfile with build: .) and db (the stock postgres:16 image). One command, whole stack.
The two services talk over that network by service name. Compose gives every service a DNS entry, so web reaches Postgres at the host db — not localhost. That’s why the app’s DATABASE_URL is postgresql://postgres:postgres@db:5432/linkstash: user, password, the host db, port, database. You inject it as an environment variable; app/db.py reads it and opens the connection.
The design decision: wait for ready, not just started
The trap that bites everyone the first time is startup order. web can’t query a database that isn’t accepting connections yet, and Postgres takes a second or two to initialise on first boot. Plain depends_on: [db] is not enough — it only waits for the db container to start, not for Postgres to be ready. So web sprints ahead, tries to connect, and crashes with connection refused.
The fix is two lines that belong together. Give db a healthcheck that runs pg_isready on an interval, and make web depend on it with condition: service_healthy. Now Compose holds web in Created until the db’s healthcheck goes green, then starts it against a database that is genuinely ready. Readiness, not mere existence, is the gate.
One more piece: db mounts a named volume, pgdata, at Postgres’s data directory. Containers are disposable; the volume is not. Your shortened links survive docker compose down and every rebuild — they only vanish if you explicitly delete the volume with down -v.
Real world:
depends_onwithout a healthcheck is like phoning a shop the instant the lights flick on — someone’s inside, but the till isn’t up and the door’s still locked.pg_isreadyis knocking until they actually open. You wait for open for business, not for the lights are on.
The official postgres image on Docker Hub is built for exactly this: it ships pg_isready in the image, so your healthcheck is a one-liner with nothing extra to install — the same pattern the Compose docs use in their own Postgres example.
By the end of the lab you’ll bring the whole stack up with one command, watch the db flip to healthy, shorten a URL through the running web service, and follow the short code’s redirect — all against a database that Compose started, wired, and waited for on your behalf.
Hands-On Lab
Budget about 25 minutes. Open your WSL2 Ubuntu 24.04 terminal with Docker 27+ and Compose v2. You’re in the linkstash repo from Days 41–42, which already has app/main.py, the multi-stage Dockerfile, and requirements.txt. Today you wire app/db.py to DATABASE_URL, write compose.yaml, and run the two-service stack. Image IDs, digests, timings and the generated code are yours-will-differ.
# 1. In the linkstash repo, confirm Days 41-42 left you the app package and the Dockerfile.
cd ~/linkstash && ls
# Output:
# app Dockerfile requirements-dev.txt requirements.txt
# 2. app/db.py — read DATABASE_URL, connect to Postgres, expose the calls main.py already imports.
# (Replaces the Day-41 placeholder. requirements.txt already pins sqlalchemy and
# psycopg[binary] from Day 41, so the Day-42 image ships the driver; main.py calls init_db() on startup.)
import os
from sqlalchemy import create_engine, text
# Compose injects DATABASE_URL; SQLAlchemy wants the psycopg driver named in the scheme.
_url = os.environ["DATABASE_URL"].replace("postgresql://", "postgresql+psycopg://", 1)
engine = create_engine(_url, pool_pre_ping=True)
def init_db() -> None:
with engine.begin() as conn:
conn.execute(text(
"CREATE TABLE IF NOT EXISTS links (code TEXT PRIMARY KEY, url TEXT NOT NULL)"
))
def save_link(code: str, url: str) -> None:
with engine.begin() as conn:
conn.execute(
text("INSERT INTO links (code, url) VALUES (:c, :u) ON CONFLICT (code) DO NOTHING"),
{"c": code, "u": url},
)
def get_url(code: str) -> str | None:
with engine.connect() as conn:
row = conn.execute(text("SELECT url FROM links WHERE code = :c"), {"c": code}).first()
return row[0] if row else None
# 3. compose.yaml — two services. web waits on db's healthcheck; db persists to a named volume.
services:
web:
build: .
ports:
- "8000:8000"
environment:
DATABASE_URL: postgresql://postgres:postgres@db:5432/linkstash
depends_on:
db:
condition: service_healthy
db:
image: postgres:16
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: linkstash
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d linkstash"]
interval: 5s
timeout: 3s
retries: 5
volumes:
pgdata:
# 4. Build the web image and start the whole stack in the background.
docker compose up -d --build
# Output (build lines trimmed; db reaches Healthy BEFORE web is Started — that's the gate working):
# [+] Building 8.4s (14/14) FINISHED docker:default
# => [web] exporting to image 0.2s
# [+] Running 4/4
# ✔ Network linkstash_default Created 0.1s
# ✔ Volume "linkstash_pgdata" Created 0.0s
# ✔ Container linkstash-db-1 Healthy 11.5s
# ✔ Container linkstash-web-1 Started 11.8s
# 5. Confirm both services are up — note db's "(healthy)" and web's published port.
docker compose ps
# Output (CREATED/STATUS ages are yours-will-differ):
# NAME IMAGE COMMAND SERVICE STATUS PORTS
# linkstash-db-1 postgres:16 "docker-entrypoint.s…" db Up 28 seconds (healthy) 5432/tcp
# linkstash-web-1 linkstash-web "uvicorn app.main:ap…" web Up 18 seconds 0.0.0.0:8000->8000/tcp
# 6. Read the web logs — init_db() ran on startup, once the db was already healthy.
docker compose logs web --tail 3
# Output (timestamps are yours-will-differ):
# linkstash-web-1 | INFO: Waiting for application startup.
# linkstash-web-1 | INFO: Application startup complete.
# linkstash-web-1 | INFO: Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit)
# 7. Shorten a URL — POST the long URL, get a short code back.
curl -s -X POST localhost:8000/shorten \
-H 'Content-Type: application/json' \
-d '{"url":"https://opscanopy.com/mission-90/"}'
# Output (the code is random — yours will differ):
# {"code":"a1B2c3"}
# 8. Follow the code — GET /{code} returns a 307 redirect to the original URL.
curl -i localhost:8000/a1B2c3
# Output (headers trimmed):
# HTTP/1.1 307 Temporary Redirect
# location: https://opscanopy.com/mission-90/
# server: uvicorn
# content-length: 0
# 9. Sanity-check the app's own health endpoint.
curl -s localhost:8000/healthz
# Output:
# {"status":"ok"}
# 10. Stop the stack. The pgdata volume (and your links) survive — down -v would wipe them.
docker compose down
# Output:
# [+] Running 3/3
# ✔ Container linkstash-web-1 Removed 0.4s
# ✔ Container linkstash-db-1 Removed 0.3s
# ✔ Network linkstash_default Removed 0.2s
Read the last steps back: one docker compose up built your image, created a network and a volume, started Postgres and waited for it to report healthy, then started the web app against a ready database. You shortened a URL and the code redirected — the full request path, web to db and back, running from a single file.
Common Errors & Fixes
These three are the compose-stack mistakes almost everyone hits the first time. Read the error text slowly — parsing it is the actual skill.
Common error: Pointing
DATABASE_URLatlocalhostinstead of the service name —postgresql://postgres:postgres@localhost:5432/linkstash— soweblogs on startup:psycopg.OperationalError: connection to server at "localhost" (127.0.0.1), port 5432 failed: Connection refused Is the server running on that host and accepting TCP/IP connections?Why: Inside the
webcontainer,localhostis the web container itself — Postgres isn’t there. On a Compose network each service is reachable by its service name, and the database lives in thedbcontainer, addressed as hostdb.Fix: Set the host in
DATABASE_URLto the service name:postgresql://postgres:postgres@db:5432/linkstash. The service is nameddbincompose.yaml, so that’s the DNS name Compose resolves on the network.How you’d spot it in prod: An app that connects fine on your laptop (Postgres on
localhost) but refuses inside containers, Compose or Kubernetes almost always has a hostname that assumes everything is co-located. Check the connection host against where the database actually runs.
Common error: Declaring
depends_on: [db]with no healthcheck and nocondition, sowebstarts the instant the db container exists — on first boot it crashes anddocker compose psshows it exited:linkstash-web-1 | psycopg.OperationalError: connection to server at "db" (172.18.0.2), port 5432 failed: Connection refused linkstash-web-1 exited with code 1Why: Plain
depends_ononly orders container start — it does not wait for Postgres to finish initialising and accept connections. Postgres needs a beat on first boot, sowebconnects too early and dies.Fix: Add a
healthchecktodb(thepg_isreadyblock from the lab) and changewebtodepends_on: { db: { condition: service_healthy } }. Compose then holdswebuntil the db is genuinely ready.How you’d spot it in prod: Flaky “works on the second try” startups, or a service that dies on a cold start but is fine after a manual restart, point to a readiness/ordering gap — not app logic.
Common error: Changing
POSTGRES_PASSWORDincompose.yamlafter the db already initialised once, then getting refused — the db logs:linkstash-db-1 | FATAL: password authentication failed for user "postgres"Why: The
postgresimage only initialises the database and appliesPOSTGRES_PASSWORDon the first start with an empty data directory. Ifpgdataalready holds data from an earlier run, the new password is ignored and the old credentials still stand — so the app’s new password no longer matches.Fix: In development, reset the data with
docker compose down -vto drop thepgdatavolume, thenupre-initialises with the current password (this destroys the data — dev only). Otherwise keep credentials consistent, or change the password inside Postgres withALTER USER.How you’d spot it in prod: Auth failures right after a credential rotation, where the app’s secret and the database’s actual password have drifted apart — the volume was never re-initialised. Never fix this with
down -von real data.
Docker Compose Interview Questions
These four are what a screening round asks once “can you write a Dockerfile?” becomes “can you run a real multi-service stack?” — cover each answer, say your own version out loud first, then compare, because recalling before revealing is what makes it stick. The four questions and answers render right after this note.
Go Deeper
Optional extras if you have ~30 more minutes today:
- 5 min — Run
docker compose configto see the fully-resolved configuration Compose actually uses, thendocker volume lsto find yourlinkstash_pgdatavolume living on independently of the containers. - 10 min — Skim the Compose specification’s depends_on section and read the three conditions —
service_started,service_healthy,service_completed_successfully— noting when each is the right gate. - 15 min — Read the Compose section of the Docker for DevOps guide for how a multi-service local stack fits the build-and-ship workflow you carry into the CI day next.
What does depends_on with condition: service_healthy do, and why isn't plain depends_on enough? Both
Plain depends_on only controls start order — it waits for the dependency's container to start, not for the service inside to be ready to serve. Postgres takes a moment to initialise, so a web service with only depends_on: [db] often races ahead and crashes with 'connection refused'. condition: service_healthy makes Compose hold the dependent service until the dependency's healthcheck reports healthy. You pair it with a healthcheck on the db — pg_isready on an interval. Now web isn't started until Postgres genuinely accepts connections. The distinction is readiness versus mere existence: the container being up is not the same as the database being ready, and only the healthcheck closes that gap.
On a Compose network, how do services reach each other — why the host 'db' and not localhost? Both
Compose puts every service on a shared user-defined network and registers each one in an internal DNS under its service name. So from the web container, Postgres is reachable at the host db — the service's name — on its container port 5432. You do not use localhost: inside a container localhost is that container itself, so localhost:5432 would look for Postgres in the web container and fail. You also don't need to publish 5432 to the host for this; the ports key only maps host to container for outside access, whereas service-to-service traffic stays on the Compose network. That's why the app's DATABASE_URL points at db, not localhost or an IP.
What is a named volume for, and what's the difference between docker compose down and down -v? Product
A named volume is Docker-managed storage that lives independently of any container, so data outlives the container's lifecycle. I mount pgdata at Postgres's data directory (/var/lib/postgresql/data) so the database files persist across restarts and rebuilds — containers are disposable, the data isn't. docker compose down stops and removes the containers and the network but leaves named volumes intact, so my shortened links are still there next time. docker compose down -v additionally deletes those volumes, wiping the data — handy to reset a dev database, dangerous anywhere you care about the data. Bind mounts are the alternative when you want the files on the host filesystem.
How does a Compose healthcheck work, and what makes a good one for Postgres? Both
A healthcheck is a command Compose runs inside the container on an interval; its exit code sets the container's health status — 0 is healthy, non-zero is unhealthy after the configured retries. For Postgres the right probe is pg_isready, which the official image ships: it checks that the server is accepting connections, not just that the process exists. I give it interval, timeout and retries so a slow first boot doesn't flap. The key is testing real readiness — 'can I actually connect?' — rather than something superficial like the port merely being open. Other services get their own probe: a web app is usually a curl to a /healthz endpoint that returns 200.
Mark Day 43 complete
Tomorrow, Project 1 Day 4, you take this same stack into GitHub Actions — a CI pipeline that lints, tests, and builds the image on every push.
Stuck on today’s lab? Ask in Mission 90 Q&A