Settings
The whole configuration surface — every group, every field, its default, and why the default is what it is.
Available since v0.1.0
Settings are grouped rather than flat, so a name says which subsystem owns it and new options can be added without turning the top level into a hundred fields.
from fastfort import FastFortSettings
settings = FastFortSettings(
project_name="Fastfort Freight",
admin={"page_size": 25, "count_strategy": "capped"},
ui={"accent_hue": 232, "timezone": "Asia/Tashkent"},
security={"cookie_secure": True, "hsts_seconds": 31_536_000},
)
From the environment
Every value can come from the environment using the FASTFORT_ prefix and a
double underscore for nesting:
FASTFORT_SECRET_KEY=...
FASTFORT_DEBUG=false
FASTFORT_ADMIN__PAGE_SIZE=50
FASTFORT_ADMIN__COUNT_STRATEGY=capped
FASTFORT_SECURITY__COOKIE_SECURE=true
FASTFORT_UI__ACCENT_HUE=232
extra="forbid" on every group, so a misspelled key is a start-up error rather
than a setting that silently does nothing.
Top level
| Field | Type | Default | |
|---|---|---|---|
secret_key | SecretStr | required | Signs sessions, CSRF tokens and JWTs. Rotating it invalidates all three. |
project_name | str | "FastFort" | Shown in the header and the page titles. |
auth_url | str | "/auth" | Where the login and logout routes are mounted. |
debug | bool | False | Tracebacks and no template caching. Never in production. |
There is no default secret_key
A framework that ships one guarantees that some deployment will run with it, so the failure is moved to start-up where it is loud and cheap to fix. The validator refuses three things:
- A placeholder. Anything containing
change-me,your-secret,insecure,example,fastfortand six others — matched anywhere in the value, because padding a placeholder out to the minimum length is exactly what somebody in a hurry does. - Fewer than 32 characters.
- Fewer than 8 distinct characters. This catches
aaaa…and1234512345…, which pass a length check while carrying almost no entropy.
uv run fastfort generate-secret --export
# FASTFORT_SECRET_KEY=xK9v…
Printed and nothing else — never written to a file. A secret that a tool put on disk is a secret that ends up committed.
admin — where the admin lives, and how much it hands out
| Field | Default | |
|---|---|---|
url | "/admin" | Normalised to a leading slash; the site root is refused. |
page_size | 20 | |
page_size_choices | (20, 50, 100) | The active page_size is added if missing, so a project configuring 25 sees its own value. |
max_page_size | 200 | Hard ceiling on ?ps=. Without it, one request can ask for every row in the table. |
count_strategy | "exact" | exact · capped · estimated. |
count_cap | 10_000 | Where capped stops counting; the UI renders “10000+”. |
autocomplete_limit | 20 | Rows one autocomplete query may return. |
export_limit | 50_000 | Hard ceiling on one export. |
export_chunk_size | 1_000 | Rows per round trip while streaming. |
dashboard_days | 30 | Days in the signups chart. 0 switches it off. |
signup_field | "" | The user column recording when an account was created; detected when empty. |
Why export_limit exists
An export runs the current query with no pagination. Without a cap, a mis-clicked export everything on a table of ten million rows is a request that never finishes and a database that stops answering anyone else.
Choosing a count_strategy
exact counts every matching row, which on a large table dominates the query
time — the count often costs more than the page of rows it accompanies. capped
stops at count_cap; estimated asks the database for its own statistics. On
anything past a few hundred thousand rows, capped is the one you want.
ui — appearance
The whole palette derives from one hue in OKLCH. Rebranding is a number, not a rebuild: there is no build step and no stylesheet to fork.
| Field | Default | |
|---|---|---|
accent_hue | 255 | 0–360. |
accent_chroma | 0.16 | 0–0.37. |
theme | "system" | system · light · dark. |
density | "comfortable" | comfortable · compact. |
logo_url · favicon_url | None | |
richtext_url | None | A script that upgrades every richtext field. |
custom_css_url | None | Loaded after the built-in stylesheet. |
map_tile_url | "" | Empty means no map. |
map_attribution | "" | |
map_center | "0, 0" | A whole-world view, not somebody’s capital city. |
map_max_zoom | 19 | The deepest level the tile source has pictures for. |
language | None | None follows the browser. |
locale_dir | None | Your own <language>.json files, taking precedence. |
timezone | "UTC" | |
environment_label | None | Rendered in the header. |
environment_tone | "warning" | info · warning · danger. |
map_tile_url is off by default
Turning it on means the admin fetches images from somebody else’s server. That host learns which rows are being looked at and roughly where they are, and most tile services have terms about it. Naming a URL is your project saying it has read them.
The host is added to the admin’s CSP img-src, which is why it is configuration
and not something a template can inject.
map_max_zoom is a property of the source, not the widget
19 is where OpenStreetMap’s standard layer stops. Some commercial raster layers reach 22, and one of those configured at the default fetches three levels shallower than it could — a blurrier map for no reason.
ui={"map_tile_url": "https://tiles.example.com/{z}/{x}/{y}.png", "map_max_zoom": 22}
This is a ceiling on requests. The view still zooms two levels past it, scaling the last one it has — which is what every map application does past its own imagery, and better than a button that stops responding.
richtext_url bundles nothing
The editor is your choice — CKEditor, TinyMCE, Quill, whichever you already
licence. FastFort renders the textarea and marks it data-ff-richtext; this
names the script that finds them. None leaves a plain textarea, which is a
working control rather than a broken editor.
Bundling one was the alternative, and it is the wrong trade: half a megabyte of
editor in the wheel for every project, most of which want a different one or none
at all. If the URL is on another origin, that origin is added to script-src —
which is exactly why it is configuration.
environment_label
ui={"environment_label": "PRODUCTION", "environment_tone": "danger"}
So the difference between the two windows somebody has open is visible without reading.
security — cookies, CSRF and headers
The defaults are the strict ones. Relaxing any of them takes an explicit change that shows up in review.
| Field | Default | |
|---|---|---|
cookie_name | "fastfort_session" | |
cookie_secure | True | |
cookie_httponly | True | |
cookie_samesite | "lax" | none requires cookie_secure=True. |
cookie_domain · cookie_path | None · "/" | |
csrf_enabled | True | |
csrf_header_name | "X-CSRF-Token" | |
csrf_field_name | "_csrf" | |
security_headers | True | CSP, X-Frame-Options, X-Content-Type-Options, Referrer-Policy. |
hsts_seconds | 0 | |
trust_forwarded_for | False |
trust_forwarded_for
Only enable this behind a proxy that overwrites the header. Behind one that appends, or behind none at all, a client can forge its own address — and lockout and audit records are then attributable to whoever the attacker chose.
The CSP
Starts at default-src 'none' with script-src 'self' and no nonce. So: no
inline <script>, no inline event handlers, no CDN. Data reaches the browser
through data- attributes.
Exactly two directives can be widened by a project, each by exactly one origin:
img-src (by ui.map_tile_url) and script-src (by ui.richtext_url).
auth — tokens, passwords and lockout
| Field | Default | |
|---|---|---|
access_token_ttl | 900 | Seconds. Short on purpose. |
session_ttl | 1_209_600 | An admin browser session — a working day is not an API refresh window. |
refresh_token_ttl | 1_209_600 | |
rotate_refresh_tokens | True | |
revoke_family_on_reuse | True | |
algorithm | "HS256" | HS256/384/512 · RS256 · EdDSA. |
issuer · audience | None | |
password_min_length | 10 | |
password_reject_common | True | |
lockout_threshold | 5 | Failures before an identity or address is locked. |
lockout_seconds | 60 | The base delay; it grows with each further failure. |
lockout_window_seconds | 900 |
Each refresh issues a new token and retires the old one. Replaying a retired token means it was stolen, so the whole family is revoked.
media — uploaded files
| Field | Default | |
|---|---|---|
root | Path("media") | Created on first use. |
upload_limit | 10_000_000 | Bytes. |
Local disk, because that is what every project can rely on without provisioning anything first — object storage is your own integration to add, not a dependency here.
Uploaded files are served through the admin, behind the same gate as every other view, rather than through a separate unauthenticated static mount. An uploaded file is a record like any other row, not a public asset.
A request reads at most one byte past upload_limit before deciding the upload
is over, so an oversized file is a validation error on the field rather than
however many gigabytes it actually was, sitting fully in memory first.
Checking a deployment
uv run fastfort check --app main:fort --deploy
Returns strings rather than raising, so one run reports every problem:
Fastfort Freight · 17 model(s)
warn debug=True exposes tracebacks and internal state. Set FASTFORT_DEBUG=false.
warn security.cookie_secure=False sends the session cookie over plain HTTP.
Set FASTFORT_SECURITY__COOKIE_SECURE=true and serve over HTTPS.
warn auth.access_token_ttl is 7200s. A leaked access token stays valid that
long; 900s is the recommended ceiling.
3 problem(s) found.
Exits non-zero, so it can gate a deployment. settings.require_production_ready()
is the same check as an exception, for a project that wants start-up to refuse.