> ## Documentation Index
> Fetch the complete documentation index at: https://fymo.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Configuration Reference

> Every key fymo.yml supports, its type, its default, and how ${VAR} interpolation resolves it.

`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`.

```yaml theme={null}
name: ${APP_NAME}
description: ${APP_DESC:-a fymo app}

auth:
  providers:
    - type: oidc
      issuer: ${OIDC_ISSUER}
```

* `${VAR}` is required. If `VAR` isn't set, config loading raises `ConfigurationError` naming the variable. It never silently falls back to loading the literal string `${VAR}`.
* `${VAR:-default}` falls back to the given default when `VAR` is 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.

<Accordion title="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.
</Accordion>

<Note>
  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.
</Note>

## 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.enabled`
* `remote.explicit_optin`
* `security.headers.enabled`
* `limits.rate_limit.enabled`
* `limits.rate_limit.trust_proxy`

| Input                                                               | Result                                                     |
| ------------------------------------------------------------------- | ---------------------------------------------------------- |
| Python `True` / `False`                                             | passed through unchanged                                   |
| `"true"` / `"TRUE"` / `" True "` (case- and whitespace-insensitive) | `True`                                                     |
| `"false"` / `"FALSE"` / `" False "`                                 | `False`                                                    |
| anything else (`"yes"`, `"1"`, `""`, a non-string, non-bool)        | raises `ConfigurationError` naming the field and the value |

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

| Key           | Type   | Default                   |
| ------------- | ------ | ------------------------- |
| `name`        | string | `"Fymo App"`              |
| `version`     | string | none (informational only) |
| `description` | string | none (informational only) |

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

```yaml theme={null}
routes:
  root: index.index
  resources:
    - posts
    - name: admin
      soft_nav: false
  about: pages.about
```

| Key           | Meaning                                                                                                                                                                                                                                       |
| ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `root`        | `controller.action` for `/`.                                                                                                                                                                                                                  |
| `resources`   | A list of resource names, each expanded into index/show/new/edit/create/update/delete routes. An entry can be a bare string, or `{name, soft_nav}` to turn off SPA-style client navigation for that resource (`soft_nav` defaults to `true`). |
| any other key | Treated as an explicit route: a string value is `controller.action`, an object value is passed through as-is.                                                                                                                                 |

## limits

```yaml theme={null}
limits:
  rate_limit:
    enabled: true
    requests_per_minute: 60
    paths:
      "/_fymo/remote/": 30
    trust_proxy: false
  max_body_bytes: 10485760
```

| Key                              | Type                      | Default                                              |
| -------------------------------- | ------------------------- | ---------------------------------------------------- |
| `rate_limit.enabled`             | bool                      | `true` in production, `false` in dev                 |
| `rate_limit.requests_per_minute` | int                       | `60`                                                 |
| `rate_limit.paths`               | map of path prefix to int | `{}` (longest matching prefix wins over the default) |
| `rate_limit.trust_proxy`         | bool                      | `false`                                              |
| `max_body_bytes`                 | int                       | `10485760` (10 MB)                                   |

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

```yaml theme={null}
security:
  headers:
    enabled: true
    extra:
      - ["Content-Security-Policy", "default-src 'self'"]
```

| Key               | Type                          | Default |
| ----------------- | ----------------------------- | ------- |
| `headers.enabled` | bool                          | `true`  |
| `headers.extra`   | list of `[name, value]` pairs | `[]`    |

With `headers.enabled` on, every response gets four defaults automatically:

* `X-Content-Type-Options`
* `X-Frame-Options`
* `Referrer-Policy`
* `Permissions-Policy`

In production (`dev=False`), two more apply on top. Both are skipped entirely in dev:

* `Content-Security-Policy-Report-Only`, with a `default-src 'self'` baseline. Added only if `headers.extra` doesn't already set a CSP of either kind.
* `Strict-Transport-Security`, added when the resolved request scheme is https. That resolution honors `X-Forwarded-Proto` only if `limits.rate_limit.trust_proxy` is on.

A header already present in the response from your own handler is never overwritten by these defaults.

## auth

```yaml theme={null}
auth:
  enabled: true
  user_store: fymo.auth.store.SqliteUserStore
  email_sender: fymo.auth.email.LoggingEmailSender
  providers:
    - password
```

| Key            | Type                     | Default                              |
| -------------- | ------------------------ | ------------------------------------ |
| `enabled`      | bool                     | `false`                              |
| `user_store`   | dotted class path        | `fymo.auth.store.SqliteUserStore`    |
| `email_sender` | dotted class path        | `fymo.auth.email.LoggingEmailSender` |
| `providers`    | list of provider entries | `[password]`                         |

A provider entry can be a bare built-in name:

* `password`
* `google`
* `oidc`
* `clerk`

Or it can be an object: a `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.

<Card title="Authentication" icon="user-lock" href="/auth">
  Full provider config, the session cookie format, and how to write a custom UserStore or AuthProvider.
</Card>

## jobs

```yaml theme={null}
jobs:
  provider: threaded
```

```yaml theme={null}
jobs:
  provider:
    type: procrastinate
    env_var: DATABASE_URL
```

| Key        | Type                                    | Default    |
| ---------- | --------------------------------------- | ---------- |
| `provider` | bare string, or `{type/class, ...opts}` | `threaded` |

`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

```yaml theme={null}
broadcasts:
  provider: postgres
```

| Key        | Type                                    | Default    |
| ---------- | --------------------------------------- | ---------- |
| `provider` | bare string, or `{type/class, ...opts}` | `postgres` |

Same string-or-object shape as `jobs.provider`. `postgres` is currently the only built-in.

## remote

```yaml theme={null}
remote:
  explicit_optin: true
```

| Key              | Type | Default |
| ---------------- | ---- | ------- |
| `explicit_optin` | bool | `false` |

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

```yaml theme={null}
storage: local
```

```yaml theme={null}
storage:
  provider: local
  root: data/uploads
```

| Key                                                                | Type                 | Default                |
| ------------------------------------------------------------------ | -------------------- | ---------------------- |
| `provider` (bare string form), or `provider`/`class` (object form) | string / dotted path | none, required if used |

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.

<Card title="Storage and Media" icon="folder-open" href="/storage-and-media">
  The StorageProvider interface, byte-range reads, and writing a custom provider.
</Card>

## media

```yaml theme={null}
media:
  - prefix: /media/videos/
    dir: data/videos
    extensions: [webm]
```

| Key          | Meaning                                                                                    | Required                     |
| ------------ | ------------------------------------------------------------------------------------------ | ---------------------------- |
| `prefix`     | URL path prefix requests are matched under.                                                | yes                          |
| `dir`        | Storage-key namespace files are resolved from, through the configured `storage:` provider. | yes                          |
| `extensions` | Allowed extensions, lowercase, no leading dot. Anything else gets a `400`.                 | no, defaults to none allowed |

`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

```yaml theme={null}
logging:
  destination: file
  file: log/fymo.log
  level: info
  format: json
```

| Key           | Values                                | Default                       |
| ------------- | ------------------------------------- | ----------------------------- |
| `destination` | `terminal`, `file`                    | `terminal`                    |
| `file`        | path, required if `destination: file` | none                          |
| `level`       | `debug`, `info`, `warning`, `error`   | `info`                        |
| `format`      | `text`, `json`                        | `text` in dev, `json` in prod |

An unrecognized value in any of these raises `ValueError` naming the key, failing fast at startup instead of silently logging somewhere unexpected.

<Card title="Logging" icon="file-lines" href="/logging">
  What flows through fymo's single root-logger handler, and the shape of request and job log lines.
</Card>

## 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:

1. `FYMO_SECRET` env var, used as raw UTF-8 bytes. Must be at least 16 characters, or startup raises.
2. `.fymo/secret.key` on disk, if present and at least 16 bytes.
3. Dev mode only (`dev=True` or `FYMO_DEV=1`). 32 random bytes get generated and written to `.fymo/secret.key` (mode `0600`) for reuse across restarts.
4. Production with neither set: startup raises. A forgeable session cookie is a worse failure mode than refusing to boot.

<Accordion title="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.
</Accordion>

## Sections that aren't read

Older or scaffolded projects sometimes carry a `server:` 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:

```bash theme={null}
fymo dev --host 0.0.0.0 --port 8000
fymo serve --host 0.0.0.0 --port 8000 --prod --workers 4
```

If you see those sections in an older or generated fymo.yml, they're inert: safe to leave, safe to delete.

## Full example

```yaml theme={null}
name: acme-blog
version: 1.0.0
description: "Blog with auth, background email, and video uploads"

routes:
  root: index.index
  resources:
    - posts
    - tags

auth:
  enabled: true
  providers:
    - password
    - type: oidc
      id: okta
      authorize_endpoint: ${OKTA_AUTHORIZE_ENDPOINT}
      token_endpoint: ${OKTA_TOKEN_ENDPOINT}
      userinfo_endpoint: ${OKTA_USERINFO_ENDPOINT}
      client_id_env: OKTA_CLIENT_ID
      client_secret_env: OKTA_CLIENT_SECRET
      required: auto

remote:
  explicit_optin: true

jobs:
  provider: procrastinate

broadcasts:
  provider: postgres

storage:
  provider: local
  root: data/uploads

media:
  - prefix: /media/videos/
    dir: videos
    extensions: [webm, mp4]

limits:
  rate_limit:
    enabled: ${RATE_LIMIT_ENABLED:-true}
    requests_per_minute: 120
  max_body_bytes: 26214400

logging:
  destination: ${LOG_DESTINATION:-terminal}
  level: ${LOG_LEVEL:-info}
```
