Deployment
Contents
- Dockerfile
- One image, three roles
- Migrations run pre-deploy
- Infrastructure as code — Terraform
- Secrets
- Health checks
- CI
One Docker image, one managed platform, all infrastructure in Terraform. The web service, the worker, and migrations are the same image run with different commands — never separate codebases.
Dockerfile
Build on the astral-sh/uv image and use a two-stage uv sync so the dependency
layer caches independently of your source.
FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim
WORKDIR /app
ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy
# 1) deps only — cached unless the lockfile changes
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-install-project --no-dev
# 2) project source
COPY . .
RUN uv sync --frozen --no-dev
RUN useradd -m appuser
USER appuser
EXPOSE 8000
CMD ["uv", "run", "uvicorn", "myapp.main:app", "--host", "0.0.0.0", "--port", "8000", \
"--proxy-headers", "--forwarded-allow-ips", "*"]--proxy-headers --forwarded-allow-ips '*' makes the app trust the platform's
load-balancer headers (correct scheme/host for secure cookies and redirects).
The client-IP trap this opens up
Trusting X-Forwarded-For (XFF) for scheme/host is fine. Trusting it to identify
who a request is from is a footgun, and it's the same mistake in three places:
rate-limit keys, IP allowlists, and the IP you write to audit logs. A proxy
appends the real peer to the XFF chain, so the left-most value is whatever the
client sent — attacker-controlled. slowapi's get_remote_address returns
request.client.host, which under --forwarded-allow-ips '*' is also derived from
that same untrusted header. So a naive limiter keyed on the left-most XFF (or on
request.client.host with a wildcard trust) buckets by a value the attacker picks:
# WRONG — attacker rotates X-Forwarded-For per request, lands in a fresh bucket
# every time. Measured effect: ~0/50 login attempts blocked vs 45/50 on the real IP.
client_ip = request.headers["x-forwarded-for"].split(",")[0].strip()
limiter = Limiter(key_func=get_remote_address) # request.client.host, same problemThe trustworthy value is a fixed position counted from the right — you trust exactly as many hops as you actually run. With one managed load balancer in front, that's the last entry:
# RIGHT — trust N proxies (here 1); take the (N+1)th from the right.
TRUSTED_PROXY_HOPS = 1
def real_client_ip(request: Request) -> str:
xff = request.headers.get("x-forwarded-for")
if xff:
chain = [p.strip() for p in xff.split(",") if p.strip()]
if len(chain) > TRUSTED_PROXY_HOPS:
return chain[-(TRUSTED_PROXY_HOPS + 1)]
return request.client.host
limiter = Limiter(key_func=real_client_ip)Define this once and reuse it everywhere an IP is consumed — the bug reappears
per-copy when _get_client_ip is reimplemented in the limiter, the auth router, and
the audit service independently. Nothing else guards brute force by default
(a failed_attempts column with no lockout enforcement does not), so a spoofable
limiter is the whole defense. Note too that an in-memory limiter is per process,
so effective limits multiply by instance count — back anything that must hold
app-wide (login, password-reset, registration) with a shared store (Redis), not
process memory.
Authentication limits fail closed when the shared store fails
A shared limiter is a security dependency, not an optional cache. Falling back to an in-process counter during a Redis outage gives every process a fresh allowance and restores the bypass the shared store was meant to remove. For authentication routes, fail closed instead:
- Return 503 Service Unavailable with
Retry-Afterwhen Redis is unavailable. Keep this distinct from 429 Too Many Requests, which means the limiter is healthy and the client exhausted its allowance. - Reject a per-process backend in deployed configuration. A safe code path is not
enough if a production environment can silently select
memory. - Record nothing locally on failure. A local fallback cannot preserve a global limit and makes behavior depend on which instance receives the next request.
Use one atomic Redis operation (usually a Lua script) to increment the bucket,
set expiry only for a new bucket, and return the count plus its remaining TTL.
Report that actual TTL in X-RateLimit-Reset / retry_after; returning the
full window on every response makes the apparent reset time slide even though
the Redis key does not. Count blocked attempts too, but do not extend the window:
local count = redis.call("INCR", KEYS[1])
local ttl = redis.call("TTL", KEYS[1])
if ttl < 0 then
redis.call("EXPIRE", KEYS[1], ARGV[1])
ttl = tonumber(ARGV[1])
end
return {count, ttl}Test the contract at three levels:
- A fake client for fast unit tests around 429 vs. 503 and headers.
- A real disposable Redis instance for command semantics: TTL sentinels, automatic expiry, and the exact async-client return/await behavior.
- A dead port for a deterministic outage test; assert 503 +
Retry-After, no local allowance, and no authentication work performed.
Fakes often encode the command behavior the implementation author expected. A one-minute real-service probe is what proves the assumption.
One image, three roles
| Role | Command |
|---|---|
| web | uvicorn myapp.main:app --proxy-headers ... |
| worker | python -m myapp.worker (cron or always-on) |
| migrate | alembic upgrade head (pre-deploy hook) |
An entrypoint.sh that dispatches on $1 (web/worker/migrate) keeps this in
one place.
Migrations run pre-deploy
Run alembic upgrade head as the platform's pre-deploy step, so a new release's
schema is in place before traffic hits it. Do not run migrations at app startup
in production — concurrent instances race, and a bad migration takes down boot.
Infrastructure as code — Terraform
Provision the platform with Terraform; keep a platform Blueprint file (a
render.yaml-style manifest) as the documented fallback. Organize by concern:
terraform/
main.tf # locals: shared env map, CSP, generated secrets
variables.tf # inputs (with sensible defaults)
versions.tf # provider pins
render.tf # project + Postgres + web service + worker/cron (+ static site)
stripe.tf # products, prices, webhook endpoint
posthog.tf # project + dashboards
outputs.tf# render.tf (shape — applies to any managed platform with a TF provider)
resource "platform_postgres" "db" { plan = "starter" }
resource "platform_web_service" "web" {
image = var.image
pre_deploy_command = "alembic upgrade head"
health_check_path = "/health"
env_vars = local.app_env # shared map, see below
}
resource "platform_cron_job" "worker" {
image = var.image
schedule = "*/5 * * * *"
command = "python -m myapp.worker"
env_vars = local.app_env
}Typical provider set: the platform provider, the Stripe provider, the
PostHog provider, and random for secret generation. One Terraform tree per
app — the app owns all of its own infrastructure; don't centralize it in a shared
infra repo.
Secrets
Two clean patterns, no copy-pasting from dashboards:
- Generate in Terraform with
random_password(e.g.secret_key) and inject the same value into both the web and worker services via the sharedlocal.app_env. - Mark no-sync (
sync = false/ equivalent) for values that must come from elsewhere, and set them out of band. The provisioned-by-TF Stripe webhook secret (covered in the payments reference) flows straight from the resource into the env.
The secret_key must be identical across web and worker (they verify the same
signed tokens). Never commit real secrets; .env is gitignored and .env.example
documents the variable names only.
Health checks
Expose /health (and optionally /readyz) that runs SELECT 1 and returns 503 if
the DB is unreachable. Point the platform's health check at it so a database-less
instance is pulled from rotation instead of serving errors.
CI
GitHub Actions: uv sync, ruff check + ruff format --check, alembic upgrade head against a Postgres service, then pytest (coverage-gated). For Approach B
frontends, a parallel job does npm ci + lint + build. Build/push the Docker image
on tag or main.