Project 02 - Local Deployment Setup
Estimated time: 3–5 hours | Level: Intermediate
Before reading the requirements, answer this: what is the difference between ENV=production hardcoded in your Dockerfile and ENV=production in a .env file read at runtime?
The hardcoded value is baked into the image - it is the same in development, staging, and production whether you want it to be or not. The runtime .env value is different per environment, never committed to Git, and rotatable without rebuilding the image. The Twelve-Factor App principle is: build once, configure at runtime. This project implements that principle for the API from Project 01.
Learning Objectives
By completing this project you will have practiced:
- Writing a multi-stage
Dockerfilethat produces a minimal, secure runtime image - Composing a three-service stack (FastAPI + Nginx + PostgreSQL) with Docker Compose
- Configuring Nginx as a reverse proxy with rate limiting, gzip, and security headers
- Managing environment-based configuration with Pydantic
BaseSettings - Running Alembic database migrations:
init, first migration,upgrade,downgrade - Implementing a health check endpoint that tests DB connectivity
- Handling graceful shutdown with FastAPI's
@asynccontextmanagerlifespan - Writing a
Makefilethat makes all operations discoverable and repeatable
Project Structure
task-api-deploy/
├── app/ # Application source (from Project 01)
│ ├── main.py
│ ├── config.py # Pydantic BaseSettings
│ ├── database.py
│ ├── models/
│ ├── schemas/
│ ├── routers/
│ └── dependencies.py
├── alembic/
│ ├── env.py # Alembic environment config
│ ├── script.py.mako # Migration template
│ └── versions/
│ └── 001_initial_schema.py
├── nginx/
│ └── nginx.conf # Nginx reverse proxy config
├── Dockerfile # Multi-stage build
├── docker-compose.yml # FastAPI + Nginx + PostgreSQL
├── alembic.ini # Alembic configuration
├── Makefile # Operational targets
├── pyproject.toml
├── .env.example # Template for .env (committed to Git)
└── .env # Actual config (never committed to Git)
Requirements
R1 - Multi-Stage Dockerfile
The Dockerfile must have two stages:
- builder: installs all Python dependencies with
pip - runtime: minimal image, no build tools, runs as non-root user
The runtime image must not contain pip, setuptools, or any build tooling.
R2 - docker-compose.yml with Three Services
Three services:
api: FastAPI application (built fromDockerfile)nginx: reverse proxy (officialnginx:alpineimage)db: PostgreSQL 16 (officialpostgres:16-alpineimage)
nginx depends on api; api depends on db. Use a named Docker network.
R3 - Nginx Configuration
The nginx.conf must implement:
proxy_passto the FastAPIapiservice- Rate limiting with
limit_req_zone(10 requests/second per IP, burst of 20) - Gzip compression for
application/jsonresponses - Security headers:
X-Frame-Options,X-Content-Type-Options,X-XSS-Protection,Referrer-Policy - Proxy headers:
X-Real-IP,X-Forwarded-For,X-Forwarded-Proto
R4 - Pydantic BaseSettings
The config.py must use BaseSettings to read all configuration from environment variables with typed defaults. Fields must cover: database_url, app_name, app_version, debug, allowed_hosts, max_connections.
R5 - Alembic Migrations
Set up Alembic:
alembic.inipointing to the correct migration directoryalembic/env.pyusing thedatabase_urlfromSettings- First migration
001_initial_schema.pycreating thetaskstable upgrade()anddowngrade()both implemented
R6 - Health Check Endpoint
GET /health must return:
status: "ok"if both the application and database are reachablestatus: "degraded"with achecksdict if the DB is unreachable- HTTP 200 if healthy, HTTP 503 if degraded
R7 - Graceful Shutdown
The application must use @asynccontextmanager lifespan to:
- Set up the database connection pool at startup
- Dispose the connection pool at shutdown (not abruptly close connections)
- Handle
SIGTERMgracefully (Docker sendsSIGTERMbeforeSIGKILLwith a 10s grace period)
R8 - Makefile
Targets: make up, make down, make logs, make migrate, make test, make clean, make help.
Complete File Contents
.env.example
# Copy this to .env and fill in the values
# .env is never committed to Git - add it to .gitignore
# Application
APP_NAME="Task Management API"
APP_VERSION="0.1.0"
DEBUG=false
# Database - PostgreSQL
DATABASE_URL=postgresql://taskuser:changeme@db:5432/taskdb
# PostgreSQL container configuration (used by docker-compose.yml)
POSTGRES_USER=taskuser
POSTGRES_PASSWORD=changeme
POSTGRES_DB=taskdb
# Connection pool
MAX_CONNECTIONS=10
app/config.py
from pydantic_settings import BaseSettings, SettingsConfigDict
from pydantic import Field
from typing import Optional
class Settings(BaseSettings):
"""
Application configuration read from environment variables.
Pydantic BaseSettings reads from:
1. Environment variables (highest priority)
2. .env file (if it exists and env_file is set)
3. Default values defined here (lowest priority)
This means the same Docker image runs differently in dev/staging/production
simply by changing the environment variables - no rebuild needed.
"""
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=False,
)
# Application metadata
app_name: str = Field(default="Task Management API", description="Application display name")
app_version: str = Field(default="0.1.0", description="Application version string")
debug: bool = Field(default=False, description="Enable debug mode - never True in production")
# Database
database_url: str = Field(
default="sqlite:///./tasks.db",
description="SQLAlchemy database URL. Use postgresql:// for production.",
)
max_connections: int = Field(
default=10,
ge=1,
le=100,
description="SQLAlchemy connection pool size",
)
# Security
allowed_hosts: list[str] = Field(
default=["localhost", "127.0.0.1"],
description="Allowed host headers - set to your domain in production",
)
secret_key: Optional[str] = Field(
default=None,
description="Secret key for signing - required in production",
)
# Module-level singleton - import this everywhere, never instantiate Settings again
settings = Settings()
BaseSettings reads .env files only if env_file is set in model_config. If .env does not exist, no error is raised - it silently uses defaults. In production, never use a .env file: inject environment variables directly through your container orchestrator (Docker, Kubernetes, ECS). .env files are for local development only.
app/database.py (PostgreSQL-ready)
from sqlalchemy import create_engine, text
from sqlalchemy.orm import sessionmaker, DeclarativeBase
from sqlalchemy.exc import OperationalError
from app.config import settings
def make_engine():
"""
Create a SQLAlchemy engine appropriate for the configured database URL.
SQLite requires check_same_thread=False for FastAPI's thread pool.
PostgreSQL uses connection pooling via SQLAlchemy's built-in QueuePool.
"""
kwargs = {}
if settings.database_url.startswith("sqlite"):
kwargs["connect_args"] = {"check_same_thread": False}
else:
# PostgreSQL: configure the connection pool
kwargs["pool_size"] = settings.max_connections
kwargs["max_overflow"] = 5
kwargs["pool_pre_ping"] = True # test connections before use (catches stale connections)
return create_engine(settings.database_url, **kwargs)
engine = make_engine()
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
class Base(DeclarativeBase):
pass
def check_db_connectivity() -> bool:
"""
Test database connectivity by executing a trivial query.
Returns True if the database is reachable, False otherwise.
Used by the health check endpoint.
"""
try:
with engine.connect() as conn:
conn.execute(text("SELECT 1"))
return True
except OperationalError:
return False
app/main.py (with graceful shutdown)
import time
import uuid
import signal
import logging
from contextlib import asynccontextmanager
from typing import AsyncIterator
from fastapi import FastAPI, Request, Response, HTTPException
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from sqlalchemy.exc import OperationalError
from app.config import settings
from app.database import engine, check_db_connectivity
from app.routers import tasks
logger = logging.getLogger(__name__)
logging.basicConfig(
level=logging.DEBUG if settings.debug else logging.INFO,
format="%(asctime)s %(levelname)s %(name)s %(message)s",
)
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
"""
Application lifespan manager.
Startup:
- Log startup with version and database URL (redacted password)
- Verify database connectivity before accepting traffic
Shutdown:
- Dispose the SQLAlchemy connection pool gracefully
- Docker sends SIGTERM, waits 10 seconds, then sends SIGKILL
- Connection pool disposal ensures in-flight queries can complete
"""
# Startup
safe_url = settings.database_url.split("@")[-1] if "@" in settings.database_url else settings.database_url
logger.info(f"Starting {settings.app_name} v{settings.app_version}")
logger.info(f"Database: {safe_url}")
logger.info(f"Debug mode: {settings.debug}")
if not check_db_connectivity():
logger.warning("Database is not reachable at startup - health check will report degraded")
yield
# Shutdown: dispose the connection pool
# This waits for in-flight connections to return to the pool before closing them
logger.info("Disposing SQLAlchemy connection pool...")
engine.dispose()
logger.info(f"Shutdown complete for {settings.app_name}")
app = FastAPI(
title=settings.app_name,
version=settings.app_version,
description=(
"A production-quality Task Management REST API. "
"All error responses follow RFC 7807 Problem Details format."
),
lifespan=lifespan,
# Disable debug in production - never expose tracebacks to clients
debug=settings.debug,
)
# ── Middleware: Request ID ─────────────────────────────────────────────────────
@app.middleware("http")
async def request_id_middleware(request: Request, call_next) -> Response:
request_id = request.headers.get("X-Request-ID") or str(uuid.uuid4())
request.state.request_id = request_id
response = await call_next(request)
response.headers["X-Request-ID"] = request_id
return response
# ── Middleware: Timing ─────────────────────────────────────────────────────────
@app.middleware("http")
async def timing_middleware(request: Request, call_next) -> Response:
start = time.perf_counter()
response = await call_next(request)
elapsed_ms = (time.perf_counter() - start) * 1000
response.headers["X-Process-Time"] = f"{elapsed_ms:.2f}ms"
return response
# ── Middleware: Structured access logging ──────────────────────────────────────
@app.middleware("http")
async def access_log_middleware(request: Request, call_next) -> Response:
"""
Log every request as a single structured line.
Nginx will log at the edge; this logs at the application layer for
correlation with request IDs.
"""
response = await call_next(request)
request_id = getattr(request.state, "request_id", "-")
logger.info(
f'{request.client.host if request.client else "-"} '
f'"{request.method} {request.url.path}" '
f'{response.status_code} '
f'request_id={request_id}'
)
return response
# ── Exception Handlers: RFC 7807 ──────────────────────────────────────────────
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError) -> JSONResponse:
errors = exc.errors()
detail_parts = [
f"{'→'.join(str(x) for x in e['loc'] if x != 'body')}: {e['msg']}"
for e in errors
]
return JSONResponse(
status_code=422,
content={
"type": "https://example.com/errors/validation",
"title": "Validation Error",
"status": 422,
"detail": "; ".join(detail_parts),
"instance": str(request.url.path),
},
)
@app.exception_handler(HTTPException)
async def http_exception_handler(request: Request, exc: HTTPException) -> JSONResponse:
if isinstance(exc.detail, dict) and "type" in exc.detail:
return JSONResponse(status_code=exc.status_code, content=exc.detail)
return JSONResponse(
status_code=exc.status_code,
content={
"type": f"https://example.com/errors/http-{exc.status_code}",
"title": "HTTP Error",
"status": exc.status_code,
"detail": str(exc.detail),
"instance": str(request.url.path),
},
)
# ── Health check endpoint (R6) ─────────────────────────────────────────────────
@app.get("/health", tags=["Health"], summary="Service and database health check")
async def health_check():
"""
Returns service health status including database connectivity.
200 OK: service and database are both reachable
503 Error: service is up but database is unreachable
Used by:
- Docker Compose healthcheck
- Nginx upstream health checks
- Kubernetes liveness and readiness probes
- Monitoring and alerting systems
"""
db_ok = check_db_connectivity()
if db_ok:
return {
"status": "ok",
"version": settings.app_version,
"checks": {
"database": "ok",
},
}
else:
return JSONResponse(
status_code=503,
content={
"status": "degraded",
"version": settings.app_version,
"checks": {
"database": "unreachable",
},
},
)
# ── Mount routers ──────────────────────────────────────────────────────────────
app.include_router(tasks.router)
Dockerfile (multi-stage)
# ════════════════════════════════════════════════════════════════════════════════
# Stage 1: builder
# Purpose: install all Python dependencies
# This stage is NOT included in the final image - only its /install output is
# ════════════════════════════════════════════════════════════════════════════════
FROM python:3.12-slim AS builder
WORKDIR /build
# Upgrade pip before installing anything
RUN pip install --upgrade pip --quiet
# Copy dependency specification
# We copy pyproject.toml before source code so Docker layer cache
# is invalidated only when dependencies change, not on every code edit
COPY pyproject.toml .
# Install production dependencies to /install prefix
# --no-cache-dir: do not save .whl files - reduces image size
# --prefix=/install: install to a custom directory for easy COPY in runtime stage
RUN pip install --no-cache-dir --prefix=/install \
fastapi \
"uvicorn[standard]" \
"sqlalchemy[asyncio]" \
"pydantic[email]" \
pydantic-settings \
orjson \
psycopg2-binary \
alembic
# ════════════════════════════════════════════════════════════════════════════════
# Stage 2: runtime
# Purpose: minimal image with only the application and its dependencies
# No pip, no setuptools, no compiler, no build cache
# ════════════════════════════════════════════════════════════════════════════════
FROM python:3.12-slim AS runtime
# Install curl for Docker healthcheck (minimal, no extras)
RUN apt-get update && apt-get install -y --no-install-recommends curl && \
rm -rf /var/lib/apt/lists/*
# Security: create non-root user
# Never run application processes as root - a vulnerability exploit as root
# escapes the process boundary; as a non-root user, it is still contained
RUN groupadd --gid 1001 appgroup && \
useradd --uid 1001 --gid 1001 --no-create-home --shell /bin/false appuser
WORKDIR /app
# Copy installed packages from builder stage
# /usr/local is where pip installs packages by default
COPY --from=builder /install /usr/local
# Copy application source - only what the runtime needs
# No tests, no dev configs, no .env files, no documentation
COPY app/ ./app/
COPY alembic/ ./alembic/
COPY alembic.ini .
# Switch to non-root user
USER appuser
# Document the port - does not actually expose or publish it
EXPOSE 8000
# Graceful shutdown: uvicorn handles SIGTERM
# Docker sends SIGTERM → uvicorn finishes in-flight requests → exits cleanly
# Docker then sends SIGKILL after stop_grace_period (default 10s)
CMD ["uvicorn", "app.main:app", \
"--host", "0.0.0.0", \
"--port", "8000", \
"--workers", "1", \
"--timeout-graceful-shutdown", "10"]
docker-compose.yml
version: "3.9"
# Named network: all services communicate on this network using service names as hostnames
# The 'api' service reaches PostgreSQL at 'db:5432'
# Nginx reaches the FastAPI app at 'api:8000'
networks:
app-network:
driver: bridge
# Named volume: PostgreSQL data persists between container restarts
volumes:
postgres_data:
services:
# ── PostgreSQL database ──────────────────────────────────────────────────────
db:
image: postgres:16-alpine
restart: unless-stopped
networks:
- app-network
environment:
POSTGRES_USER: ${POSTGRES_USER:-taskuser}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-changeme}
POSTGRES_DB: ${POSTGRES_DB:-taskdb}
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
# pg_isready: lightweight check that the PostgreSQL server is accepting connections
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-taskuser} -d ${POSTGRES_DB:-taskdb}"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
# ── FastAPI application ──────────────────────────────────────────────────────
api:
build:
context: .
target: runtime
restart: unless-stopped
networks:
- app-network
environment:
DATABASE_URL: postgresql://${POSTGRES_USER:-taskuser}:${POSTGRES_PASSWORD:-changeme}@db:5432/${POSTGRES_DB:-taskdb}
APP_NAME: ${APP_NAME:-Task Management API}
APP_VERSION: ${APP_VERSION:-0.1.0}
DEBUG: ${DEBUG:-false}
MAX_CONNECTIONS: ${MAX_CONNECTIONS:-10}
depends_on:
db:
condition: service_healthy # wait until PostgreSQL passes its healthcheck
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 15s
# Do NOT expose ports directly - all traffic goes through Nginx
expose:
- "8000"
# ── Nginx reverse proxy ──────────────────────────────────────────────────────
nginx:
image: nginx:alpine
restart: unless-stopped
networks:
- app-network
ports:
- "80:80" # HTTP - Nginx listens here and proxies to 'api:8000'
volumes:
# Mount nginx.conf as read-only - Nginx reads it on start
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
depends_on:
api:
condition: service_healthy # wait until FastAPI passes its healthcheck
nginx/nginx.conf
# ── Rate limiting zone ────────────────────────────────────────────────────────
# Define a shared memory zone 'api_limit' for tracking request rates.
# 10m: 10 MB of shared memory (holds ~160,000 IP addresses)
# rate=10r/s: allow 10 requests per second per IP address
http {
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s;
# ── Gzip compression ──────────────────────────────────────────────────────
gzip on;
gzip_vary on;
gzip_min_length 1024; # only compress responses above 1 KB
gzip_types
application/json
application/javascript
text/plain
text/css;
# ── Upstream: FastAPI application ─────────────────────────────────────────
# 'api' resolves to the Docker Compose service name
# Port 8000 is where uvicorn listens inside the 'api' container
upstream fastapi_backend {
server api:8000;
keepalive 32; # keep 32 idle connections to the backend (reduces TCP overhead)
}
# ── Server block ──────────────────────────────────────────────────────────
server {
listen 80;
server_name _; # match any hostname \text{---} replace with your domain in production
# ── Security headers ──────────────────────────────────────────────────
# Prevent the browser from embedding this API in an iframe
add_header X-Frame-Options "DENY" always;
# Prevent MIME type sniffing \text{---} browser must respect Content-Type
add_header X-Content-Type-Options "nosniff" always;
# Enable XSS filter in older browsers
add_header X-XSS-Protection "1; mode=block" always;
# Do not send Referer header on cross-origin requests
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# ── Rate limiting ─────────────────────────────────────────────────────
# burst=20: allow a burst of 20 requests above the rate limit
# nodelay: don't queue burst requests \text{---} process them immediately up to burst
limit_req zone=api_limit burst=20 nodelay;
# Return 429 Too Many Requests when rate limit is exceeded
limit_req_status 429;
# ── Proxy configuration ───────────────────────────────────────────────
location / {
proxy_pass http://fastapi_backend;
proxy_http_version 1.1;
# Required for keepalive connections to work
proxy_set_header Connection "";
# Pass the real client IP to FastAPI
# Without this, FastAPI sees Nginx's IP (172.x.x.x) for all requests
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Pass the original Host header
proxy_set_header Host $host;
# Timeouts
proxy_connect_timeout 5s;
proxy_read_timeout 60s;
proxy_send_timeout 60s;
# Buffer settings: Nginx buffers the full response before sending to client
# Disable for streaming endpoints (NDJSON)
proxy_buffering on;
proxy_buffer_size 16k;
proxy_buffers 8 16k;
}
# ── Health check: bypass rate limiting for monitoring tools ────────────
location /health {
proxy_pass http://fastapi_backend;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
# No rate limiting on health checks
limit_req off;
}
# ── Hide Nginx version from Server header ──────────────────────────────
server_tokens off;
}
}
# ── Events block ──────────────────────────────────────────────────────────────
events {
# Maximum number of simultaneous connections per worker process
worker_connections 1024;
}
alembic.ini
[alembic]
# Relative to the location of alembic.ini (the project root)
script_location = alembic
# The database URL is set dynamically in env.py from Settings
# This placeholder is overridden - do not put real credentials here
sqlalchemy.url = driver://user:pass@localhost/dbname
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
alembic/env.py
"""
Alembic environment configuration.
This file runs every time an Alembic command is executed (alembic upgrade,
alembic downgrade, alembic revision, etc.).
Key responsibilities:
- Load the database URL from the application's Settings (not from alembic.ini)
- Point Alembic at the SQLAlchemy metadata (Base.metadata) for autogenerate
- Configure online and offline migration modes
"""
from logging.config import fileConfig
from sqlalchemy import engine_from_config, pool
from alembic import context
# Import the application's settings and Base so Alembic knows:
# 1. Where the database is (settings.database_url)
# 2. What the schema looks like (Base.metadata for --autogenerate)
from app.config import settings
from app.database import Base
# Import all models so they are registered on Base.metadata
# Without this import, Alembic's autogenerate sees an empty schema
import app.models.task # noqa: F401
# Alembic Config object (provides access to values from alembic.ini)
config = context.config
# Interpret the config file for Python logging
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# Override the sqlalchemy.url from alembic.ini with the app's settings
# This means migrations always run against the same DB as the application
config.set_main_option("sqlalchemy.url", settings.database_url)
# The SQLAlchemy metadata object - used by --autogenerate to detect schema changes
target_metadata = Base.metadata
def run_migrations_offline() -> None:
"""
Run migrations in offline mode.
Generates SQL DDL statements without connecting to the database.
Useful for: generating migration scripts to review before running,
or for databases where direct connectivity is not available.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""
Run migrations in online mode (default).
Connects to the database and applies migrations directly.
"""
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool, # NullPool: no connection pool for migrations
)
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=target_metadata,
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
alembic/versions/001_initial_schema.py
"""Initial schema - create tasks table
Revision ID: 001
Revises: (none - this is the first migration)
Create Date: 2024-03-15 12:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# Revision identifiers - used by Alembic to build the migration chain
revision = "001"
down_revision = None # None means this is the first migration (no parent)
branch_labels = None
depends_on = None
def upgrade() -> None:
"""
Create the tasks table.
Called by: alembic upgrade head
"""
op.create_table(
"tasks",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("title", sa.String(200), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column(
"status",
sa.Enum(
"pending", "in_progress", "done", "cancelled",
name="task_status",
),
nullable=False,
server_default="pending",
),
sa.Column("priority", sa.Integer(), nullable=False, server_default="2"),
sa.Column("assignee", sa.String(100), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("CURRENT_TIMESTAMP"),
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("CURRENT_TIMESTAMP"),
),
)
# Indexes for common filter/sort patterns
op.create_index("ix_tasks_id", "tasks", ["id"])
op.create_index("ix_tasks_title", "tasks", ["title"])
op.create_index("ix_tasks_status", "tasks", ["status"])
op.create_index("ix_tasks_priority", "tasks", ["priority"])
def downgrade() -> None:
"""
Drop the tasks table.
Called by: alembic downgrade -1
"""
op.drop_index("ix_tasks_priority", table_name="tasks")
op.drop_index("ix_tasks_status", table_name="tasks")
op.drop_index("ix_tasks_title", table_name="tasks")
op.drop_index("ix_tasks_id", table_name="tasks")
op.drop_table("tasks")
# Drop the Enum type (PostgreSQL-specific - Enums are named types in PostgreSQL)
sa.Enum(name="task_status").drop(op.get_bind(), checkfirst=True)
Makefile
# Makefile for task-api deployment operations
# Run 'make help' to see all available targets.
.PHONY: help up down logs migrate migrate-down migrate-status test clean \
build shell db-shell ps health
.DEFAULT_GOAL := help
# ── Help ──────────────────────────────────────────────────────────────────────
help: ## Show this help message
@echo "Usage: make [target]"
@echo ""
@echo "Targets:"
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \
| awk 'BEGIN {FS = ":.*?## "}; {printf " \%-22s \%s\n", $$1, $$2}'
# ── Docker Compose operations ─────────────────────────────────────────────────
up: ## Start all services (build if needed, run in background)
docker compose up --build -d
@echo "Services started. API available at http://localhost:80"
@echo "Run 'make logs' to follow logs, 'make health' to check status"
down: ## Stop all services (preserves volumes)
docker compose down
down-v: ## Stop all services AND delete volumes (destroys database data)
docker compose down -v
@echo "Warning: database volume deleted"
restart: ## Restart all services without rebuilding
docker compose restart
build: ## Build Docker images without starting services
docker compose build
ps: ## Show running services and their status
docker compose ps
logs: ## Follow logs for all services (Ctrl+C to stop)
docker compose logs -f
logs-api: ## Follow logs for the API service only
docker compose logs -f api
logs-nginx: ## Follow logs for the Nginx service only
docker compose logs -f nginx
# ── Database migrations (Alembic) ─────────────────────────────────────────────
migrate: ## Apply all pending migrations (alembic upgrade head)
docker compose exec api alembic upgrade head
@echo "Migrations applied"
migrate-down: ## Roll back the last migration (alembic downgrade -1)
docker compose exec api alembic downgrade -1
@echo "Last migration rolled back"
migrate-status: ## Show current migration state (alembic current)
docker compose exec api alembic current
migrate-history: ## Show migration history
docker compose exec api alembic history --verbose
# ── Testing ───────────────────────────────────────────────────────────────────
test: ## Run the full test suite against the in-memory test database
docker compose exec api pytest tests/ --tb=short -q
@echo "Tests complete"
test-cov: ## Run tests with coverage report
docker compose exec api pytest tests/ --cov=app --cov-report=term-missing
# ── Health checks ─────────────────────────────────────────────────────────────
health: ## Check service health (curl GET /health)
@echo "Checking API health..."
@curl -sf http://localhost/health | python3 -m json.tool || \
(echo "API health check failed \text{---} run 'make logs-api' to investigate"; exit 1)
# ── Interactive shells ────────────────────────────────────────────────────────
shell: ## Open a shell inside the running API container
docker compose exec api /bin/bash
db-shell: ## Open a psql shell inside the PostgreSQL container
docker compose exec db psql -U $${POSTGRES_USER:-taskuser} -d $${POSTGRES_DB:-taskdb}
# ── Cleanup ───────────────────────────────────────────────────────────────────
clean: down ## Stop services and remove all generated artifacts
docker compose down -v --remove-orphans
find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
find . -type f -name "*.pyc" -delete
rm -rf .pytest_cache/ .mypy_cache/ .ruff_cache/
@echo "Clean complete"
Alembic Workflow Reference
# First-time setup (already done if you have alembic/env.py and versions/001...)
# Initialize Alembic in a new project:
# alembic init alembic
# Apply all migrations to the database (run after 'make up')
make migrate
# Equivalent to: docker compose exec api alembic upgrade head
# Roll back one migration
make migrate-down
# Equivalent to: docker compose exec api alembic downgrade -1
# Check current migration state
make migrate-status
# Shows: INFO [alembic.runtime.migration] Running on postgresql://...
# 001 (head)
# Generate a new migration after changing a model
docker compose exec api alembic revision --autogenerate -m "add due_date to tasks"
# Creates: alembic/versions/002_add_due_date_to_tasks.py
# Review the generated file before applying it - autogenerate is not always perfect
# Apply the new migration
make migrate
Always review autogenerated migrations before running them. Alembic detects schema changes by comparing Base.metadata to the current database state - but it cannot detect data migrations, index renames, or column renames (it sees those as drop + add). Any migration that drops data must be manually reviewed and tested with a downgrade() that restores the data.
Verification Checklist
Before submitting this project, verify each item:
-
make up- all three services start without errors -
docker compose ps- api, nginx, and db are allhealthy -
make migrate- applies migration001without errors -
curl http://localhost/health→{"status": "ok", ...}(going through Nginx) -
POST http://localhost/tasks/with JSON body →201(Nginx proxies to FastAPI) -
make logs-nginx- shows access logs for the requests -
make migrate-down- rolls back migration cleanly;make migratere-applies -
make migrate-status- shows001 (head)after full migration -
make down-v && make up && make migrate- full reset works end-to-end -
make test- all tests pass inside the container - Sending 25+ rapid requests to
http://localhost/tasks/→ some return429(Nginx rate limit) - Response headers include
X-Frame-Options,X-Content-Type-Options -
make healthexits 0 and shows the JSON health response
Extension Challenges
Extension 1 - Add HTTPS with a self-signed certificate
Generate a self-signed TLS certificate and configure Nginx to listen on port 443 with SSL. Redirect port 80 to 443. Use openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -days 365 -nodes and mount both files into the Nginx container. Update docker-compose.yml to expose port 443.
Extension 2 - Add a second migration
Add a due_date column (nullable DateTime(timezone=True)) to the tasks table. Write the Alembic migration with upgrade() (add column) and downgrade() (drop column). Apply it with make migrate. Verify with make migrate-status.
Extension 3 - Add Prometheus metrics
Add prometheus-fastapi-instrumentator to expose GET /metrics for Prometheus scraping. Add a prometheus service to docker-compose.yml with a prometheus.yml scrape config pointing at the FastAPI metrics endpoint. Verify metrics appear at http://localhost:9090.
