description: "Deployment & DevOps — Writing an application is only part of the job — it needs to run reliably somewhere other than your laptop. This module covers…"---
08 · Deployment & DevOps¶
Writing an application is only part of the job — it needs to run reliably somewhere other than your laptop. This module covers containerizing a Python app with Docker, automating tests and builds with CI/CD, and managing environment-specific configuration safely.
Why containers¶
A container packages your application together with its exact runtime environment (Python version, system libraries, dependencies), so "works on my machine" becomes "works everywhere this image runs."
A Dockerfile for a FastAPI app¶
# Dockerfile
FROM python:3.12-slim
WORKDIR /app
# copy only requirements first, so Docker can cache this layer
# and skip reinstalling dependencies when only app code changes
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app/ ./app/
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
Building and running the image¶
docker build -t book-api:latest .
docker run -p 8000:8000 book-api:latest
# pass environment variables into the container
docker run -p 8000:8000 -e DATABASE_URL="sqlite:///./books.db" book-api:latest
.dockerignore¶
Keep the build context small and avoid leaking local artifacts into the image.
docker-compose for multi-service apps¶
Real applications usually need more than one container — the app plus a database, for instance.
# docker-compose.yml
services:
api:
build: .
ports:
- "8000:8000"
environment:
- DATABASE_URL=postgresql://appuser:apppass@db:5432/appdb
depends_on:
- db
db:
image: postgres:16
environment:
- POSTGRES_USER=appuser
- POSTGRES_PASSWORD=apppass
- POSTGRES_DB=appdb
volumes:
- db_data:/var/lib/postgresql/data
volumes:
db_data:
Inside the compose network, the API reaches the database at hostname db —
Docker Compose sets up internal DNS between services automatically.
Environment-based configuration¶
Never hard-code environment-specific values (database URLs, secrets, debug flags) — read them from the environment so the same image behaves correctly across dev, staging, and production.
# config.py
import os
class Settings:
database_url: str = os.environ.get("DATABASE_URL", "sqlite:///./dev.db")
debug: bool = os.environ.get("DEBUG", "false").lower() == "true"
secret_key: str = os.environ["SECRET_KEY"] # required — fail fast if missing
settings = Settings()
Using pydantic-settings gives you validation and type coercion on top of
this pattern:
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
database_url: str = "sqlite:///./dev.db"
debug: bool = False
secret_key: str
class Config:
env_file = ".env" # also loads from a local .env file, useful in development
settings = Settings()
.env files (never commit real secrets)¶
# .env (local development only — add this file to .gitignore)
DATABASE_URL=postgresql://appuser:apppass@localhost:5432/appdb
DEBUG=true
SECRET_KEY=dev-only-secret-do-not-use-in-prod
CI/CD pipeline: test, build, push¶
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
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 -r requirements-dev.txt
- run: pytest --cov=app
build-and-push:
needs: test # only runs if tests passed
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- uses: docker/build-push-action@v6
with:
context: .
push: true
tags: myorg/book-api:latest
Gating build-and-push behind needs: test means a broken build never gets
published as a deployable image — a fundamental CI/CD safety property.
A minimal deployment checklist¶
| Concern | Practice |
|---|---|
| Reproducible environment | Dockerfile pinned to a specific Python base image |
| Secrets | environment variables / secret managers, never in source or images |
| Config differences (dev/stage/prod) | environment variables, not code branches |
| Automated verification | CI runs the full test suite on every push |
| Rollback safety | tag images by commit SHA, not just latest |
| Observability | structured logging (tying back to |
| Production-Grade APIs's middleware) |
How It Actually Works¶
A Docker image is a stack of read-only layers, each one a filesystem diff from
the layer below it — every FROM, COPY, and RUN instruction in a Dockerfile
produces exactly one new layer, identified by a content hash of its resulting
filesystem changes. When you rebuild, Docker checks whether a given instruction's
inputs (its command text, and for COPY, the hash of the files being copied) match
a previously built layer with that same hash — if so, it reuses the cached layer
instead of re-executing the instruction. This is the entire mechanism behind the
"copy requirements.txt first" trick: as long as requirements.txt hasn't changed,
the RUN pip install layer's cache key matches and Docker skips reinstalling
everything, even though the COPY app/ step after it (which changes on every code
edit) always misses cache and reruns — ordering instructions from least-to-most
frequently changing is what maximizes how much of the stack stays cached.
docker run -p 8000:8000 sets up a network address translation (NAT) rule on
the host: the container gets its own isolated network namespace (its own virtual
network interface, its own view of localhost) via Linux kernel namespaces, and the
port mapping tells the host's networking stack to forward incoming connections on
host port 8000 to the container's internal port 8000. This is why the app inside the
container can simply bind to 0.0.0.0:8000 without knowing anything about the host
machine's actual IP or port configuration — the kernel-level translation is what
makes "8000:8000" work identically regardless of what else is running on the host.
Inside docker-compose's network, the API reaching the database via the hostname
db isn't magic service discovery — Compose creates a private Docker network for
the whole docker-compose.yml file and runs an embedded DNS server on it that
resolves each service's name (from the YAML key, db) to that container's internal
IP address on the shared network, refreshed automatically as containers restart with
new IPs — ordinary DNS resolution, just scoped to containers on that one
Compose-created network rather than the wider internet.
needs: test in the GitHub Actions workflow creates an explicit dependency edge in
the workflow's job graph: GitHub Actions computes which jobs can run in parallel and
which must wait, and a job listed in another's needs only starts after that
dependency job's steps have all completed and exited successfully (non-zero exit
from any step fails the whole job). build-and-push therefore literally cannot
begin — its runner isn't even provisioned — until every step of test (including
the pytest run) has returned a zero exit code, which is the concrete mechanism
making "broken code can't reach the registry" true rather than aspirational.
Exercise¶
Write a Dockerfile and docker-compose.yml for the Level 3 Book Catalog API
that runs the API alongside a Postgres container (swap the SQLAlchemy URL to
postgresql://...), reading DATABASE_URL and SECRET_KEY from environment
variables with pydantic-settings. Then write a GitHub Actions workflow that
runs pytest on every push and only builds the Docker image on main after
tests pass.