Course outline · 0% complete

0/29 lessons0%

Course overview →

Capstone part 1: containerize the app

lesson 10-1 · ~13 min · 28/29

A capstone warm-up. Given a Python web app with a requirements.txt and a Postgres database, the two files from lessons 3-1 and 5-1 that turn it into a one-command runnable stack are a Dockerfile and a docker-compose.yml.

FileJob
Dockerfilepackages the app into an image
docker-compose.ymldeclares the app plus db stack

With both in place, docker compose up -d boots everything. Those are exactly the two files written in this lesson.

The app we're shipping

A small notes API called notesy: Python, a server.py listening on port 8000, storing notes in Postgres, with a /health endpoint (lesson 9-2) that checks its database connection. The repo:

notesy/
├── server.py
├── requirements.txt
├── tests/
│   └── test_notes.py
└── .git/

The mission across two lessons, using only things you have learned:

  1. Dockerfile → image (unit 3)
  2. compose file with app + db, volume, healthcheck (units 4, 5, 9)
  3. GitHub Actions pipeline: test → build → push → deploy (units 7, 8)

Step zero, before any building: .dockerignore, from lesson 3-3:

.git
__pycache__
.env

The Dockerfile

FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["python", "server.py"]
LineLesson it comes from
pinned slim base3-1 and 6-2
dependencies copied before code3-3, the cache order
EXPOSE 8000documentation of the listening port
a single CMD3-1

Building and smoke-testing it locally:

docker build -t notesy:dev .
docker run --rm -p 8080:8000 notesy:dev

--rm auto-removes the container when it stops, which is handy for throwaway runs. The app starts and then crashes, because there is no database. That is correct behavior, and exactly why compose exists.

The compose file

services:
  app:
    build: .
    ports:
      - "8080:8000"
    environment:
      DATABASE_URL: postgres://notesy:devpass@db:5432/notesy
    depends_on:
      db:
        condition: service_healthy
  db:
    image: postgres:16
    environment:
      POSTGRES_USER: notesy
      POSTGRES_PASSWORD: devpass
      POSTGRES_DB: notesy
    volumes:
      - dbdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD", "pg_isready", "-U", "notesy"]
      interval: 5s
      retries: 5
volumes:
  dbdata:
DetailLesson
service names as hostnames4-2 and 5-1
env config matching on both sides5-2
a named volume so notes survive4-1
condition: service_healthy gating startup9-2

pg_isready is Postgres's own readiness probe command, which is why the healthcheck needs no custom script.

After docker compose up -d, a curl localhost:8080/health answers ok. The stack is alive.

Why the URL says db and not localhost

DATABASE_URL uses the hostname db because inside the app container localhost is the app itself, and services reach each other by service name on the compose network.

This comes straight from lessons 4-2 and 5-1. Each container has its own localhost, and compose's network resolves service names through DNS.

Host in the URLReaches
localhost:5432nothing, the app container has no Postgres
db:5432the Postgres container

Renaming the service would mean updating the URL to match, since the service name is the DNS record. That coupling is worth noticing in review, because a rename that looks cosmetic breaks the connection string.

The line that guarantees the notes survived

The line is the volume mount under the db service, - dbdata:/var/lib/postgresql/data.

Postgres writes into the named volume dbdata, which lives outside any container, so down followed by up finds the same data waiting.

CommandEffect on dbdata
docker compose downuntouched
docker compose up -dremounted into the fresh container
docker compose down -vdeleted

down removes containers and the network but keeps named volumes, so the fresh db container mounts the same data. Only down -v would destroy it, which is why that flag deserves a pause before pressing enter.