Week 13: Containerization & Deployment

Everything so far has run with uvicorn --reload on your laptop, talking directly to a local Postgres — none of that survives contact with a real deployment target. This week packages your FastAPI service into a small, non-root, multi-stage Docker image; moves configuration and secrets out of the image and into environment variables, where they belong; and wires up a CI pipeline that runs lint and tests before anything gets built, followed by Gunicorn managing several Uvicorn worker processes the way a real production deployment actually runs. By the end of the week you'll have something you could hand to a platform team without flinching.

Module 10 of 22 Week 13 of 26 ~3–4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Write a small multi-stage Dockerfile that separates dependency installation from the runtime image
  • Run the container as a non-root user and inject configuration through environment variables instead of baking it into the image
  • Wire a CI pipeline that lints and tests before building the image, and explain how Gunicorn manages multiple Uvicorn worker processes in production

1. Small Multi-Stage Docker Images

A naive Dockerfile installs your dependencies and copies your code into the same image you ship — which means the final image also carries a compiler toolchain, build headers, and Poetry itself, none of which are needed to actually run the app. A multi-stage build splits this into two stages: a builder stage with the full toolchain that installs dependencies into a virtual environment, and a slim runtime stage that copies only the finished virtual environment and your application code out of it:

Dockerfile — builder stage
FROM python:3.12-slim AS builder
WORKDIR /app

RUN pip install --no-cache-dir poetry
COPY pyproject.toml poetry.lock ./
RUN poetry config virtualenvs.in-project true \
 && poetry install --only main --no-root

COPY app ./app
Dockerfile — runtime stage
FROM python:3.12-slim AS runtime
WORKDIR /app

RUN addgroup --system app && adduser --system --ingroup app app

COPY --from=builder /app/.venv /app/.venv
COPY app ./app

USER app
CMD ["/app/.venv/bin/uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

COPY --from=builder /app/.venv /app/.venv is the key line — it pulls only the installed virtual environment out of the builder stage, leaving Poetry, pip caches, and the base build tools behind entirely; they simply don't exist in the final image's layers. The result is typically a few hundred megabytes smaller than a single-stage build, which matters for both pull time and attack surface: fewer installed packages means fewer things that can have a vulnerability. Note the exec-form CMD (a JSON array, not a shell string) — it runs Uvicorn as PID 1 directly rather than through an intermediate shell, so a SIGTERM from the orchestrator reaches Uvicorn immediately for a clean shutdown instead of being swallowed by a shell that doesn't forward signals.

2. Non-Root Runtime & Environment Configuration

Without a USER instruction, a container runs its process as root by default. Container isolation is not a full security boundary — a vulnerability in one of your dependencies, or a container-escape bug in the runtime itself, has a much larger blast radius if the process inside was running as root, since root inside the container often maps to more real privilege than you'd expect (write access to mounted volumes, a wider syscall surface). Several hardened platforms (OpenShift among them) simply refuse to run a container as root at all. The addgroup / adduser --system / USER app sequence in the runtime stage above creates an unprivileged system user and switches to it before the final CMD — every instruction after USER app, and the running process itself, executes as that user, not root.

Configuration is the other thing that shouldn't live inside the image. DATABASE_URL, REDIS_URL, a JWT signing secret — none of these belong in a Dockerfile's ENV instruction or baked in with COPY, because values set with ENV get written permanently into the image's layer history and are visible to anyone who can run docker history or docker inspect against it, secret or not. Instead, read configuration from the process environment at runtime — a Pydantic BaseSettings class reading os.environ is the common pattern — and inject the actual values from outside the image: docker run -e, a Compose environment: block, or a Kubernetes ConfigMap/Secret. The same built image then gets promoted unchanged from staging to production; only the environment differs.

Finally, a .dockerignore file (parallel to .gitignore) keeps things you never want in the build context — .git, __pycache__, a local .venv, tests/, and especially a local .env file — out of the image entirely:

.dockerignore
.git
__pycache__/
*.pyc
.venv/
tests/
.env

Without it, a stray COPY . . layer can silently pull your local .env file — with real credentials in it — straight into the image, which is a much worse leak than it sounds: that layer persists in the image's history even if a later layer deletes the file.

Why this matters

Non-root plus environment-based configuration is the difference between an image you can safely push to a public or shared registry and one that's a liability the moment anyone else can pull it. Neither costs you anything at runtime — they're both "set it up right once" decisions.

3. CI Checks & Production Process Models

An image should never get built from code that hasn't passed its checks. A CI pipeline expresses that as an explicit dependency between jobs — a fast lint-and-test job that has to succeed before a separate build job is even allowed to start:

.github/workflows/ci.yml
name: CI

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
      - run: pip install poetry && poetry install
      - run: poetry run ruff check .
      - run: poetry run pytest --maxfail=1

  build:
    needs: test
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: docker build -t myservice:${{ github.sha }} .

needs: test is the entire point — if ruff or pytest fails, the build job never runs at all, and no image tagged with that broken commit ever gets produced or pushed. This is a much stronger guarantee than "someone remembered to run tests before deploying" — it's structurally impossible to skip.

In production, you also don't run the same command you use locally. uvicorn app.main:app --reload is a single process that restarts itself on file changes — useful for development, useless for handling real concurrent traffic or surviving a worker crash. Production instead runs Gunicorn as a process manager for multiple Uvicorn worker processes:

production entrypoint
gunicorn app.main:app \
  -k uvicorn.workers.UvicornWorker \
  --workers 4 \
  --bind 0.0.0.0:8000

Gunicorn owns the worker pool: it spawns the requested number of Uvicorn worker processes, restarts any that crash, and can perform rolling restarts without dropping traffic. A common starting point for --workers is (2 × CPU cores) + 1, tuned from there based on measured CPU and memory use. This is also exactly why Week 11's connection-pool math matters here: each Gunicorn worker is a fully separate process with its own SQLAlchemy engine and its own connection pool, so the real number of connections your service opens against Postgres is --workers multiplied by pool_size, not just pool_size on its own.

4. Hands-on Exercise

Hands-on

Containerize the service and pipe it through CI

Build a production-shaped Dockerfile, add an explicit migration step, and wire a CI pipeline that tests before it ever builds an image.

Requirements:

  1. Write a multi-stage Dockerfile (builder + runtime) matching the pattern above, with the runtime stage running as a non-root USER.
  2. Add a .dockerignore excluding at least .git, __pycache__, .venv, tests/, and .env.
  3. Add a Docker HEALTHCHECK instruction that calls GET /health/ready (from Week 12) and marks the container unhealthy after a few consecutive failures.
  4. Write a separate release script or CI step that runs alembic upgrade head once, and make sure it is not called from your app's startup event or run redundantly by every worker process.
  5. Write a GitHub Actions workflow with a test job (lint + pytest) and a build job that only runs if test succeeds.
  6. Swap the image's CMD for a Gunicorn entrypoint managing several UvicornWorker processes, and note in a comment how your chosen worker count relates to your SQLAlchemy pool_size.
Hint

The python:3.12-slim base image doesn't ship curl by default, so a HEALTHCHECK CMD curl ... will fail with "command not found" unless you install it in the builder stage. It's often simpler to hit the endpoint with a one-line python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health/ready')" instead, which needs nothing extra installed.

5. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

Why shouldn't database migrations run independently inside every web worker at startup?

Several worker processes booting at once and each trying to run alembic upgrade head can race on the same schema change — one worker mid-migration while another starts a conflicting one, or two workers both trying to create the same index simultaneously. A single, explicit release step that runs the migration once, before any worker starts serving traffic, makes the ordering unambiguous and safe instead of leaving it to chance based on which worker happens to start first.

Q2

Why copy only the built virtual environment from the builder stage into the runtime stage, instead of installing dependencies directly in the runtime image?

Installing dependencies needs a compiler toolchain, build headers, and a package manager, none of which are needed to actually run the app — if you install directly in the runtime stage, all of that stays in the final image, inflating its size and attack surface. Copying just the finished .venv directory leaves the entire build toolchain behind in the discarded builder stage, so the shipped image only contains what's needed to execute the app.

Q3

Why should database credentials and secret keys be passed as environment variables at runtime rather than set with ENV in the Dockerfile?

Values baked in with a Dockerfile's ENV instruction get written permanently into that image layer's history, visible to anyone who can run docker history or docker inspect against the image — effectively public within your registry. Injecting values from outside the image at runtime (docker run -e, a Kubernetes Secret) keeps the image itself free of any secret, so the same built artifact can be safely promoted from staging to production with only the injected values changing.

Q4

Why does production typically run Gunicorn managing multiple Uvicorn worker processes instead of uvicorn app.main:app --reload?

--reload runs a single process that watches the filesystem and restarts on every change — exactly what you want while coding, and exactly wrong for production, where there's no code changing and a single process means no concurrency headroom and no recovery if that one process crashes. Gunicorn instead manages a pool of several independent Uvicorn worker processes, restarting any that crash and spreading load across all of them, which is what lets a service actually use multiple CPU cores and stay up if one worker dies.