Deployment

What to set, what to check, and the two things people get wrong behind a proxy.

Available since v0.1.0

The environment

Every setting can come from the environment: FASTFORT_ prefix, double underscore for nesting.

FASTFORT_SECRET_KEY=…                      # from `fastfort generate-secret`
FASTFORT_DEBUG=false

FASTFORT_SECURITY__COOKIE_SECURE=true
FASTFORT_SECURITY__HSTS_SECONDS=31536000
FASTFORT_SECURITY__CSRF_ENABLED=true

FASTFORT_ADMIN__COUNT_STRATEGY=capped      # on a large table
FASTFORT_ADMIN__COUNT_CAP=10000
FASTFORT_ADMIN__EXPORT_LIMIT=50000

FASTFORT_UI__ENVIRONMENT_LABEL=PRODUCTION
FASTFORT_UI__ENVIRONMENT_TONE=danger

extra="forbid" on every group, so a misspelled key is a start-up error rather than a setting that silently does nothing.

Gate the deploy on the check

uv run fastfort check --app main:fort --deploy

Exits non-zero. It reports debug=True, an insecure cookie, a JavaScript-readable cookie, CSRF disabled, security headers disabled, an access token lifetime over an hour, and refresh rotation turned off.

- run: uv run fastfort check --app main:fort --deploy
  env:
    FASTFORT_SECRET_KEY: ${{ secrets.FASTFORT_SECRET_KEY }}
    FASTFORT_DEBUG: "false"
    FASTFORT_SECURITY__COOKIE_SECURE: "true"

Or make start-up refuse:

settings.require_production_ready()

The two things people get wrong behind a proxy

trust_forwarded_for

FASTFORT_SECURITY__TRUST_FORWARDED_FOR=true

Only when your proxy overwrites X-Forwarded-For. Behind one that appends, or behind none, a client can forge its own address — and lockout and audit records become attributable to whoever the attacker chose.

Do not compress HTML

Static assets are already negotiated Brotli → gzip → identity by FastFort, cached per encoding per process. HTML is deliberately excluded, because a page holds a CSRF token and request-chosen text — the BREACH pattern.

So: no GZipMiddleware, and turn HTML compression off at the proxy for the admin’s paths.

location /admin {
    gzip off;
    proxy_pass http://app;
    proxy_set_header X-Forwarded-For $remote_addr;   # overwrite, not append
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header Host $host;
}

Caddy has no way to say “everything except HTML” — its response matcher has no not — so the list is a positive one:

encode {
	zstd
	gzip
	match {
		header Content-Type application/json*
		header Content-Type text/csv*
	}
}

Which is the better shape anyway: a content type nobody has thought about yet arrives uncompressed and gets considered, rather than being compressed because nobody remembered to add it to a deny-list.

Count the CDN as a proxy

This is the one that gets missed, and it is worth stating on its own because this project’s own demo shipped with it wrong. The origin was corrected to leave HTML alone, the deploy went out green, and /admin/login still came back:

content-encoding: zstd
server: cloudflare

Cloudflare, Fastly, CloudFront and every other CDN compress HTML at their edge by default, and they terminate TLS — so the compressed length is still what reaches the client, and BREACH does not care which machine did the compressing. Fixing the origin and stopping there fixes nothing.

On Cloudflare it is Rules → Compression Rules: match the admin’s path and set compression off. The equivalent exists on every CDN worth using; what matters is remembering that you have two proxies and only fixed one.

Checking is one request, and it is the only way to know:

curl -sI -H 'Accept-Encoding: gzip, br, zstd' https://example.com/admin/login \
  | grep -i content-encoding

No output is the correct result.

Debug mode and asset caching

With debug=False, CSS and JavaScript are read from disk once and cached per process. That is what you want in production, and it means an edit to a stylesheet needs a restart.

With debug=True they are re-read on every request — which is why the setting exists, and why it must not be on in production.

Docker

FROM python:3.13-slim

# uv, then dependencies, then source — so a source change does not re-resolve.
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv

WORKDIR /app
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev

COPY . .

# No `npm install`, no node_modules, no build step. The admin's CSS and
# JavaScript are files inside the wheel.
ENV FASTFORT_DEBUG=false
EXPOSE 8000
CMD ["uv", "run", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

Migrations

FastFort never runs DDL. Your schema is yours — Alembic, or whatever you already use. The models are ordinary declarative models with no FastFort base class in the MRO, which is exactly what makes that possible.

Database extensions are the same: postgis, hstore, citext and vector each add a type, so a CREATE TABLE naming one fails on the DDL. Enabling them is your project’s job — an admin framework that quietly installs database extensions is one nobody can review.

The first account

FF_PASSWORD="$(openssl rand -base64 24)" \
uv run fastfort createsuperuser \
    --app app.main:fort \
    --identity ops@example.com \
    --password-env FF_PASSWORD \
    --no-input

--password-env, not --password: an argument is visible in the process list.

Sizing

SettingWhy
admin.count_strategy = "capped"An exact count dominates query time past a few hundred thousand rows
admin.max_page_sizeA ceiling on ?ps=; without it one request can ask for the whole table
admin.export_limitAn export has no pagination
admin.autocomplete_limitPer keystroke, per picker
admin.dashboard_daysEach day is one indexed count, so this is also the dashboard’s query count

What is not in the box

Rate limiting, an audit-log backend and soft delete are planned for fastfort.contrib and are not there yet. Rate limiting in particular is something to put at your proxy or gateway today — lockout_* protects the login form, and nothing else.