diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..26411d6 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +** +!pyproject.toml +!uv.lock +!README.md +!LICENSE +!src/ +!src/** +!alembic.ini +!alembic/ +!alembic/** diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 7e749b1..f46fa02 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -25,4 +25,17 @@ updates: labels: - "dependencies" commit-message: - prefix: "ci" \ No newline at end of file + prefix: "ci" + + - package-ecosystem: "docker" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + time: "09:30" + timezone: "Asia/Bangkok" + open-pull-requests-limit: 5 + labels: + - "dependencies" + commit-message: + prefix: "deps" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d624aa8..e948c09 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -128,3 +128,42 @@ jobs: "import tomllib; from app.main import app; expected = tomllib.load(open('pyproject.toml', 'rb'))['project']['version']; assert app.version == expected, (app.version, expected)" + + - name: Build production container image + run: docker build --tag fastapi-production-api:ci . + + - name: Verify production container identity and dependencies + run: | + test "$(docker run --rm --entrypoint id fastapi-production-api:ci -u)" = "10001" + docker run --rm --entrypoint python fastapi-production-api:ci -c \ + "import importlib.util; assert importlib.util.find_spec('pytest') is None" + + - name: Smoke-test production container + run: | + docker run --detach \ + --name fastapi-production-api-ci \ + --network host \ + --read-only \ + --tmpfs /tmp \ + --cap-drop ALL \ + --security-opt no-new-privileges:true \ + --env DATABASE_URL="$DATABASE_URL" \ + --env SECRET_KEY="$SECRET_KEY" \ + fastapi-production-api:ci + + for attempt in {1..30}; do + if curl --fail --silent http://127.0.0.1:8000/health/live \ + && curl --fail --silent http://127.0.0.1:8000/health/ready; then + exit 0 + fi + sleep 1 + done + exit 1 + + - name: Show production container logs + if: failure() + run: docker logs fastapi-production-api-ci 2>/dev/null || true + + - name: Remove production container + if: always() + run: docker rm --force fastapi-production-api-ci 2>/dev/null || true diff --git a/CHANGELOG.md b/CHANGELOG.md index 05a062c..fff52a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Added +- Multi-stage production container image with locked runtime-only dependencies, + non-root execution, liveness healthcheck, Compose migrations, and CI smoke tests - Explicit trusted-proxy IP/CIDR allowlisting for Uvicorn client-address resolution, with canonical client IPs shared by rate limiting and request logs - Administrative account disable/re-enable lifecycle with immediate access diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 0a05494..c1641d0 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -28,11 +28,41 @@ at the container level. | Single Linux host | Nginx -> Gunicorn -> Uvicorn workers | Small services with direct host operations | | Container platform | Load balancer -> one Uvicorn process per container | Platforms that own restarts, health probes, and horizontal scaling | -This repository includes a complete single-host example below. It does not yet -ship a production image or orchestration manifest. On a container platform, use -the same locked install and migration rules, start the application with -`uv run uvicorn app.main:app --host 0.0.0.0 --port 8000`, configure liveness and -readiness separately, and expose `/metrics` only to the monitoring network. +This repository includes both the complete single-host example below and a +multi-stage production `Dockerfile`. The image runs one Uvicorn process as an +unprivileged user, contains only locked runtime dependencies, and provides a +liveness healthcheck. Container platforms must still configure readiness, +release migrations, secrets, ingress, and restricted metrics exposure. + +## Production container image + +Build an immutable image from a reviewed tag or commit: + +```bash +docker build --pull --tag fastapi-production-api: . +``` + +The runtime image does not contain the source checkout, `uv`, test tools, or an +environment file. Inject configuration at runtime and run migrations as a +separate release task before starting application replicas: + +```bash +docker run --rm --env-file .env \ + fastapi-production-api: alembic upgrade head + +docker run --detach --name fastapi-production-api \ + --env-file .env \ + --publish 8000:8000 \ + --read-only --tmpfs /tmp \ + --cap-drop ALL --security-opt no-new-privileges:true \ + fastapi-production-api: +``` + +Ensure database, Redis, SMTP, OIDC, and telemetry hostnames in `.env` resolve +from the container network. Do not run migrations in every replica's startup +command. Use `/health/live` for container restart decisions and `/health/ready` +for traffic admission. The image healthcheck intentionally uses liveness so a +temporary database outage does not create a restart loop. ## 1. Prepare the server @@ -394,6 +424,7 @@ trace-context columns; tracing metadata is not a correctness dependency. - [ ] Back up the production database - [ ] Test migrations and rollback procedures in staging - [ ] Run test, lint, and dependency-audit jobs successfully +- [ ] Build the production image and smoke-test liveness/readiness as non-root - [ ] Restrict database and service-account permissions - [ ] Restrict the application port to the trusted proxy - [ ] Set `FORWARDED_ALLOW_IPS` to direct proxy peers only and test spoofed headers diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 1821aed..9e9024a 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -52,6 +52,17 @@ Open . To create a local user, call `POST /register/` from Swagger UI; the repository intentionally does not ship a shared demo password. +To exercise the production image locally, start the complete Compose stack: + +```bash +python scripts/dev.py stack-up +``` + +The one-shot `migrate` service applies migrations before `api` starts. The API +container runs without root privileges, Linux capabilities, or a writable root +filesystem. Inspect it with `docker compose ps` and `docker compose logs api`, +then stop it without deleting database data using `docker compose down`. + ## Run durable email workers Generate a dedicated Fernet key, set `EMAIL_DELIVERY_MODE=outbox`, configure @@ -78,6 +89,7 @@ missing or different from the key used to enqueue pending payloads. | Command | Purpose | | --- | --- | | `python scripts/dev.py db-up` | Start PostgreSQL and Redis and wait until healthy | +| `python scripts/dev.py stack-up` | Build and start the complete containerized stack | | `python scripts/dev.py db-down` | Stop Compose services without deleting data | | `python scripts/dev.py migrate` | Apply pending Alembic migrations | | `python scripts/dev.py serve` | Run Uvicorn with auto-reload | diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f6de0b0 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,47 @@ +# syntax=docker/dockerfile:1.7 + +FROM ghcr.io/astral-sh/uv:0.12.0 AS uv + +FROM python:3.13-slim-trixie AS builder + +COPY --from=uv /uv /bin/uv + +ENV UV_COMPILE_BYTECODE=1 \ + UV_LINK_MODE=copy \ + UV_PYTHON_DOWNLOADS=0 + +WORKDIR /app + +COPY pyproject.toml uv.lock README.md LICENSE ./ +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --locked --no-dev --no-install-project + +COPY src ./src +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --locked --no-dev --no-editable + + +FROM python:3.13-slim-trixie AS runtime + +ENV PATH="/app/.venv/bin:$PATH" \ + PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +RUN groupadd --system --gid 10001 app \ + && useradd --system --uid 10001 --gid app \ + --home-dir /nonexistent --shell /usr/sbin/nologin app + +WORKDIR /app + +COPY --from=builder --chown=10001:10001 /app/.venv /app/.venv +COPY --chown=10001:10001 alembic.ini ./alembic.ini +COPY --chown=10001:10001 alembic ./alembic + +USER 10001:10001 + +EXPOSE 8000 + +HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ + CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health/live', timeout=2)"] + +CMD ["fastapi-production-api"] diff --git a/README.md b/README.md index 3848f1a..1b0aece 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ security middleware, automated tests, and a release-ready GitHub workflow. | API hardening | CORS, security headers, rate limiting, and error handlers | | Reliability | Liveness/readiness probes, transaction rollback, and request logging | | Quality | Pytest, Ruff, dependency audit, and GitHub Actions CI | -| Operations | Environment-based configuration and Gunicorn/Uvicorn guidance | +| Operations | Non-root production image, Compose stack, and Gunicorn/Uvicorn guidance | ## Quick start @@ -71,6 +71,15 @@ Open: See [DEVELOPMENT.md](DEVELOPMENT.md) for individual commands, manual setup, contributor workflows, and local troubleshooting. +To build and run the complete containerized development stack instead: + +```bash +python scripts/dev.py stack-up +``` + +The helper creates `.env` with generated local secrets when needed. Compose +runs migrations once before starting the non-root API container. + ## Documentation map | Guide | Use it when you need to... | @@ -274,7 +283,8 @@ Read [DEPLOYMENT.md](DEPLOYMENT.md) for the reverse proxy, systemd, TLS, and deployment checklist, and [MONITORING.md](MONITORING.md) for probes, Prometheus, multi-worker metrics, alerting, and troubleshooting. Container orchestration platforms should normally run one Uvicorn process per container and scale at -the container level. +the container level. The included multi-stage `Dockerfile` installs only locked +runtime dependencies and runs as UID/GID `10001`. ## Known limitations @@ -288,8 +298,9 @@ the container level. exact redirect configuration, and application-specific threat-model review. - TOTP reduces password-only risk but is not phishing resistant. Prefer WebAuthn/passkeys when the application requires phishing-resistant MFA. -- The provided Docker Compose service runs PostgreSQL for local development; it - does not yet build or deploy the API container. +- The Compose stack is intended for local development and evaluation. Production + scheduling, ingress, secret injection, and persistent services remain + platform responsibilities. - Deployment defaults must be reviewed for your traffic, proxy topology, secrets platform, backup policy, and compliance requirements. diff --git a/RELEASE_CHECKLIST.md b/RELEASE_CHECKLIST.md index 72e8271..0289a57 100644 --- a/RELEASE_CHECKLIST.md +++ b/RELEASE_CHECKLIST.md @@ -51,6 +51,8 @@ python scripts/dev.py check - [ ] Dependency audit passes with `uv run pip-audit`. - [ ] Distribution build passes with `uv build`. - [ ] The release wheel is smoke-tested in an isolated environment. +- [ ] The production image builds from the lockfile, runs as non-root, and + passes liveness/readiness smoke tests with a read-only root filesystem. - [ ] Alembic has exactly one head. - [ ] The full migration chain upgrades successfully to `head`. - [ ] The newest migration's rollback/forward procedure was verified against diff --git a/ROADMAP.md b/ROADMAP.md index cafb664..9845f24 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -66,7 +66,7 @@ Status: in progress Status: exploratory -- Container image and Compose development stack +- Container image and Compose development stack (completed) - Kubernetes deployment example - Cloud deployment guides - Backup, disaster recovery, and operational runbooks diff --git a/docker-compose.yml b/docker-compose.yml index 90d47af..00c986e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,3 +1,7 @@ +x-api-environment: &api-environment + DATABASE_URL: postgresql+psycopg://fastapi_user:fastapi_password@postgres:5432/fastapi_db + REDIS_URL: redis://redis:6379/0 + services: postgres: image: postgres:17-alpine @@ -38,6 +42,50 @@ services: timeout: 5s retries: 10 + migrate: + build: + context: . + target: runtime + image: fastapi-production-api:local + command: ["alembic", "upgrade", "head"] + env_file: .env + environment: *api-environment + depends_on: + postgres: + condition: service_healthy + read_only: true + tmpfs: + - /tmp + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + restart: "no" + + api: + build: + context: . + target: runtime + image: fastapi-production-api:local + env_file: .env + environment: *api-environment + depends_on: + migrate: + condition: service_completed_successfully + redis: + condition: service_healthy + ports: + - "8000:8000" + init: true + read_only: true + tmpfs: + - /tmp + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + restart: unless-stopped + volumes: postgres_data: redis_data: diff --git a/scripts/dev.py b/scripts/dev.py index adfdce2..866bfeb 100644 --- a/scripts/dev.py +++ b/scripts/dev.py @@ -126,6 +126,12 @@ def services_up() -> None: run_command(["docker", "compose", "up", "-d", "--wait", "postgres", "redis"]) +def stack_up() -> None: + require_docker_engine() + ensure_env() + run_command(["docker", "compose", "up", "--build", "-d", "--wait"]) + + def database_down() -> None: require_docker_engine() run_command(["docker", "compose", "down"]) @@ -165,6 +171,9 @@ def build_parser() -> argparse.ArgumentParser: subparsers.add_parser( "db-up", help="Start and wait for local PostgreSQL and Redis." ) + subparsers.add_parser( + "stack-up", help="Build and start the complete containerized stack." + ) subparsers.add_parser("db-down", help="Stop local Compose services.") subparsers.add_parser("check", help="Run the complete CI-equivalent quality gate.") return parser @@ -176,6 +185,7 @@ def main(argv: list[str] | None = None) -> int: "serve": serve, "migrate": migrate, "db-up": services_up, + "stack-up": stack_up, "db-down": database_down, "check": check, } diff --git a/tests/test_container_files.py b/tests/test_container_files.py new file mode 100644 index 0000000..a8b5d74 --- /dev/null +++ b/tests/test_container_files.py @@ -0,0 +1,31 @@ +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parents[1] + + +def test_production_image_is_locked_minimal_and_non_root(): + dockerfile = (PROJECT_ROOT / "Dockerfile").read_text(encoding="utf-8") + + assert "uv sync --locked --no-dev --no-editable" in dockerfile + assert "FROM python:3.13-slim-trixie AS runtime" in dockerfile + assert "USER 10001:10001" in dockerfile + assert "HEALTHCHECK" in dockerfile + assert 'CMD ["fastapi-production-api"]' in dockerfile + + +def test_docker_context_excludes_secrets_and_local_environments(): + dockerignore = (PROJECT_ROOT / ".dockerignore").read_text(encoding="utf-8") + + assert dockerignore.startswith("**\n") + assert "!.env" not in dockerignore + assert "!.venv" not in dockerignore + assert "!src/**" in dockerignore + + +def test_compose_runs_migrations_before_the_api_with_runtime_hardening(): + compose = (PROJECT_ROOT / "docker-compose.yml").read_text(encoding="utf-8") + + assert "service_completed_successfully" in compose + assert 'command: ["alembic", "upgrade", "head"]' in compose + assert compose.count("read_only: true") == 2 + assert compose.count("no-new-privileges:true") == 2 diff --git a/tests/test_dev_script.py b/tests/test_dev_script.py index 5a8b803..e37dc04 100644 --- a/tests/test_dev_script.py +++ b/tests/test_dev_script.py @@ -127,6 +127,19 @@ def test_database_commands_use_compose_without_deleting_data(monkeypatch): assert ["docker", "compose", "down", "--volumes"] not in commands +def test_stack_up_prepares_env_and_builds_the_complete_stack(monkeypatch): + monkeypatch.setattr(dev, "require_docker_engine", lambda: None) + prepared = [] + monkeypatch.setattr(dev, "ensure_env", lambda: prepared.append(True)) + commands = [] + monkeypatch.setattr(dev, "run_command", commands.append) + + dev.stack_up() + + assert prepared == [True] + assert commands == [["docker", "compose", "up", "--build", "-d", "--wait"]] + + def test_docker_preflight_explains_unavailable_engine(monkeypatch): monkeypatch.setattr(dev, "require_command", lambda _: None) monkeypatch.setattr(