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
| Setting | Why |
|---|---|
admin.count_strategy = "capped" | An exact count dominates query time past a few hundred thousand rows |
admin.max_page_size | A ceiling on ?ps=; without it one request can ask for the whole table |
admin.export_limit | An export has no pagination |
admin.autocomplete_limit | Per keystroke, per picker |
admin.dashboard_days | Each 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.