|
| 1 | +# ADR-0009: Docker and Compose Strategy |
| 2 | + |
| 3 | +Date: 2026-04-02 |
| 4 | + |
| 5 | +## Status |
| 6 | + |
| 7 | +Accepted |
| 8 | + |
| 9 | +## Context |
| 10 | + |
| 11 | +The project needs to run in a self-contained environment for demos, CI, |
| 12 | +and as a reference point in the cross-language comparison. Two concerns |
| 13 | +apply: |
| 14 | + |
| 15 | +1. **Image size and security**: a naive build installs all dependencies |
| 16 | + including C build tools (required for native extensions such as |
| 17 | + `greenlet` and `aiosqlite`) into the final image, increasing its |
| 18 | + size and attack surface. |
| 19 | +2. **Local orchestration**: contributors should be able to start the |
| 20 | + application with a single command, without installing Python or `uv`, |
| 21 | + configuring environment variables, or managing a database file manually. |
| 22 | + |
| 23 | +Dependency resolution strategies considered: |
| 24 | + |
| 25 | +- **Single-stage build with pip**: simplest, but requires `build-essential`, |
| 26 | + `gcc`, `libffi-dev`, and `libssl-dev` in the final image to compile |
| 27 | + native extensions at install time. |
| 28 | +- **Multi-stage with virtualenv**: builder creates a `.venv`; runtime |
| 29 | + copies it. Works for pure-Python projects but is fragile when native |
| 30 | + extensions reference absolute paths baked in during compilation. |
| 31 | +- **Multi-stage with pre-built wheels**: builder resolves dependencies via |
| 32 | + `uv export` and pre-compiles them into `.whl` files (`pip wheel`); |
| 33 | + runtime installs from the local wheelhouse with `--no-index`. Build |
| 34 | + tools stay in the builder stage; the final image needs only `pip install`. |
| 35 | + |
| 36 | +## Decision |
| 37 | + |
| 38 | +We will use a multi-stage Docker build where the builder stage pre-compiles |
| 39 | +all dependency wheels, and Docker Compose to orchestrate the application |
| 40 | +locally. |
| 41 | + |
| 42 | +- **Builder stage** (`python:3.13.3-slim-bookworm`): installs |
| 43 | + `build-essential`, `gcc`, `libffi-dev`, and `libssl-dev`; uses |
| 44 | + `uv export --frozen --no-dev --no-hashes` to produce a pinned, |
| 45 | + reproducible dependency list from `uv.lock`, then compiles every |
| 46 | + package into a `.whl` file via `pip wheel`. The wheelhouse is written |
| 47 | + to `/app/wheelhouse/`. |
| 48 | +- **Runtime stage** (`python:3.13.3-slim-bookworm`): installs `curl` only |
| 49 | + (for the health check); copies the pre-built wheels from the builder; |
| 50 | + installs them with `--no-index --find-links` (no network access, no |
| 51 | + build tools required); removes the wheelhouse after installation. |
| 52 | +- **Entrypoint script**: on first start, copies the pre-seeded database |
| 53 | + from the image's read-only `hold/` directory to the writable named |
| 54 | + volume at `/storage/`, then runs both seed scripts to ensure the schema |
| 55 | + and data are up to date. On subsequent starts, the volume file is |
| 56 | + preserved and seed scripts run again (they are idempotent). |
| 57 | +- **Compose (`compose.yaml`)**: defines a single service with port |
| 58 | + mapping (`9000`), a named volume (`storage`), and environment variables |
| 59 | + (`STORAGE_PATH`, `PYTHONUNBUFFERED=1`). Health checks are declared in |
| 60 | + the Dockerfile (`GET /health`); Compose relies on that declaration. |
| 61 | +- A non-root `fastapi` user is created in the runtime stage following the |
| 62 | + principle of least privilege. |
| 63 | + |
| 64 | +## Consequences |
| 65 | + |
| 66 | +**Positive:** |
| 67 | +- Build tools (`gcc`, `libffi-dev`) are confined to the builder stage and |
| 68 | + never reach the runtime image — smaller attack surface and faster pulls. |
| 69 | +- Offline installation (`--no-index`) eliminates network-related |
| 70 | + non-determinism during the runtime image build. |
| 71 | +- `uv.lock` pins every transitive dependency; the builder produces the |
| 72 | + same wheels regardless of upstream index state. |
| 73 | +- `docker compose up` is a complete local setup with no prerequisites |
| 74 | + beyond Docker. |
| 75 | +- The named volume preserves data across restarts; `docker compose down -v` |
| 76 | + resets it cleanly. |
| 77 | + |
| 78 | +**Negative:** |
| 79 | +- Multi-stage builds are more complex to read and maintain than |
| 80 | + single-stage builds. |
| 81 | +- The wheelhouse is an intermediate artifact: if a wheel cannot be |
| 82 | + pre-built (e.g. binary-only distributions without a source distribution), |
| 83 | + the builder stage will fail. |
| 84 | +- The seed scripts run on every container start. They are idempotent but |
| 85 | + add latency to startup and must remain so as the project evolves. |
| 86 | +- The SQLite database file is versioned and bundled, meaning schema changes |
| 87 | + require a Docker image rebuild. |
| 88 | + |
| 89 | +**When to revisit:** |
| 90 | + |
| 91 | +- If a dependency ships only as a binary wheel for the target platform, |
| 92 | + the `pip wheel` step may need to be replaced with a direct `pip install` |
| 93 | + in the builder stage. |
| 94 | +- If a second service (e.g. PostgreSQL) is added, Compose will need a |
| 95 | + dedicated network and dependency ordering. |
0 commit comments