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;
}

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.