Skip to content

Phase 2 · CONTAINERS & CI/CD

Project 1, Day 3: compose the full stack locally

Day 43 of 90 ~60 min 0/25 in phase Builds on Day 42

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

Section 1 of 5 · ~2 min

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_on without 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_isready is 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.

docker compose up starts two services on the linkstash_default network: web (FastAPI, built from the Dockerfile, port 8000) connects to db (postgres:16) at the host db on port 5432 via DATABASE_URL. The db runs a pg_isready healthcheck, and web starts only once db is healthy. The db persists data to the named volume pgdata. linkstash_default network docker compose up -d --build web (FastAPI) build: . · :8000 db (postgres:16) healthcheck: pg_isready DATABASE_URL @db:5432 named volume: pgdata web starts only once db is healthy
One command brings both services up on a private network; web waits for the db healthcheck, connects by service name, and the db's data lives in a named volume.

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

Section 2 of 5 · ~3 min

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

Section 3 of 5 · ~2 min

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_URL at localhost instead of the service name — postgresql://postgres:postgres@localhost:5432/linkstash — so web logs 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 web container, localhost is 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 the db container, addressed as host db.

Fix: Set the host in DATABASE_URL to the service name: postgresql://postgres:postgres@db:5432/linkstash. The service is named db in compose.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 no condition, so web starts the instant the db container exists — on first boot it crashes and docker compose ps shows 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 1

Why: Plain depends_on only orders container start — it does not wait for Postgres to finish initialising and accept connections. Postgres needs a beat on first boot, so web connects too early and dies.

Fix: Add a healthcheck to db (the pg_isready block from the lab) and change web to depends_on: { db: { condition: service_healthy } }. Compose then holds web until 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_PASSWORD in compose.yaml after the db already initialised once, then getting refused — the db logs:

linkstash-db-1  | FATAL:  password authentication failed for user "postgres"

Why: The postgres image only initialises the database and applies POSTGRES_PASSWORD on the first start with an empty data directory. If pgdata already 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 -v to drop the pgdata volume, then up re-initialises with the current password (this destroys the data — dev only). Otherwise keep credentials consistent, or change the password inside Postgres with ALTER 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 -v on real data.

Docker Compose Interview Questions

Section 4 of 5 · ~1 min

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

Section 5 of 5 · ~1 min

Optional extras if you have ~30 more minutes today:

  • 5 min — Run docker compose config to see the fully-resolved configuration Compose actually uses, then docker volume ls to find your linkstash_pgdata volume 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