Everything in this series so far—reproducible environments, required checks, previews, migration discipline, scoped credentials—exists to make one moment boring: the moment code reaches production. This part makes that moment concrete with a specific platform. The patterns generalize to other container-hosting platforms, but naming one keeps the examples honest rather than abstract.

Two apps, one codebase

Staging and production should be two distinct Fly.io applications, each with its own name, its own Postgres cluster, and its own secrets—not two processes sharing one app under different environment variables. Two apps means a mistake in staging's configuration cannot accidentally touch production, and it means the two environments can genuinely run different machine counts, regions, or scaling rules without one config file trying to express both.

.
├── fly.toml            # production
├── fly.staging.toml     # staging
└── .github/workflows/
    ├── deploy-staging.yml
    └── deploy-production.yml

Both files build from the same Dockerfile and the same source tree. What differs is the app name, the release command's target database, and, often, the machine count.

fly.toml and fly.staging.toml: what actually differs

# fly.staging.toml
app = "my-app-staging"
primary_region = "iad"

[build]

[deploy]
  release_command = "npx prisma migrate deploy"

[[services]]
  internal_port = 3000
  protocol = "tcp"

  [[services.ports]]
    port = 443
    handlers = ["tls", "http"]

[[services.http_checks]]
  interval = "15s"
  timeout = "5s"
  grace_period = "10s"
  path = "/api/health"
# fly.toml (production)
app = "my-app"
primary_region = "iad"

[build]

[deploy]
  release_command = "npx prisma migrate deploy"
  strategy = "rolling"

[[services]]
  internal_port = 3000
  protocol = "tcp"
  min_machines_running = 2

  [[services.ports]]
    port = 443
    handlers = ["tls", "http"]

[[services.http_checks]]
  interval = "15s"
  timeout = "5s"
  grace_period = "10s"
  path = "/api/health"
SettingStagingProduction
App namemy-app-stagingmy-app
min_machines_running0 or 1, cost-optimized2, for availability during a rolling deploy
Deploy strategyDefault (immediate)rolling, so old and new machines overlap briefly
DatabaseSeparate Fly Postgres cluster, smaller machine sizeSeparate cluster, sized for real traffic
TriggerAutomatic on merge to mainManual approval via GitHub Environment protection (part eight)

Keeping the two fly.toml files nearly identical in structure, differing mainly in scale and trigger, makes staging a genuinely useful rehearsal of production rather than a different application wearing the same name.

Deploying with release_command and the cold-start trap

The release_command in both files runs prisma migrate deploy before the new machines start serving traffic—the same command validated on every pull request in part six and promoted through the discipline in part seven. On Fly.io, this step has a specific, easy-to-miss failure mode worth planning for directly: a managed Postgres machine configured to auto-stop when idle can still be asleep the moment release_command runs, and the connection attempt fails before the database finishes waking up.

The fix is not to fight the platform's auto-stop behavior—it exists to control cost on lightly used databases, and disabling it everywhere just to avoid one narrow failure mode is the wrong trade. The practical fix is a short retry loop with backoff wrapping the migration command, giving the database the few seconds it needs to come up before the deploy gives up.

#!/usr/bin/env bash
# scripts/migrate-with-retry.sh
set -euo pipefail

max_attempts=5
attempt=1
delay=3

until npx prisma migrate deploy; do
  if [ "$attempt" -ge "$max_attempts" ]; then
    echo "Migration failed after ${attempt} attempts." >&2
    exit 1
  fi
  echo "Migration attempt ${attempt} failed, likely a cold-starting database. Retrying in ${delay}s..." >&2
  sleep "$delay"
  attempt=$((attempt + 1))
  delay=$((delay * 2))
done
[deploy]
  release_command = "bash scripts/migrate-with-retry.sh"

This is a small script, and it is worth writing once rather than re-discovering the failure during a release under time pressure. The exponential backoff also matters: a fixed, short retry interval can hammer a database that is still initializing, while a growing delay gives it room to finish waking up.

Health checks that catch a bad deploy before traffic does

The http_checks block above is not optional decoration. Fly.io uses it to decide whether a newly deployed machine is actually healthy before routing traffic to it, and whether an old machine can be safely retired during a rolling deploy. A meaningful health check endpoint should verify more than "the process is running"—at minimum, that it can reach the database.

// app/api/health/route.ts
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";

export async function GET() {
  try {
    await prisma.$queryRaw`SELECT 1`;
    return NextResponse.json({ status: "ok" }, { status: 200 });
  } catch {
    return NextResponse.json({ status: "unavailable" }, { status: 503 });
  }
}

A health check that only confirms the HTTP server is listening will happily report success while the application is unable to serve a single real request—exactly the gap a health check exists to close.

Rolling deploys and rollback

With strategy = "rolling" and min_machines_running = 2 in production, a new deploy brings up replacement machines one at a time, checks their health, and only then retires the old ones. If a new machine fails its health check, Fly.io stops the rollout rather than replacing every machine with a broken version at once—a form of automatic partial rollback built into the deploy strategy itself.

For a deploy that passed health checks but is behaving badly in ways checks did not catch, flyctl releases list and flyctl deploy --image <previous-image-ref> provide a fast, explicit rollback path. Because migrations from part seven follow an expand/contract discipline, a rollback to the previous application version is safe precisely because the previous version never depended on a column or table the new migration might have removed.

Promoting staging to production deliberately

Staging deploys automatically on every merge to main, which keeps it a continuously current rehearsal environment. Production deploys from a tag, created deliberately once staging has been observed working:

git tag v2026.10.26
git push origin v2026.10.26

This gives the team a specific, named artifact for every production release, a natural place to attach release notes, and a clean boundary between "merged" and "released" that survives even a main branch with a high commit frequency.

Common mistakes

  • Sharing one Fly Postgres cluster between staging and production "temporarily," which becomes permanent and risks staging traffic touching real data.
  • Skipping the retry wrapper on release_command and treating the resulting cold-start failures as random flakiness instead of a known, fixable pattern.
  • Writing a health check that only checks process liveness, not the database connection the application actually depends on.
  • Setting min_machines_running to 1 in production, removing the overlap a rolling deploy needs to avoid a brief capacity dip.
  • Deploying production directly from main on every merge, collapsing the deliberate "released" boundary a tag-based trigger provides.

Practical checklist

  • Staging and production are separate Fly.io apps with separate Postgres clusters and secrets.
  • release_command runs migrations wrapped in a retry loop with backoff.
  • The health check endpoint verifies real database connectivity, not just process liveness.
  • Production uses strategy = "rolling" with at least two machines running during a deploy.
  • Production deploys are triggered by a tag or manual dispatch, with a required reviewer, not by every merge to main.
  • flyctl releases list and the previous image reference are known and tested as a rollback path before they are needed.

Previous: Secrets, Environments, and Deployment Security in GitHub Actions

Next: The Reference Architecture: A Reusable Blueprint for Your Next Product. The series closes by generalizing everything built so far into a repeatable reference structure for the next project.