Skip to main content

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 Dockerfile that 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 @asynccontextmanager lifespan
  • Writing a Makefile that 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 from Dockerfile)
  • nginx: reverse proxy (official nginx:alpine image)
  • db: PostgreSQL 16 (official postgres:16-alpine image)

nginx depends on api; api depends on db. Use a named Docker network.

R3 - Nginx Configuration

The nginx.conf must implement:

  • proxy_pass to the FastAPI api service
  • Rate limiting with limit_req_zone (10 requests/second per IP, burst of 20)
  • Gzip compression for application/json responses
  • 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.ini pointing to the correct migration directory
  • alembic/env.py using the database_url from Settings
  • First migration 001_initial_schema.py creating the tasks table
  • upgrade() and downgrade() both implemented

R6 - Health Check Endpoint

GET /health must return:

  • status: "ok" if both the application and database are reachable
  • status: "degraded" with a checks dict 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 SIGTERM gracefully (Docker sends SIGTERM before SIGKILL with 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()
warning

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
tip

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 all healthy
  • make migrate - applies migration 001 without 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 migrate re-applies
  • make migrate-status - shows 001 (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 return 429 (Nginx rate limit)
  • Response headers include X-Frame-Options, X-Content-Type-Options
  • make health exits 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.

© 2026 EngineersOfAI. All rights reserved.