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.
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:
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.
granian is an optional extra, not a hard dependency:
Why prefer granian
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: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.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:
How workers get their sidecars
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.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. Caddy handles this well out of the box, issuing and renewing certificates automatically, and it setsX-Forwarded-Proto and X-Forwarded-For for you:
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 ontrust_proxy: true in fymo.yml once you have a reverse proxy you trust in front of the app:
How this connects to HSTS
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.Security headers
In production, Fymo adds four security headers to every response:X-Content-Type-OptionsX-Frame-OptionsReferrer-PolicyPermissions-Policy
dev=True or FYMO_DEV=1), so there’s no CSP noise on localhost and no HSTS caching to trip over later.
Content Security Policy details
Content Security Policy details
Fymo ships a report-only CSP with a same-origin baseline, unless you override it: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.analyticsIDhotjarcustom
<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:-
Set your own header explicitly through
security.headers.extrainfymo.yml. This always wins over the default: - 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.
- 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.
HSTS details
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.Secrets
Fymo signs auth cookies and session state with an HMAC key. In production, the app refuses to boot unlessFYMO_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:
- 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_DEVunset in production. It turns on dev-only behavior, like verbose tracebacks and cookies without theSecureflag, 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:
${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.
Interpolation rules in detail
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.
.env for local development
.env for local development
In dev mode, Fymo loads a One
.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: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.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.
How the list is built
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.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:
- gunicorn (
syncworker 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 2to4on 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 1to2and 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:
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.
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.
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:fymo.yml:
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.
File output is append-only with no built-in rotation. Use logrotate or your container platform’s log driver to manage it.
Background job logging
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.Storage and exposure
Where a file lives follows one rule. Something that ships with your code, a logo, a font, a favicon, belongs inapp/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:
expose: is entirely optional, an app without one registers no extra routes at all.
How matching and range requests work
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
mimetypesmodule.
expose entries with different prefixes, directories, and extensions.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.app/routes.py still sits alongside this.