fymo.yml lives at your project root and loads once, at startup. The class that handles it, ConfigManager, lives in fymo/core/config.py.
The file itself is optional. Every section below has a sensible default, so a project with no fymo.yml at all still boots. It just runs with auth off and jobs on the in-process threaded provider.
Each section loads through its own getter, like get_auth_config() or get_jobs_config(). An explicitly null section, auth: with nothing underneath, behaves the same as a missing one. Both resolve to {}.
Env var interpolation
${VAR} and ${VAR:-default} placeholders get resolved directly in the raw YAML text. This happens before yaml.safe_load ever parses it, and it happens once for the whole file, not per section. So a placeholder works the same whether it sits at the top level or three levels deep inside auth.providers.
${VAR}is required. IfVARisn’t set, config loading raisesConfigurationErrornaming the variable. It never silently falls back to loading the literal string${VAR}.${VAR:-default}falls back to the given default whenVARis unset. That fallback only evaluates when needed, so in${A:-${B}}, B is never touched if A is already set.- An empty default (
${VAR:-}) resolves to an empty string, not an error.
Literal braces and malformed placeholders
Literal braces and malformed placeholders
A default can contain literal braces.
${VAR:-{"a":1}} resolves to {"a":1}, because brace matching counts nesting depth instead of stopping at the first closing brace.An unterminated ${ raises ConfigurationError at load time. So does a malformed variable name like ${123bad}. Either way, the error names the problem.Every resolved value gets spliced back into the YAML text as an explicit double-quoted scalar, never as a raw substring. So an env var can’t restructure your config, even if its value contains a newline followed by something that looks like a new YAML key, like
admin: true. It always comes back out as the literal string it was. This is what makes interpolation safe to use with values you don’t fully control.Typed values from strings
Every interpolated value is a plain YAML string.${VAR} never becomes a real bool or int just because the variable holds text like “true” or “60”. That matters most for booleans: a bare bool("false") in Python evaluates to True, since any non-empty string is truthy. A config value like auth.enabled: ${AUTH_ENABLED} resolving to the string “false” would otherwise silently turn auth on.
Fymo prevents this with parse_bool(), used for every boolean key in fymo.yml:
auth.enabledremote.explicit_optinsecurity.headers.enabledlimits.rate_limit.enabledlimits.rate_limit.trust_proxy
Numeric keys like
limits.max_body_bytes and limits.rate_limit.requests_per_minute don’t need this treatment. Plain int(...) already does the right thing here, since int("60") behaves exactly as expected.
Top-level keys
Your own code can read version and description back through
ConfigManager.get(...) if it needs them. Nothing in the framework itself enforces or uses those two keys.
routes
limits
The rate limiter runs as a per-process, per-client-IP token bucket, one bucket per matched rule. There’s no sharing across worker processes.
rate_limit.trust_proxy controls whether the first hop of X-Forwarded-For is trusted as the client IP. Only enable it behind a reverse proxy that actually overwrites that header, since otherwise a client could spoof its own rate-limit bucket. The same flag also decides whether X-Forwarded-Proto is honored when resolving the request’s scheme, which feeds both the HSTS header below and the session cookie’s Secure flag.
security
With
headers.enabled on, every response gets four defaults automatically:
X-Content-Type-OptionsX-Frame-OptionsReferrer-PolicyPermissions-Policy
dev=False), two more apply on top. Both are skipped entirely in dev:
Content-Security-Policy-Report-Only, with adefault-src 'self'baseline. Added only ifheaders.extradoesn’t already set a CSP of either kind.Strict-Transport-Security, added when the resolved request scheme is https. That resolution honorsX-Forwarded-Protoonly iflimits.rate_limit.trust_proxyis on.
auth
A provider entry can be a bare built-in name:
passwordgoogleoidcclerk
type key for a built-in provider, or a class key for a dotted path to your own. Either way, it can carry whatever extra options that provider’s constructor takes.
Add required: auto to an entry to skip it entirely when the provider isn’t configured, instead of crashing on a missing secret.
Authentication
Full provider config, the session cookie format, and how to write a custom UserStore or AuthProvider.
jobs
threaded runs jobs in-process, with no external dependency. A job is lost if the process restarts mid-run.
procrastinate is a durable, Postgres-backed queue instead, meant to be picked up by a separate fymo jobs-worker process. Using it needs the procrastinate extra installed, plus a database, read from DATABASE_URL by default.
broadcasts
Same string-or-object shape as
jobs.provider. postgres is currently the only built-in.
remote
With
explicit_optin off (the default), every public, type-annotated top-level function in app/remote/*.py is exposed to the browser. Turning it on requires @remote on each function you want reachable. Everything else in that file stays server-only.
storage
Unlike jobs, broadcasts, and auth, there is no fallback provider here. Leaving
storage: out of fymo.yml fails at startup if media: is configured. Silently writing to local disk is exactly the kind of default that works fine in dev, but quietly loses data in production.
Storage and Media
The StorageProvider interface, byte-range reads, and writing a custom provider.
media
media: is a list, defaults to []. Each entry becomes a byte-range-aware GET route, useful for video and audio scrubbing, with no hand-written WSGI handler needed.
A prefix under the reserved /dist/ or /assets/ paths doesn’t fail the build, it only prints a warning. Those routes are matched first, so a prefix there would never actually get reached.
logging
An unrecognized value in any of these raises
ValueError naming the key, failing fast at startup instead of silently logging somewhere unexpected.
Logging
What flows through fymo’s single root-logger handler, and the shape of request and job log lines.
The identity secret
FYMO_SECRET isn’t a fymo.yml key, it’s read straight from the environment. But it governs enough of the config above, like signed session cookies and OAuth’s PKCE state, that it deserves a mention here.
Resolution order at startup:
FYMO_SECRETenv var, used as raw UTF-8 bytes. Must be at least 16 characters, or startup raises..fymo/secret.keyon disk, if present and at least 16 bytes.- Dev mode only (
dev=TrueorFYMO_DEV=1). 32 random bytes get generated and written to.fymo/secret.key(mode0600) for reuse across restarts. - Production with neither set: startup raises. A forgeable session cookie is a worse failure mode than refusing to boot.
Why the dev flags skip parse_bool
Why the dev flags skip parse_bool
FYMO_DEV and FYMO_SECRET are read with a simple truthy check, not with parse_bool: “1”, “true”, “yes”, or “on”, case-insensitive.That’s deliberate. Defaulting an unrecognized value to False here is a reasonable fallback for an env var read directly. The stricter parse_bool governs actual fymo.yml booleans instead, where a typo should raise rather than get guessed at.Sections that aren’t read
Older or scaffolded projects sometimes carry aserver: block (host, port, reload) and a build: block (output_dir, minify) in their fymo.yml. The framework doesn’t read either one. Host and port instead come from CLI flags:
