# Python 14 - Containerizing StudyDesk with Docker and Docker Compose

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

## Where StudyDesk is now

```plaintext
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."

![](https://cdn.hashnode.com/uploads/covers/68822b6ad516e7a593530cf9/61b507c7-a99d-4ffb-b8f6-46564f7822eb.png align="center")

---

## 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:

```bash
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:

```bash
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`

```python
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`

```python
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)

```dockerfile
# 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)

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

### Build it

```bash
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.

![](https://cdn.hashnode.com/uploads/covers/68822b6ad516e7a593530cf9/dd48d871-01ad-48e9-a514-da765423fe97.png align="center")

---

## 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)

```yaml
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

![](https://cdn.hashnode.com/uploads/covers/68822b6ad516e7a593530cf9/f68f1b76-333b-4e9d-b69f-6130166de62e.png align="center")

---

## Part 6: Bring It Up

### Start everything

```bash
docker compose up --build
```

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

```plaintext
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**:

```bash
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:

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

### Stop and clean up

```bash
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:

```bash
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

```plaintext
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.
