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:
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
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:
.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.
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:
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:
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
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:
- Write a multi-stage Dockerfile (
builder+runtime) matching the pattern above, with the runtime stage running as a non-rootUSER. - Add a
.dockerignoreexcluding at least.git,__pycache__,.venv,tests/, and.env. - Add a Docker
HEALTHCHECKinstruction that callsGET /health/ready(from Week 12) and marks the container unhealthy after a few consecutive failures. - Write a separate release script or CI step that runs
alembic upgrade headonce, and make sure it is not called from your app's startup event or run redundantly by every worker process. - Write a GitHub Actions workflow with a
testjob (lint +pytest) and abuildjob that only runs iftestsucceeds. - Swap the image's
CMDfor a Gunicorn entrypoint managing severalUvicornWorkerprocesses, and note in a comment how your chosen worker count relates to your SQLAlchemypool_size.
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?
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?
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?
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?
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.