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

# Production Deployment

> Running a Fymo app in production, from choosing a server to security headers, secrets, and health checks.

Moving a Fymo app from `fymo dev` to production changes a handful of things. This page walks through each one: choosing a server, the process model, what sits in front of it, secrets, worker sizing, health checks, and logging. Read the essentials in each section first, the details are there when you need them.

<Warning>
  The `Dockerfile` at the fymo framework repo root is a template, not something you build directly. Copy it, along with `.dockerignore`, into your Fymo project directory. That's the one containing:

  * `server.py`
  * `fymo.yml`
  * `app/`
  * `requirements.txt`
  * `package.json`

  Then run `docker build` from there. The build context is always the current directory, so building from the framework repo instead of your project won't produce a working image.
</Warning>

## Choosing a server

`fymo serve --prod` runs your app under one of two supported servers. Pick explicitly with `--server`, or let Fymo choose for you:

```sh theme={null}
fymo serve --prod                     # auto: granian if installed, else gunicorn
fymo serve --prod --server granian    # explicit granian, errors if not installed
fymo serve --prod --server gunicorn   # explicit gunicorn
```

`auto`, the default, prefers granian when it's importable and falls back to gunicorn when it isn't. The choice is never silent, one log line at startup says which server was picked and why.

<Tip>
  Asking for `--server granian` without granian installed is a hard error naming the fix, `pip install 'fymo[granian]'`, never a silent fallback to gunicorn.
</Tip>

granian is an optional extra, not a hard dependency:

```sh theme={null}
pip install 'fymo[granian]'
```

<Accordion title="Why prefer granian">
  gunicorn's `sync` worker class handles exactly one request at a time per process, which makes the server layer, not Fymo, the bottleneck. Measured layer by layer on the same machine with `ab -n 2000 -c 50`, interleaved runs:

  | Workload                           | gunicorn (sync)     | granian (WSGI) |
  | ---------------------------------- | ------------------- | -------------- |
  | Bare do-nothing WSGI app, 1 worker | 2,961 req/s         | 27,022 req/s   |
  | Fymo full SSR                      | \~1,400–2,259 req/s | \~4,300 req/s  |

  Fymo's own per-request time is 0.3–0.5 ms. On gunicorn's sync workers, the server layer throws most of that away. An independent re-run on different hardware reproduced the shape: per worker, granian carried roughly 2.3x gunicorn's SSR throughput on the same app, with zero failed requests on either server.

  `/healthz`, JSON access logging, and clean sidecar shutdown behave identically under both servers, only raw throughput differs.
</Accordion>

gunicorn is still the right pick when you want the battle-tested option, already have gunicorn-specific tooling around your deploy, or can't take on a compiled-wheel dependency.

## Process model

`fymo serve --prod --workers N` runs N worker processes under whichever server you picked. Under both servers, each worker is a full Python process that spawns its own Node child to handle SSR. Nothing is shared between workers, so a running instance looks like this:

```
server master (gunicorn arbiter / granian main)
├── worker 1 (python) --spawns--> node dist/sidecar.mjs (SSR child)
├── worker 2 (python) --spawns--> node dist/sidecar.mjs (SSR child)
└── worker N (python) --spawns--> node dist/sidecar.mjs (SSR child)
```

<Accordion title="How workers get their sidecars">
  How workers come to own their sidecars differs by server. gunicorn forks workers from a master that already imported the app, so Fymo rebuilds a worker-owned app after each fork. granian workers each import `server.py` themselves and never share anything with the parent. The resulting process tree, and everything below it, is the same either way.
</Accordion>

That's why your runtime container needs both Python and Node. A Python-only image will boot fine and even pass a basic smoke check. Every real page render will fail once traffic arrives, though, since there's no sidecar around to render with.

## Reverse proxy and TLS

Put a reverse proxy in front of your production server, granian or gunicorn, and let it terminate TLS. Don't terminate TLS in the app server itself.

<Tip>
  Under gunicorn, Fymo configures the `sync` worker class, which handles exactly one request at a time per worker, another reason a proxy belongs in front: it handles slow client connections and TLS negotiation so your Python workers don't have to.
</Tip>

Caddy handles this well out of the box, issuing and renewing certificates automatically, and it sets `X-Forwarded-Proto` and `X-Forwarded-For` for you:

```
example.com {
    reverse_proxy 127.0.0.1:8000
}
```

nginx needs those same headers set by hand:

```nginx theme={null}
server {
    listen 443 ssl;
    server_name example.com;

    location / {
        proxy_pass http://127.0.0.1:8000;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}
```

## Trusting your proxy

Fymo's rate limiter tells clients apart by IP address. Behind a reverse proxy, though, the address it sees by default is the proxy's, not the visitor's. Turn on `trust_proxy: true` in `fymo.yml` once you have a reverse proxy you trust in front of the app:

```yaml theme={null}
limits:
  rate_limit:
    enabled: true
    requests_per_minute: 60
    trust_proxy: true
```

<Warning>
  Only enable `trust_proxy` once a trusted reverse proxy sits in front of your app and rewrites (not appends to) `X-Forwarded-For`. Turn it on without one, and any visitor can forge that header and dodge rate limiting entirely. Leave it `false`, the default, if your app is ever reachable directly.
</Warning>

<Tip>
  Rate limiting is on by default in production and off in dev, so `fymo dev` never throttles your local work. Set `limits.rate_limit.enabled` explicitly if you want different behavior in either environment.
</Tip>

<Accordion title="How this connects to HSTS">
  The `trust_proxy` flag also decides whether HSTS (covered below) trusts `X-Forwarded-Proto`. It's one trust boundary to manage, not two. Flip it on, and both the rate limiter's client detection and HSTS's scheme detection start honoring the proxy's headers together.
</Accordion>

## Security headers

In production, Fymo adds four security headers to every response:

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

Two more, a content security policy and HSTS, are added conditionally and are covered below. None of this applies in dev (`dev=True` or `FYMO_DEV=1`), so there's no CSP noise on localhost and no HSTS caching to trip over later.

<Accordion title="Content Security Policy details">
  Fymo ships a report-only CSP with a same-origin baseline, unless you override it:

  ```
  default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline';
  img-src 'self' data: https:; font-src 'self' data:; connect-src 'self';
  object-src 'none'; base-uri 'self'; frame-ancestors 'none'
  ```

  It ships report-only rather than enforcing on purpose. Fymo's own SSR page only needs `script-src 'self'` on its own: the hydration script is same-origin, and JSON prop data uses `application/json`, which browsers never execute regardless of policy.

  But three `fymo.yml` options break that assumption when you turn them on:

  * `head.script.analyticsID`
  * `hotjar`
  * `custom`

  Each of these injects inline `<script>` blocks or third-party hosts, like Google Tag Manager and Hotjar, when configured. An enforcing `script-src 'self'` would silently break those the moment you enabled one, which is exactly the failure this default is meant to avoid. Report-only ships the header from day one, so violations show up in the console without breaking anything.

  To move to an enforcing policy:

  1. Set your own header explicitly through `security.headers.extra` in `fymo.yml`. This always wins over the default:

     ```yaml theme={null}
     security:
       headers:
         enabled: true
         extra:
           - ["Content-Security-Policy", "default-src 'self'; script-src 'self' 'nonce-<per-request-nonce>'"]
     ```

  2. If you use inline analytics or custom scripts, either allow their hosts in your script policy, or switch to nonces. Generate one per request, add it to your policy, and stamp it onto each inline script tag. Fymo doesn't generate nonces for you today, so a true per-request nonce needs a small wrapper around your own middleware.

  3. Test in report-only first. Watch the console, or a configured report URI, before switching to enforcing. A policy that's too strict fails closed and blocks the resource rather than failing open.
</Accordion>

<Accordion title="HSTS details">
  Fymo adds `Strict-Transport-Security` with a one-year max age whenever the request's resolved scheme is https. Behind a TLS-terminating proxy, the app itself only ever sees plain http on the wire. It relies on the `X-Forwarded-Proto` header instead, and only trusts it when `trust_proxy: true` is set.

  Without `trust_proxy`, only a direct https connection turns HSTS on, and a visitor spoofing the forwarded header over plain http can't force it.
</Accordion>

## Secrets

Fymo signs auth cookies and session state with an HMAC key. In production, the app refuses to boot unless `FYMO_SECRET` is set to at least 16 characters. That's deliberate: a loud failure at startup beats a cookie that quietly becomes forgeable.

Generate one with:

```sh theme={null}
python -c 'import secrets; print(secrets.token_urlsafe(32))'
```

A few rules follow naturally from what this key protects:

* Never commit it. It shouldn't appear in your `Dockerfile`, `fymo.yml`, version control, or CI logs.
* Inject it as an environment variable at deploy time, either directly with `docker run -e FYMO_SECRET="$FYMO_SECRET" ...`, or through a secret manager that resolves it at startup.
* Use the same value across every worker and replica in one deployment. That's what lets a session signed by one process validate on another. Rotating it logs everyone out, since it invalidates all existing sessions.
* Leave `FYMO_DEV` unset in production. It turns on dev-only behavior, like verbose tracebacks and cookies without the `Secure` flag, that should never run live.

## Config variables

`fymo.yml` can read environment variables directly, so a deployment-specific value like a database DSN doesn't force you to write a custom Python class just to read `os.environ`:

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

`${VAR}` resolves to the variable's value, and fails loudly at config load if it's unset. `${VAR:-default}` falls back to a default instead.

<Accordion title="Interpolation rules in detail">
  * `${VAR}` resolves to the environment variable's value. If it's unset, config loading fails immediately, naming the variable, rather than silently loading a config with the literal string `${VAR}` in it.
  * `${VAR:-default}` falls back to that default value when the variable is unset. The default can be empty, or reference another placeholder such as `${A:-${B}}`, resolved the same way and only when the first one is actually unset.
  * The resolved value always gets spliced back in as a quoted YAML string. It can never restructure the config, no matter what characters it contains, including a literal newline. A value from a less-trusted source can supply a string, never new YAML structure.
  * Interpolation runs on the raw YAML text before parsing, so it works anywhere in the file, even inside comments. A substitution written in a `#` comment still gets resolved, since the substitution pass doesn't know about comments.
</Accordion>

<Accordion title=".env for local development">
  In dev mode, Fymo loads a `.env` file from the project root into the process environment before `fymo.yml` is parsed, so `${VAR}` placeholders and any code reading `os.environ` can see it:

  ```
  CLERK_ISSUER=https://example.clerk.accounts.dev
  DATABASE_URL=postgres://localhost/myapp_dev
  ```

  One `KEY=value` per line. Blank lines and lines starting with `#` are ignored, and a value wrapped in matching quotes has them stripped. A real environment variable already set in the shell always wins, `.env` never overwrites it.

  This file is never read in production. `.env` only loads when `dev=True`, so a production process ignores it even if one exists on disk. Add `.env` to `.gitignore` yourself, Fymo doesn't do that for you.
</Accordion>

## Provider-owned tables

Some Fymo providers create real, permanent objects in your app's database. `jobs: {provider: procrastinate}` puts its queue tables, functions, and types, `procrastinate_jobs`, `procrastinate_events`, and others, in the same schema as your own tables.

Your declarative schema file only declares what your app owns, so a schema diff tool like pgschema or migra sees the provider's objects as strays and generates `DROP TABLE`, `DROP FUNCTION`, and `DROP TYPE` for every one of them. Applying that plan destroys the live job queue.

<Warning>
  Before running any schema diff against a database Fymo providers share, enumerate what they own. Applying a diff plan without excluding provider objects will drop your job queue.
</Warning>

```sh theme={null}
fymo schema provider-tables          # one "kind name" per line
fymo schema provider-tables --json   # [{kind, name, provider}, ...] for tooling
```

<Accordion title="How the list is built">
  The command reads `fymo.yml`, resolves the configured `jobs:` and `broadcasts:` providers, and prints every table, type, function, sequence, index, trigger, and extension they create. The list is derived from the installed provider library itself, so it matches the version you actually run, and no database connection is made.

  Feed the names into your tool's exclude or ignore list, or generate the list in CI, instead of hand-maintaining one.
</Accordion>

Providers that create nothing print nothing. The default `threaded` job provider and the `postgres` broadcast provider, pure LISTEN/NOTIFY, both own zero objects, and the command exits 0 with a note on stderr.

## Worker sizing

`--workers` means OS processes under both servers, and each worker costs one Python process plus one Node sidecar. Budget with both in mind:

```
total memory ≈ workers × (python_worker_rss + node_sidecar_rss)
```

<Tip>
  Measure both processes' memory under load before picking a worker count. Component tree size and SSR payload size both affect the Node side.
</Tip>

How far throughput scales with each added worker differs by server:

* **gunicorn** (`sync` worker class): one request at a time per worker, so concurrency comes only from process count. The usual rule of thumb is 2 to 4 workers per core, but each one carries a sidecar, so sizing purely on CPU count overcommits memory here. Start conservative, `--workers 2` to `4` on a single host, and scale with observed memory, not just CPU.
* **granian**: each worker dispatches requests from a pool of blocking threads, Fymo caps it at `min(2 × cores, 64)` per worker, so a single worker already handles concurrent requests. Fewer processes reach the same throughput. Start at `--workers 1` to `2` and add more only when a single worker's CPU, Python side or its Node sidecar, saturates.

`--workers` is a CLI flag, so you can tune it per environment without rebuilding the image:

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

## Health checks

`GET /healthz` is a liveness probe. It skips auth, rate limiting, and the body-size cap, and simply pings the current worker's Node sidecar:

* `200 {"status": "ok"}`: the sidecar responded, this worker is healthy.
* `503 {"status": "degraded"}`: the sidecar is unavailable, crashed, hung, or not yet started.

Point your load balancer or orchestrator's health check at this path, whether that's Docker's `HEALTHCHECK`, a Kubernetes probe, or an ALB target group check. Each worker owns its own sidecar, so a response only reflects the worker that happened to serve it. A load balancer polling repeatedly across workers will catch a degraded one within a few checks.

<Tip>
  `/healthz` is left out of access logging, so frequent polling doesn't drown out your real request logs.
</Tip>

## Logging

In production, Fymo writes one JSON object per line per request to stdout and stderr, no text formatting, no multi-line tracebacks mixed in:

```json theme={null}
{"method": "GET", "path": "/todos", "status": 200, "duration_ms": 4.21}
```

Only method, path, status, and duration get logged, never cookies, request bodies, or auth headers. Configure the destination and level through `fymo.yml`:

```yaml theme={null}
logging:
  destination: file      # terminal (default) | file
  file: log/fymo.log     # required when destination: file
  level: info             # debug | info | warning | error
  format: json            # text | json (default: text in dev, json in prod)
```

Fymo owns a single handler on Python's root logger, so your app's own `logging.getLogger(...)` calls and any library logs share the same destination and format. Attach an additional handler in `server.py` if you need a second sink, such as Sentry.

<Note>
  File output is append-only with no built-in rotation. Use logrotate or your container platform's log driver to manage it.
</Note>

<Tip>
  Prefer letting the process log to stdout and stderr rather than a file inside the container. Your container runtime's log driver, or a sidecar log shipper, can pick lines up and forward them to your log backend. Since each line is already valid JSON, most shippers parse it without a custom grok or regex rule.
</Tip>

<Accordion title="Background job logging">
  The same `logging` section drives both the web process and `fymo jobs-worker`, which emits one line per job: started, succeeded, or failed, with duration. Job arguments, cookies, request bodies, and auth headers are never logged by Fymo's own lines.

  The underlying `procrastinate` library normally echoes job arguments in its own log lines, so its logger is capped to `WARNING` by default and its permanent-failure line is filtered out. Fymo's own "job failed" line carries name, status, duration, and the traceback instead. Set the `procrastinate` logger to `INFO` explicitly only if you're fine with its lines including job arguments.
</Accordion>

## Storage and exposure

Where a file lives follows one rule. Something that ships with your code, a logo, a font, a favicon, belongs in `app/static/`. Something created at runtime, an upload, a recording, anything a job writes, belongs under `storage:`. Give a runtime file a URL by adding it to `storage.expose`.

Apps that need to serve binary files with range support, video or audio scrubbing in particular, don't need to hand-write a raw route for it. Declare the exposure in `fymo.yml` instead:

```yaml theme={null}
storage:
  provider: local
  root: ${DATA_DIR:-data}
  expose:
    - prefix: /media/videos/
      dir: videos
      extensions: [webm]
```

Fymo handles range requests, content types, and 404s for you. `expose:` is entirely optional, an app without one registers no extra routes at all.

<Accordion title="How matching and range requests work">
  `prefix` matches the request path the same way Fymo's own `/dist/` and `/static/` routes do, a plain prefix, not a template. `dir` resolves relative to `storage.root` (the project root when unset), so the entry above serves files a job wrote via `get_storage_provider().write("videos/...", data)`.

  `extensions` is the allow-list for the filename after the prefix. Anything else gets a 400, along with any filename containing `..` or starting with `/`.

  From there, Fymo handles the rest automatically:

  * A single-range request gets a 206 with `Content-Range`.
  * A full-file request gets a 200 with `Content-Length`.
  * A missing file gets a 404.
  * The content type is resolved from the filename via the standard library's `mimetypes` module.

  You can list multiple `expose` entries with different prefixes, directories, and extensions.
</Accordion>

<Warning>
  `storage.expose` needs `storage.provider` set. Exposed entries serve files through the configured storage provider, and there's no default, so an `expose` list with no provider configured fails at build and at boot.
</Warning>

<Note>
  An `expose` `dir` that doesn't exist yet under the storage root only warns at boot, naming the resolved path. A job may create it later.
</Note>

For anything that isn't just "serve a file from a directory", webhooks or non-file responses, the raw WSGI extension point at `app/routes.py` still sits alongside this.
