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

FieldTypeDefault
secret_keySecretStrrequiredSigns sessions, CSRF tokens and JWTs. Rotating it invalidates all three.
project_namestr"FastFort"Shown in the header and the page titles.
auth_urlstr"/auth"Where the login and logout routes are mounted.
debugboolFalseTracebacks 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, fastfort and 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… and 1234512345…, 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

FieldDefault
url"/admin"Normalised to a leading slash; the site root is refused.
page_size20
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_size200Hard ceiling on ?ps=. Without it, one request can ask for every row in the table.
count_strategy"exact"exact · capped · estimated.
count_cap10_000Where capped stops counting; the UI renders “10000+”.
autocomplete_limit20Rows one autocomplete query may return.
export_limit50_000Hard ceiling on one export.
export_chunk_size1_000Rows per round trip while streaming.
dashboard_days30Days 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.

FieldDefault
accent_hue2550–360.
accent_chroma0.160–0.37.
theme"system"system · light · dark.
density"comfortable"comfortable · compact.
logo_url · favicon_urlNone
richtext_urlNoneA script that upgrades every richtext field.
custom_css_urlNoneLoaded 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_zoom19The deepest level the tile source has pictures for.
languageNoneNone follows the browser.
locale_dirNoneYour own <language>.json files, taking precedence.
timezone"UTC"
environment_labelNoneRendered 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.

FieldDefault
cookie_name"fastfort_session"
cookie_secureTrue
cookie_httponlyTrue
cookie_samesite"lax"none requires cookie_secure=True.
cookie_domain · cookie_pathNone · "/"
csrf_enabledTrue
csrf_header_name"X-CSRF-Token"
csrf_field_name"_csrf"
security_headersTrueCSP, X-Frame-Options, X-Content-Type-Options, Referrer-Policy.
hsts_seconds0
trust_forwarded_forFalse

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

FieldDefault
access_token_ttl900Seconds. Short on purpose.
session_ttl1_209_600An admin browser session — a working day is not an API refresh window.
refresh_token_ttl1_209_600
rotate_refresh_tokensTrue
revoke_family_on_reuseTrue
algorithm"HS256"HS256/384/512 · RS256 · EdDSA.
issuer · audienceNone
password_min_length10
password_reject_commonTrue
lockout_threshold5Failures before an identity or address is locked.
lockout_seconds60The base delay; it grows with each further failure.
lockout_window_seconds900

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

FieldDefault
rootPath("media")Created on first use.
upload_limit10_000_000Bytes.

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.