All deployment targets
Managed platform

Deploy to Fly.io

Runs the Docker image close to your users, with a real disk if you need one.

From
~$5
Ops effort
Low
Managed Postgres
Persistent disk
Best for

A single Go binary you want in several regions without running servers. The best fit for Grit’s single-binary mode.

Not for

Teams that want a click-through dashboard for everything: Fly is CLI-first and expects you to read a TOML file.

Quick setup

  1. 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. 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 = 8080
    force_https = true
    auto_stop_machines = "suspend"
    auto_start_machines = true
    min_machines_running = 1
    [[http_service.checks]]
    interval = "15s"
    timeout = "3s"
    grace_period = "10s"
    method = "GET"
    path = "/api/health"
  3. 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-db
    fly postgres attach my-app-db
    fly redis create
  4. 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. 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 deploy
    fly 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.

Full walkthrough

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 | sh
fly 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 = 8080
force_https = true
auto_stop_machines = false
auto_start_machines = true
min_machines_running = 1
[[vm]]
cpu_kind = "shared"
cpus = 1
memory_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 = 3000
force_https = true
auto_stop_machines = false
auto_start_machines = true
min_machines_running = 1
[[vm]]
cpu_kind = "shared"
cpus = 1
memory_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_URLweb 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_command on sentex-api is Fly’s equivalent of Railway’s Pre-Deploy Command and Render’s preDeployCommand — 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. Same sh -c wrapping reasoning as the other platforms.
  • [build.args] is how Fly forwards Docker build arguments — this maps directly to the build.args block for admin/web in docker-compose.prod.yml. Build args aren’t available at runtime, so this only covers the NEXT_PUBLIC_*/THEME/SOCIAL_AUTH_ENABLED values 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 in fly.toml — they go in via fly secrets set in 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 product
fly postgres create --name sentex-postgres --region jnb
# Attach it to the api app — this auto-creates a DATABASE_URL secret on sentex-api
fly postgres attach sentex-postgres --app sentex-api
# Redis — Fly's built-in Upstash-managed integration
fly 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 Compose
fly deploy apps/api --config fly.api.toml --dockerfile apps/api/Dockerfile
# admin — context is the repo root, matching build.context: . in Compose
fly deploy . --config fly.admin.toml --dockerfile apps/admin/Dockerfile
# web — same reasoning as admin
fly 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-api
fly certs add admin.sentex.gritcms.com -a sentex-admin
fly 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

  1. fly logs -a sentex-api — confirm the release command ran ./migrate && ./seed successfully before the app started serving.
  2. Visit all three domains, confirm no CORS errors, confirm the seeded demo login works.
  3. 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:

  1. Generate a deploy token: fly tokens create deploy -a sentex-api (and the same for sentex-admin, sentex-web, or one org-wide token if you prefer — see fly tokens create org).
  2. Add it as a repo secret, e.g. FLY_API_TOKEN.
  3. Add .github/workflows/fly-deploy.yml:
    name: Deploy to Fly.io
    on:
    push:
    branches: [main]
    jobs:
    deploy-api:
    runs-on: ubuntu-latest
    steps:
    - 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-only
    env:
    FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
    deploy-admin:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v4
    - uses: superfly/flyctl-actions/setup-flyctl@master
    - run: flyctl deploy . --config fly.admin.toml --dockerfile apps/admin/Dockerfile --remote-only
    env:
    FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
    deploy-web:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v4
    - uses: superfly/flyctl-actions/setup-flyctl@master
    - run: flyctl deploy . --config fly.web.toml --dockerfile apps/web/Dockerfile --remote-only
    env:
    FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
  4. Optionally add paths: filters per job (mirroring the Watch Paths concept from the Railway guide) so a change under apps/web/** doesn’t trigger an unnecessary sentex-api rebuild.

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/