Deploy to Fly.io
Runs the Docker image close to your users, with a real disk if you need one.
A single Go binary you want in several regions without running servers. The best fit for Grit’s single-binary mode.
Teams that want a click-through dashboard for everything: Fly is CLI-first and expects you to read a TOML file.
Quick setup
- 1
Launch without deploying
Let Fly detect the Dockerfile and write a fly.toml, but stop before it ships anything: the defaults need two changes first.
fly launch --no-deploy - 2
Point the health check at the API
Fly checks
/by default. Grit serves its health endpoint at/api/health, so without this the machine is marked unhealthy and cycled forever while the app is running perfectly.[http_service]internal_port = 8080force_https = trueauto_stop_machines = "suspend"auto_start_machines = truemin_machines_running = 1[[http_service.checks]]interval = "15s"timeout = "3s"grace_period = "10s"method = "GET"path = "/api/health" - 3
Attach Postgres and Redis
Attaching sets DATABASE_URL for you. Redis comes from Upstash through Fly and gives you REDIS_URL.
fly postgres create --name my-app-dbfly postgres attach my-app-dbfly redis create - 4
Set the secrets
Secrets are encrypted and injected at runtime. Setting them triggers a redeploy, so do it before the first one.
fly secrets set \JWT_SECRET="$(openssl rand -base64 32)" \APP_ENV=production \CORS_ORIGINS="https://your-domain.com" - 5
Deploy, then migrate
Grit does not auto-migrate on boot in production: a process that rewrites the schema every time it restarts is a bad idea when the platform can restart it for its own reasons. Run migrations as an explicit step.
fly deployfly ssh console -C "/app/migrate"
What catches people out
Machines suspend on idle by default. The first request after a quiet period pays the wake-up cost: set min_machines_running = 1 for anything user-facing.
A volume is attached to one machine in one region. Scale past one machine and each gets its own empty disk, so uploads must go to S3/R2 rather than local storage.
Fly Postgres is an unmanaged Postgres you own, not a managed service. Backups are your job: fly postgres will not do point-in-time recovery for you.
Deploying Sentex: Fly.io
The example application below is called sentex and its domains are on gritcms.com. Substitute your own project name and hostnames as you go — every other detail, including the service layout and the Compose translation, applies unchanged to any Grit project.
Fly.io is the most different of the four: there’s no single "project" that holds multiple services the way Railway/Render/Dokploy/Coolify have one. Every deployable thing is its own Fly app with its own fly.toml, and you orchestrate the monorepo yourself with flyctl flags rather than a platform-level "root directory" setting. Databases are provisioned separately too (Fly Postgres runs as your own VMs; Redis comes from Fly’s built-in Upstash integration).
4.1 Install flyctl and log in
curl -L https://fly.io/install.sh | shfly auth login
4.2 Create three fly.toml files (one per app)
Fly doesn’t read a monorepo config format — each app gets its own TOML file and you tell flyctl which Dockerfile and build context to use for it via flags. Create these at the repo root (keeping them there, rather than inside apps/*, keeps the build context flexible — see 4.4):
fly.api.toml
app = "sentex-api"primary_region = "jnb" # pick the region closest to your users[build][env]APP_ENV = "production"[deploy]release_command = "sh -c './migrate && ./seed'"[http_service]internal_port = 8080force_https = trueauto_stop_machines = falseauto_start_machines = truemin_machines_running = 1[[vm]]cpu_kind = "shared"cpus = 1memory_mb = 512
fly.admin.toml
app = "sentex-admin"primary_region = "jnb"[build][build.args]NEXT_PUBLIC_API_URL = "https://api.sentex.gritcms.com"NEXT_PUBLIC_WEB_URL = "https://sentex.gritcms.com"NEXT_PUBLIC_ADMIN_URL = "https://admin.sentex.gritcms.com"THEME = "atlas"SOCIAL_AUTH_ENABLED = "false"NEXT_PUBLIC_DEMO_LOGINS = "false"[http_service]internal_port = 3000force_https = trueauto_stop_machines = falseauto_start_machines = truemin_machines_running = 1[[vm]]cpu_kind = "shared"cpus = 1memory_mb = 512
fly.web.toml — same shape as fly.admin.toml, with:
app = "sentex-web"
and its build args matching the web service’s args from the Compose file (no NEXT_PUBLIC_WEB_URL — web doesn’t need its own URL as a build arg, same as in the Compose file):
[build][build.args]NEXT_PUBLIC_API_URL = "https://api.sentex.gritcms.com"NEXT_PUBLIC_ADMIN_URL = "https://admin.sentex.gritcms.com"THEME = "atlas"SOCIAL_AUTH_ENABLED = "false"
Notes:
release_commandonsentex-apiis Fly’s equivalent of Railway’s Pre-Deploy Command and Render’spreDeployCommand— it spins up a temporary Machine using the freshly built image, runs./migrate && ./seed, and only proceeds to deploy the real release if it exits 0. Samesh -cwrapping reasoning as the other platforms.[build.args]is how Fly forwards Docker build arguments — this maps directly to thebuild.argsblock foradmin/webindocker-compose.prod.yml. Build args aren’t available at runtime, so this only covers theNEXT_PUBLIC_*/THEME/SOCIAL_AUTH_ENABLEDvalues that Next.js needs baked into the bundle — exactly like the Compose file’s own comments describe.- Runtime secrets (Postgres/Redis URLs, R2 credentials,
APP_URL,CORS_ORIGINS) are not put infly.toml— they go in viafly secrets setin Part 4.5, the same way Fly handles all sensitive runtime config.
4.3 Provision Postgres and Redis
# Postgres — runs as Fly Machines you own, not a separate managed productfly postgres create --name sentex-postgres --region jnb# Attach it to the api app — this auto-creates a DATABASE_URL secret on sentex-apifly postgres attach sentex-postgres --app sentex-api# Redis — Fly's built-in Upstash-managed integrationfly redis create# When prompted: name it sentex-redis, pick the same region (jnb), and# choose whether to enable eviction based on your caching needs.
fly postgres attach sets a DATABASE_URL secret directly on sentex-api automatically. Since your app code expects discrete POSTGRES_HOST/POSTGRES_PORT/POSTGRES_USER/POSTGRES_PASSWORD/POSTGRES_DB variables rather than a single DSN, parse DATABASE_URL into those five values in application startup code, or set the five secrets explicitly from the credentials Fly prints when it creates the cluster (Part 4.5 shows the explicit-secrets approach, which needs no code change).
fly redis create prints a redis:// connection string — copy it for Part 4.5.
4.4 Deploy each app
Because admin and web need the repo root as build context (same reason as every other platform in this doc — pnpm workspace access) while api’s context is just apps/api, deploy each with an explicit working directory and --dockerfile/--config pair:
# api — context is apps/api, matching build.context: ./apps/api in Composefly deploy apps/api --config fly.api.toml --dockerfile apps/api/Dockerfile# admin — context is the repo root, matching build.context: . in Composefly deploy . --config fly.admin.toml --dockerfile apps/admin/Dockerfile# web — same reasoning as adminfly deploy . --config fly.web.toml --dockerfile apps/web/Dockerfile
The first argument to fly deploy is the build context sent to Docker; the --config flag tells flyctl which fly.toml (and therefore which app) you mean, and --dockerfile overrides the default <context>/Dockerfile lookup.
The first fly deploy apps/api ... call will prompt to create the sentex-api app if it doesn’t exist yet (since fly.toml names it but you haven’t run fly launch interactively) — accept the prompt, or run fly apps create sentex-api (and the equivalent for admin/web) ahead of time if you’d rather do it explicitly.
4.5 Set runtime secrets
fly secrets set -a sentex-api \POSTGRES_HOST="sentex-postgres.flycast" \POSTGRES_PORT="5432" \POSTGRES_USER="<from fly postgres create output>" \POSTGRES_PASSWORD="<from fly postgres create output>" \POSTGRES_DB="<your database name>" \REDIS_URL="<redis:// URL from fly redis create>" \APP_URL="https://api.sentex.gritcms.com" \CORS_ORIGINS="https://sentex.gritcms.com,https://admin.sentex.gritcms.com" \R2_ACCOUNT_ID="..." \R2_ACCESS_KEY_ID="..." \R2_SECRET_ACCESS_KEY="..." \R2_BUCKET="..." \R2_ENDPOINT="..."
Setting a secret triggers a new deploy of sentex-api automatically (which also reruns release_command, safe since it’s idempotent). admin and web don’t need runtime secrets in this stack — their configuration is entirely build-time ([build.args] in step 4.2).
4.6 Attach custom domains
fly certs add api.sentex.gritcms.com -a sentex-apifly certs add admin.sentex.gritcms.com -a sentex-adminfly certs add sentex.gritcms.com -a sentex-web
Each command prints the DNS record(s) to create — typically an A/AAAA pair pointing at Fly’s anycast IPs, or a CNAME depending on whether the hostname is a root domain or subdomain. Create those records at your DNS provider, then poll status until issued:
fly certs check api.sentex.gritcms.com -a sentex-api
4.7 Verify
fly logs -a sentex-api— confirm the release command ran./migrate && ./seedsuccessfully before the app started serving.- Visit all three domains, confirm no CORS errors, confirm the seeded demo login works.
fly status -a sentex-api/-a sentex-admin/-a sentex-web— confirm each shows healthy running Machines.
4.8 Ongoing deploys (GitHub Actions)
Fly has no native "connect a GitHub repo and auto-deploy on push" toggle the way the other three platforms do — the standard pattern is a GitHub Actions workflow that calls flyctl deploy for each app:
- Generate a deploy token:
fly tokens create deploy -a sentex-api(and the same forsentex-admin,sentex-web, or one org-wide token if you prefer — seefly tokens create org). - Add it as a repo secret, e.g.
FLY_API_TOKEN. - Add
.github/workflows/fly-deploy.yml:name: Deploy to Fly.ioon:push:branches: [main]jobs:deploy-api:runs-on: ubuntu-lateststeps:- uses: actions/checkout@v4- uses: superfly/flyctl-actions/setup-flyctl@master- run: flyctl deploy apps/api --config fly.api.toml --dockerfile apps/api/Dockerfile --remote-onlyenv:FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}deploy-admin:runs-on: ubuntu-lateststeps:- uses: actions/checkout@v4- uses: superfly/flyctl-actions/setup-flyctl@master- run: flyctl deploy . --config fly.admin.toml --dockerfile apps/admin/Dockerfile --remote-onlyenv:FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}deploy-web:runs-on: ubuntu-lateststeps:- uses: actions/checkout@v4- uses: superfly/flyctl-actions/setup-flyctl@master- run: flyctl deploy . --config fly.web.toml --dockerfile apps/web/Dockerfile --remote-onlyenv:FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} - Optionally add
paths:filters per job (mirroring the Watch Paths concept from the Railway guide) so a change underapps/web/**doesn’t trigger an unnecessarysentex-apirebuild.
Platform dashboards and CLI flags change faster than these docs. For anything that looks different from what is written here, Fly.io's own documentation is the authority: fly.io/docs/
