Перейти к содержимому

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.

  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 depend on what is in it.

  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.

    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.

  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 = "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.

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

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 = '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 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.