Skip to main content

Python 14 - Containerizing StudyDesk with Docker and Docker Compose

Updated 14 min readMarkdown
Part of seriespython

Package the API and its database so that one command starts the whole system, identically, on any machine.

Where StudyDesk is now

studydesk/
├── .env
├── .env.example
├── pyproject.toml / uv.lock
└── src/studydesk/
    ├── __init__.py, config.py, database.py, models.py, schemas.py, crud.py, seed.py
    ├── routers/students.py
    └── data/students.json

It runs on Aman's laptop, with Aman's PostgreSQL, Aman's Python version, and Aman's .env. By the end of this article, docker compose up starts PostgreSQL and the API together on any computer that has Docker, with nothing else installed.


Part 1: "It Works on My Machine"

Karan clones the repository to run the API locally. It takes him an afternoon:

  • his Python is 3.11, the project wants 3.12
  • he has no PostgreSQL installed
  • he installs it, but his postgres user has a different password
  • psycopg needs a system library his laptop does not have

Everything that was already set up on Aman's laptop has to be set up again on Karan's, and again on the production server, and again when a new developer joins.

Neha says, "You will not fix this by writing a longer README. You fix it by shipping the environment with the code. That is what Docker does."


Part 2: Docker in Five Words

Word Meaning Everyday picture
Image A frozen snapshot: an operating system layer, Python, your code, your dependencies. Read-only. A recipe plus all ingredients, vacuum-sealed
Container A running instance of an image. Isolated process with its own filesystem and network. The dish being cooked from that recipe
Dockerfile The text file with step-by-step instructions to build an image. The written recipe
Volume Storage that lives outside the container, so data survives when the container is deleted. The fridge next to the kitchen
Compose A YAML file that describes several containers and how they connect, started with one command. The whole restaurant: kitchen, fridge, wiring

Key fact: a container is disposable. You can delete it and start a fresh one from the image in a second. Anything you want to keep (the database data) must be in a volume.

Install Docker

Install Docker Desktop (Windows, macOS) or Docker Engine (Linux) from docker.com. Verify:

docker --version
docker compose version

If you ran PostgreSQL with docker run in Article 7, stop and remove that container now; Compose will manage the database from here on:

docker stop studydesk-db && docker rm studydesk-db

Part 3: One Small Change to the App

Inside a container, the API must listen on all network interfaces, not just 127.0.0.1. 127.0.0.1 means "only this machine", and inside a container "this machine" is the container itself, so nothing outside could reach it. The address for "all interfaces" is 0.0.0.0.

We make the host and port configurable through environment variables, with the old values as defaults for local development.

src/studydesk/config.py

import os
from pathlib import Path

from dotenv import load_dotenv

BASE_DIR = Path(__file__).resolve().parent.parent.parent  # project root
load_dotenv(BASE_DIR / ".env")

DATABASE_URL: str = os.environ["DATABASE_URL"]

ALLOWED_ORIGINS: list[str] = [
    origin.strip()
    for origin in os.getenv("ALLOWED_ORIGINS", "").split(",")
    if origin.strip()
]

HOST: str = os.getenv("HOST", "127.0.0.1")
PORT: int = int(os.getenv("PORT", "8000"))
RELOAD: bool = os.getenv("RELOAD", "true").lower() == "true"

src/studydesk/__init__.py

from contextlib import asynccontextmanager

import uvicorn
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy import func, select

from .config import ALLOWED_ORIGINS, HOST, PORT, RELOAD
from .database import Base, DbSession, engine
from .models import Student
from .routers import students


@asynccontextmanager
async def lifespan(app: FastAPI):
    Base.metadata.create_all(bind=engine)
    yield


app = FastAPI(
    title="StudyDesk API",
    description="Student records API for a coaching institute.",
    version="0.2.0",
    lifespan=lifespan,
)

app.add_middleware(
    CORSMiddleware,
    allow_origins=ALLOWED_ORIGINS,
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

app.include_router(students.router)


@app.get("/", tags=["General"])
def home():
    return {"message": "Welcome to StudyDesk API"}


@app.get("/db-check", tags=["General"], summary="Check the database connection")
def db_check(db: DbSession):
    total = db.scalar(select(func.count()).select_from(Student))
    return {"database": "ok", "students_in_db": total}


def main() -> None:
    """Start the server. Host, port and reload come from the environment."""
    uvicorn.run("studydesk:app", host=HOST, port=PORT, reload=RELOAD)

On your laptop nothing changes: uv run studydesk still binds 127.0.0.1:8000 with reload on. In the container we will set HOST=0.0.0.0 and RELOAD=false.


Part 4: The Dockerfile

A Dockerfile is a recipe read top to bottom. Each instruction creates a layer, and Docker caches layers: if a step's inputs have not changed, it is reused instead of re-run. We order the steps so that the slow part (installing dependencies) is cached and only the fast part (copying code) re-runs when you edit a file.

Dockerfile (project root)

# 1. Start from an image that already has Python 3.12 and uv installed.
FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim

# 2. Work inside /app in the container.
WORKDIR /app

# 3. Tell uv to create .venv inside /app and never try to download Python.
ENV UV_PROJECT_ENVIRONMENT=/app/.venv \
    UV_PYTHON_DOWNLOADS=never \
    UV_COMPILE_BYTECODE=1

# 4. Copy only the files needed to resolve dependencies, then install them.
#    This layer is cached until pyproject.toml or uv.lock changes.
COPY pyproject.toml uv.lock README.md ./
RUN uv sync --frozen --no-dev --no-install-project

# 5. Now copy the source code and install our own package into the venv.
COPY src ./src
RUN uv sync --frozen --no-dev

# 6. Put the venv's bin folder on PATH so "uvicorn" and "studydesk" are found.
ENV PATH="/app/.venv/bin:$PATH"

# 7. Document the port (does not publish it; compose does that).
EXPOSE 8000

# 8. The command that runs when the container starts.
CMD ["studydesk"]

Reading it

FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim The base image, maintained by the uv team: Debian ("bookworm"), trimmed down ("slim"), with Python 3.12 and uv preinstalled. Starting from this is faster and smaller than installing uv ourselves.

WORKDIR /app All later paths are relative to /app inside the container.

ENV ... Environment variables baked into the image. UV_COMPILE_BYTECODE=1 precompiles .pyc files so the app starts faster.

COPY pyproject.toml uv.lock README.md ./ then uv sync --frozen --no-dev --no-install-project Copy only the dependency manifest and install third-party packages. --frozen means "use uv.lock exactly, do not re-resolve". --no-dev skips dev tools (ruff, pytest). --no-install-project installs the dependencies but not StudyDesk itself, because the source is not copied yet. README.md is needed because pyproject.toml references it.

Because this layer only depends on three files, editing Python code does not invalidate it. Rebuilds after a code change take seconds.

COPY src ./src then uv sync --frozen --no-dev Now copy the code and install the studydesk package into the venv (this is what makes the studydesk command and import studydesk exist).

ENV PATH=... Puts the venv first on PATH, so CMD ["studydesk"] finds our script without uv run.

CMD ["studydesk"] What runs at container start: the same main() entry point as on your laptop. Host, port, and reload will come from Compose.

.dockerignore

Like .gitignore, but for what goes into the image. Without it, COPY src ./src would happily include __pycache__ folders, and a careless COPY . . would ship your .env with real passwords into the image.

.dockerignore (project root)

.venv/
.git/
.env
__pycache__/
*.pyc
.pytest_cache/
.ruff_cache/

Build it

docker build -t studydesk-api .

-t studydesk-api tags the image with a name. The . is the build context: the folder Docker may copy from. The first build downloads the base image and installs packages (a minute or two). Run it again and you will see CACHED on most steps.


Part 5: Docker Compose — API and Database Together

The image contains the API. It does not contain PostgreSQL, and it should not: databases hold data that must outlive any single container. So we run two containers and wire them together. That is Compose's job.

docker-compose.yml (project root)

services:
  db:
    image: postgres:16
    container_name: studydesk-db
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres
      POSTGRES_DB: studydesk
    volumes:
      - postgres_data:/var/lib/postgresql/data
    ports:
      - "5432:5432"
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres -d studydesk"]
      interval: 5s
      timeout: 5s
      retries: 10

  api:
    build: .
    container_name: studydesk-api
    env_file:
      - .env
    environment:
      DATABASE_URL: postgresql+psycopg://postgres:postgres@db:5432/studydesk
      HOST: 0.0.0.0
      PORT: "8000"
      RELOAD: "false"
    ports:
      - "8000:8000"
    depends_on:
      db:
        condition: service_healthy

volumes:
  postgres_data:

Reading it

services: — each entry is one container.

db service

  • image: postgres:16 — the official PostgreSQL image. No Dockerfile needed; we use it as-is.
  • environment: — the three variables the Postgres image reads on first start to create a user, password, and database.
  • volumes: postgres_data:/var/lib/postgresql/data — the folder where PostgreSQL keeps its files is mapped to a named volume. Delete the container, start a new one, and the students are still there.
  • ports: "5432:5432" — publish the database port to your laptop so you can still use psql locally. host:container.
  • healthcheck: — Compose runs pg_isready every 5 seconds. The service counts as healthy only when PostgreSQL is actually accepting connections, not merely when the process started.

api service

  • build: . — build the image from the Dockerfile in this folder.
  • env_file: .env — load your local .env (so ALLOWED_ORIGINS and anything else you add later come along).
  • environment: — explicit values that override env_file. The important one is DATABASE_URL: the host is db, not localhost. Inside Compose's private network, every service is reachable by its service name. From the API container's point of view, "localhost" would be the API container itself, which has no database. HOST=0.0.0.0 makes Uvicorn reachable from outside the container; RELOAD=false because there is no code editing in production.
  • ports: "8000:8000" — publish the API to your laptop at localhost:8000.
  • depends_on with condition: service_healthy — start the API only after the database passes its health check. Without this, the API could boot first, fail to connect, and crash.

volumes: at the bottom declares the named volume used above.

How the containers talk


Part 6: Bring It Up

Start everything

docker compose up --build

--build rebuilds the API image if the Dockerfile or code changed. You will see both services' logs interleaved. Wait for:

studydesk-db   | database system is ready to accept connections
studydesk-api  | INFO:     Uvicorn running on http://0.0.0.0:8000
studydesk-api  | INFO:     Application startup complete.

Open http://localhost:8000/docs. Same API. GET /db-check returns "students_in_db": 0: this is a brand-new database inside the volume, separate from your local PostgreSQL.

Seed it

Run the seed script inside the running API container:

docker compose exec api python -m studydesk.seed

exec api runs a command in the api container. python resolves to the venv's Python because of the PATH we set. Output: Inserted 10 students. Now GET /students returns ten.

Run in the background

Press Ctrl+C to stop. To run detached:

docker compose up -d
docker compose logs -f api      # follow the API logs
docker compose ps               # what is running

Stop and clean up

docker compose down           # stop and remove containers; the volume stays
docker compose down -v        # also delete the volume (all data!)

The daily cycle

You changed… Run
Python code docker compose up --build
pyproject.toml / uv.lock docker compose up --build (dependency layer rebuilds)
docker-compose.yml or .env docker compose up
nothing, just restarting docker compose up

For everyday development you can keep using uv run studydesk against your local PostgreSQL, and use Compose when you want to check that the packaged version works exactly like production.


Part 7: What Changed for Karan

Karan pulls the repository, copies .env.example to .env, and runs:

docker compose up --build
docker compose exec api python -m studydesk.seed

Two commands. No Python install, no PostgreSQL install, no password mismatch. The same two commands will work on the production server, on a CI runner, and on the next developer's laptop. The environment travels with the code.

Two things to remember

Compose is for development and small deployments. Larger production setups use the same image with an orchestrator such as Kubernetes, but the Dockerfile you wrote is exactly what they would run.

The image does not contain .env. .dockerignore keeps it out. Secrets reach the container at run time through env_file and environment, never at build time. Anyone who gets a copy of the image gets code, not passwords.


Where StudyDesk is after this article

studydesk/
├── Dockerfile               ← builds the API image
├── .dockerignore
├── docker-compose.yml       ← api + db + volume
├── .env / .env.example
├── pyproject.toml / uv.lock
└── src/studydesk/
    ├── __init__.py          ← main() reads HOST, PORT, RELOAD
    ├── config.py            ← HOST, PORT, RELOAD added
    └── ... (unchanged)
Command Result
docker compose up --build PostgreSQL + API running, API at localhost:8000
docker compose exec api python -m studydesk.seed 10 students inserted
docker compose down everything stopped, data kept in the volume

Concept Quiz

Answers are below. No coding needed.

Q1. What is the difference between an image and a container?

Q2. Why does the Dockerfile copy pyproject.toml and uv.lock and run uv sync before copying src?

Q3. What does --no-install-project do in the first uv sync, and why is it needed there?

Q4. Why must the API listen on 0.0.0.0 inside the container instead of 127.0.0.1?

Q5. In the Compose DATABASE_URL, why is the host db and not localhost?

Q6. What happens to the students if you run docker compose down and then docker compose up? And with down -v?

Q7. What does the healthcheck on db combined with depends_on: condition: service_healthy prevent?

Q8. Why is .env listed in .dockerignore, and how do its values still reach the container?

Q9. What does docker compose exec api python -m studydesk.seed do, step by step?

Q10. After editing a Python file, which Dockerfile layers rebuild and which are cached?


Answers

  1. An image is a frozen, read-only snapshot (OS layer, Python, code, dependencies). A container is a running, disposable instance of an image with its own filesystem and network.
  2. Layer caching. Dependencies change rarely, code changes often. Installing dependencies first means code edits only rebuild the cheap final layers; the slow install layer is reused.
  3. It installs third-party packages but not StudyDesk itself. Needed because src has not been copied yet, so the project cannot be installed at that point.
  4. 127.0.0.1 means "this machine only", and inside the container that is the container itself. 0.0.0.0 listens on all interfaces, so traffic published through ports can reach it.
  5. Inside the Compose network each service is reachable by its name. localhost from the API container would mean the API container, which has no database.
  6. down then up: students are still there, because PostgreSQL's data lives in the named volume. down -v deletes the volume, so the database starts empty.
  7. The API starting before PostgreSQL is ready to accept connections, which would make the API crash at startup when create_all tries to connect.
  8. To keep real secrets out of the image, where anyone with a copy could read them. The values are injected at run time through Compose's env_file and environment.
  9. exec api opens a command in the running API container; python is the venv's Python (via PATH); -m studydesk.seed runs the seed module, which connects to db and inserts the ten students.
  10. The dependency layers (COPY pyproject.toml ... and the first uv sync) are cached. COPY src and the second uv sync rebuild.

Epilogue

Karan runs two commands and has the full system on his laptop. The production server will get the same two commands.

Neha opens /docs from a different machine on the office network, finds DELETE /students/3, and deletes Sneha. No login, no question asked. "Anyone who can reach this URL can delete every student," she says. "CORS was never security, remember? Now we need the real thing."

In the next article: authentication and authorization — password hashing, a users table, POST /auth/register and /auth/login, JWT tokens, a get_current_user dependency, and admin-only routes.

No comments yet. Be the first to comment.