Skip to content

Deploy to Fly.io

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

  1. Create the app and pick a region:

    Terminal window
    fly launch --no-deploy

    Keep the shipped fly.toml when it asks — the sections below are load-bearing.

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

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

  4. Deploy:

    Terminal window
    fly deploy --remote-only
[deploy]
release_command = 'bun run db:setup'

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, not after, 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 — while the first-run setup page at GET /admin still works, which makes it look like a login bug rather than 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 MONGODB_URI, DATABASE_NAME and ISSUERISSUER included, because it is read for the admin client’s redirect_uri.

[[http_service.checks]]
method = 'GET'
path = '/health'
interval = '30s'
timeout = '5s'
grace_period = '15s'
headers = { X-Forwarded-Proto = 'https' }

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

Two details are deliberate. 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 one route the rate limiter never counts — a refused liveness probe would take the Machine out of the load balancer.

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.

  • 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 = 'stop', auto_start_machines = true, min_machines_running = 0: fine for a low-traffic deployment, and the health check’s grace period accounts for the cold start.
  • 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.