Skip to main content
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.
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.

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.
Asking for --server granian without granian installed is a hard error naming the fix, pip install 'fymo[granian]', never a silent fallback to gunicorn.
granian is an optional extra, not a hard dependency:
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.
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:
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.
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.
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.
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:
nginx needs those same headers set by hand:

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:
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.
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.
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-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.
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.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:
  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.
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 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:
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:
${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.
  • ${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.
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:
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.

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.
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.
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.
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:
Measure both processes’ memory under load before picking a worker count. Component tree size and SSR payload size both affect the Node side.
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:

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.
/healthz is left out of access logging, so frequent polling doesn’t drown out your real request logs.

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:
Only method, path, status, and duration get logged, never cookies, request bodies, or auth headers. Configure the destination and level through fymo.yml:
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.
File output is append-only with no built-in rotation. Use logrotate or your container platform’s log driver to manage it.
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.
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 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:
Fymo handles range requests, content types, and 404s for you. expose: is entirely optional, an app without one registers no extra routes at all.
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.
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.
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.
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.