Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
**
!pyproject.toml
!uv.lock
!README.md
!LICENSE
!src/
!src/**
!alembic.ini
!alembic/
!alembic/**
15 changes: 14 additions & 1 deletion .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,17 @@ updates:
labels:
- "dependencies"
commit-message:
prefix: "ci"
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"
39 changes: 39 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
41 changes: 36 additions & 5 deletions DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<version> .
```

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:<version> 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:<version>
```

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

Expand Down Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,17 @@ Open <http://127.0.0.1:8000/docs>. 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
Expand All @@ -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 |
Expand Down
47 changes: 47 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
19 changes: 15 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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... |
Expand Down Expand Up @@ -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

Expand All @@ -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.

Expand Down
2 changes: 2 additions & 0 deletions RELEASE_CHECKLIST.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
48 changes: 48 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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:
10 changes: 10 additions & 0 deletions scripts/dev.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
Expand Down Expand Up @@ -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
Expand All @@ -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,
}
Expand Down
31 changes: 31 additions & 0 deletions tests/test_container_files.py
Original file line number Diff line number Diff line change
@@ -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
Loading