---
title: "Deploy to Fly.io | FoxAuth"
description: "The shipped fly.toml, why the release command provisions the database, and which values are secrets."
canonical: "https://foxauth.dev/docs/deploy/fly/"
lastmod: "2026-10-12T20:43:16+03:00"
---
Deploy to Fly.io

The repository ships a working fly.toml. This page explains what is in it and why, so you can
adopt it as is or adapt it with your eyes open.

Create the app and pick a region:

Terminal window

fly launch --no-deploy

Keep the shipped fly.toml when it asks. The sections below depend on what is in it.

Set the database connection string as a secret:

Terminal window

fly secrets set MONGODB_URI='mongodb+srv://user:password@cluster.example.mongodb.net'

See MongoDB Atlas for the details of the string, including why the
database name does not belong in it.

For PostgreSQL, set POSTGRES_URL instead and skip DATABASE_NAME in the next step, since the
database name is part of that URL. Set one connection string, never both; see
PostgreSQL.

Set the two non-secret variables in fly.toml:

[env]

ISSUER = 'https://auth.example.com'

DATABASE_NAME = 'OAuth'

ISSUER must be the public URL browsers reach the app at, and it must be right the first time:
the release command bakes it into the seeded console client’s redirect URI once. Neither value is
a credential, so neither needs to be a secret.

Deploy:

Terminal window

fly deploy --remote-only

The release commandSection titled “The release command”

[deploy]

release_command = "sh -c 'bun run db:setup && bun run db:migrate'"

This is the most important line in the file. Fly runs it on a temporary Machine with the app’s
environment, once per deploy attempt, before any new Machine serves traffic, and aborts the
deploy on a non-zero exit.

It has to run before the app serves because the seeds must exist for the deployment to be usable
at all: without the reserved admin project, bucket and client, GET /admin/login redirects to
/auth with a client_id no Client document matches, and the admin plane is unreachable. The
first-run setup page at GET /admin still works in that state, so the symptom looks like a login
bug when the real problem is a missing seed. The same run provisions the TTL and unique-e-mail
indexes that the collections would otherwise be created without.

It also aborts the deploy usefully: db:setup exits non-zero when a constraint it declares is not
in force, so a database whose data violates a new unique index fails the deploy instead of silently
serving without it.

The release command needs the connection string, DATABASE_NAME on MongoDB, and ISSUER. Yes,
ISSUER too: it is read for the admin client’s redirect_uri. On PostgreSQL the setup step is
db:setup:pg.

db:migrate follows in the same command because the server refuses to start against a database that
is behind the migrations its release declares. Left out, a release carrying its first migration
deploys an image that cannot boot; run from here, the same release is gated on the migration
succeeding. Most releases declare none and the step reports current and exits, but 0.7.0, 0.8.0,
0.10.0 and 0.11.0 each carried migrations that cannot be undone, so read the release notes before a deploy and take a
backup when they name one. The sh -c wrapper is what makes && a shell operator instead of two more arguments to bun.

The health checksSection titled “The health checks”

[[http_service.checks]]

method = 'GET'

path = '/health'

interval = '30s'

timeout = '5s'

grace_period = '15s'

headers = { X-Forwarded-Proto = 'https' }

[[http_service.checks]]

method = 'GET'

path = '/ready'

interval = '60s'

timeout = '10s'

grace_period = '20s'

headers = { X-Forwarded-Proto = 'https' }

Boot does a database connection and a keystore load at module scope, so a Machine that cannot reach
the datastore exits instead of serving. Without a check the proxy cannot tell that apart from a
healthy Machine and routes to it anyway.

Two details are there on purpose. The X-Forwarded-Proto = 'https' header is a documented safeguard
against force_https turning the check into a 301 that the proxy reads as a failure. And the
15-second grace period covers a cold start, since auto_start_machines wakes Machines from stopped.

GET /health is the only route the rate limiter never counts — a refused liveness probe would take
the Machine out of the load balancer. It answers for the process alone, which is why the second
check exists. GET /ready reports whether the datastore is reachable, and Fly’s checks stop routing
to a failing Machine without restarting it, so one whose database has gone away is shed instead of
cycled. Its interval is the longer of the two on purpose. The endpoint holds its last answer for a
second and shares one outstanding probe, so it cannot become database load, and there is little to
gain from asking twice a minute about something a deploy cannot fix.

rateLimit.trustedProxy stays onSection titled “rateLimit.trustedProxy stays on”

Fly’s proxy sits in front of the app, so every request arrives from it. Leave
rateLimit.trustedProxy at its default of
true: with it off, every caller is counted as the proxy’s own address, the whole internet shares
one allowance, and all traffic is refused within seconds. The origin resolver reads Fly-Client-IP
first, which is exactly the header Fly sets. Behind a reverse proxy
covers the other direction, where the same default is wrong.

A release deploys itself, and only a release reaches production

A deploy runs db:setup against a real datastore, so merging never triggers one. Pushing a version
tag does: .github/workflows/release.yml publishes and signs the image, then calls
.github/workflows/deploy.yml for the conformance instance and, once that succeeds, for production.
Each deploy resolves the tag to a digest, verifies the signature against the release run that made
it, and hands Fly that digest with flyctl deploy --image, so production runs exactly the attested
bytes and nothing is built or tested a second time. The only manual path is the conformance
instance, built from source with --remote-only once CI has passed for the commit. Every deploy then
probes both health endpoints and compares the advertised issuer with the one the config asks for.
That last comparison is the part worth keeping. In our experience the issuer is the value a deploy is
most likely to get wrong with nothing else catching it, and reading the expected value out of the
config is what stops the check from merely agreeing with itself.

The rest of the fileSection titled “The rest of the file”

internal_port = 3000 matches the EXPOSE 3000 in the Dockerfile and the port bun start
listens on.

force_https = true, so ISSUER is an https URL and HSTS (which the server emits
unconditionally) is not a lie.

auto_stop_machines = 'off' with auto_start_machines = true: the proxy never stops a Machine for
want of traffic, so a request after a quiet night is served by a running process instead of waiting
for a boot. min_machines_running is left out on purpose, since it only means anything while
something is allowed to stop Machines. Autostart stays on purely as a backstop: a Machine stopped
by a host event comes back on the next request. A deployment nobody depends on yet will probably
want the opposite, and that is what the second configuration below is.

strategy = 'bluegreen': with a single Machine a rolling deploy replaces it in place, so every
deploy costs a boot’s worth of downtime. Blue-green boots the replacement alongside it and moves
traffic once the new Machine’s checks pass. It is available only because nothing here has a volume
attached; blue-green and canary both refuse one.

One shared-CPU VM with 1 GB of memory. Note that rate-limit counting is per instance and held in
memory, so scaling to N Machines multiplies the effective allowance by N — see the caveat in
Kubernetes, which applies identically here.

The second configurationSection titled “The second configuration”

The repository also ships fly.conformance.toml, which describes a separate Fly app: the instance
the OpenID conformance suite is pointed at. It is the same file with two answers reversed:
auto_stop_machines = 'stop' and min_machines_running = 0, so it costs close to nothing between
runs, and no blue-green strategy, because a test bed can afford a boot’s downtime.

Deploy it with fly deploy --remote-only -c fly.conformance.toml --ha=false. The last flag is worth
knowing about: on the first deploy of a new app flyctl creates a second Machine for high
availability whatever the config says, and then advises setting min_machines_running = 0 — which
this file already does, because the two settings are unrelated. Left as it lands, a test bed runs two
Machines whose in-memory rate-limit counters are independent, so the effective allowance is double
the configured one.

Two of its choices are probably worth borrowing for any second environment. It sets a different
DATABASE_NAME, so a run cannot write into production data and its console can be configured however
a test plan requires. Server settings are stored per database, so sharing a database would mean
sharing them. And it stops instead of suspending, though suspending resumes faster: boot connects
to the datastore and loads the keystore at module scope, so a resumed process holds a connection pool
it established before it was frozen, and the failure looks like a server defect instead of a cold
start.

PreviousProtect your MCP server with OAuthNextDeploy with Docker Compose
