Deploy a Rails App to Fly.io and Get Preview Environments for Every Pull Request

Tadashi Shigeoka ·  Fri, February 6, 2026

When you open a pull request, you want a preview environment where reviewers can immediately verify changes. Fly.io is a platform that runs apps on KVM-based hardware-virtualized containers, with a Tokyo region (nrt) available.

Fly.io does not have a built-in Pull Request (PR) preview environment feature, but you can achieve this by combining GitHub Actions with superfly/fly-pr-review-apps.

I deployed the same Rails 8.1 + PostgreSQL app from the previous Railway article to Fly.io and verified that PR preview environments work end to end.

What is Fly.io

Fly.io is a platform for deploying applications globally across distributed regions.

FeatureDescription
MachinesKVM-based hardware-virtualized containers. Launch only when needed
Global DeploymentDeploy across 18 regions. Tokyo (nrt) supported
Auto Stop/StartStop Machines when idle to reduce costs
Fly PostgresCreate managed PostgreSQL clusters in your chosen region
CLI-centricComplete app creation, deployment, and management via the flyctl CLI
SecurityRust/Go stack. SOC2 Type 2 attestation

Tech Stack

The tech stack used for this verification. The same app from the Railway article is used.

TechnologyVersion
Ruby3.4.8
Rails8.1.2
PostgreSQL17

The app is a minimal scaffold with Post CRUD. For details on routing, health checks (GET /up), and the Dockerfile, see the Railway article.

Deploying to Fly.io

1. Create fly.toml

Add fly.toml to the root of your repository.

app = "rails-preview-demo"
primary_region = "nrt"
 
[build]
 
[deploy]
  release_command = "bin/rails db:prepare"
 
[http_service]
  internal_port = 3000
  force_https = true
  auto_stop_machines = "stop"
  auto_start_machines = true
  min_machines_running = 0
 
[checks.status]
  port = 3000
  type = "http"
  interval = "10s"
  timeout = "2s"
  grace_period = "30s"
  method = "GET"
  path = "/up"
 
[[vm]]
  memory = "512mb"
  cpu_kind = "shared"
  cpus = 1
SettingValueDescription
primary_regionnrtDeploy to the Tokyo region
release_commandbin/rails db:prepareRun migrations before starting the app
internal_port3000Rails (Puma) default port
auto_stop_machinesstopStop Machines when idle
auto_start_machinestrueAuto-start Machines on request
min_machines_running0Minimum running count 0 (allow full stop)
path (checks)/upRails health check endpoint

A key difference from Railway is the release_command. Fly.io runs release_command in a dedicated temporary Machine during deployment, before starting the app Machine. Since db:prepare is executed here, there’s no need to run it via docker-entrypoint.

2. Create the App via CLI

Use the Fly.io CLI (flyctl) to create the app and PostgreSQL cluster.

# Install the CLI
curl -L https://fly.io/install.sh | sh
 
# Sign in
fly auth login
 
# Create the app
fly apps create rails-preview-demo
 
# Create PostgreSQL cluster (Tokyo region)
fly postgres create --name rails-preview-demo-db --region nrt
 
# Attach PostgreSQL to the app (DATABASE_URL is set automatically)
fly postgres attach rails-preview-demo-db --app rails-preview-demo
 
# Set RAILS_MASTER_KEY
fly secrets set RAILS_MASTER_KEY=<value from config/master.key> --app rails-preview-demo
 
# Deploy
fly deploy

When you run fly postgres attach, DATABASE_URL is automatically set as an environment variable on the app. Unlike Railway, there’s no need to manually write variable reference syntax.

3. Verify the Deployment

Once deployment completes, you can access your app at <app-name>.fly.dev.

Production Deploy via GitHub Actions

Fly.io also supports connecting your GitHub repository from the Dashboard for auto-deployment, but we use GitHub Actions to manage it alongside PR preview environments.

Create .github/workflows/fly-deploy.yml:

name: Fly Deploy
 
on:
  push:
    branches: [main]
 
env:
  FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
 
jobs:
  deploy:
    runs-on: ubuntu-latest
    concurrency: deploy-group
    steps:
      - uses: actions/checkout@v4
      - uses: superfly/flyctl-actions/setup-flyctl@master
      - run: flyctl deploy --remote-only

When pushing to the main branch, flyctl deploy --remote-only builds the Docker image on Fly.io’s remote builders and deploys it.

Required GitHub Secrets

SecretDescription
FLY_API_TOKENFly.io org-level token
RAILS_MASTER_KEYValue from config/master.key

Get the FLY_API_TOKEN with:

fly tokens create org

The reason an org-level token is required is discussed later.

PR Preview Environments

This is the main topic. Fly.io does not have built-in PR preview features like Railway. Instead, we use superfly/fly-pr-review-apps, an official GitHub Action.

Workflow

Create .github/workflows/fly-preview.yml:

name: Fly Preview
 
on:
  pull_request:
    types: [opened, reopened, synchronize, closed]
 
env:
  FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
 
jobs:
  preview:
    runs-on: ubuntu-latest
    concurrency:
      group: pr-${{ github.event.number }}
    steps:
      - uses: actions/checkout@v4
      - uses: superfly/fly-pr-review-apps@1.5.0
        with:
          region: nrt
          org: personal
          postgres: rails-preview-demo-db
          secrets: RAILS_MASTER_KEY=${{ secrets.RAILS_MASTER_KEY }}

How It Works

The workflow automatically performs the following based on PR events:

EventAction
opened / reopenedCreate a new Fly app for the PR and deploy
synchronizeRedeploy on every push to the PR branch
closedDelete the PR’s Fly app

Internally, superfly/fly-pr-review-apps performs these steps:

StepDescription
1. Create appCreate a Fly app named pr-{number}-{repository-name}
2. Attach DBCreate a new database in the existing PostgreSQL cluster and attach it
3. Set secretsSet RAILS_MASTER_KEY and other secrets on the Fly app
4. Build & deployBuild the Docker image on Fly.io’s remote builders and start
5. DB migrationRun release_command (db:prepare)

When the PR is merged or closed, the preview Fly app is automatically deleted.

Posting the Preview URL

superfly/fly-pr-review-apps itself does not post comments on the PR. Add steps to the workflow to comment the preview URL:

      - name: Set preview URL
        if: github.event.action != 'closed'
        id: preview-url
        run: |
          APP_NAME="pr-${{ github.event.number }}-$(echo '${{ github.repository }}' | tr '/' '-')"
          echo "url=https://${APP_NAME}.fly.dev" >> "$GITHUB_OUTPUT"
      - name: Comment preview URL on PR
        if: github.event.action != 'closed'
        env:
          GH_TOKEN: ${{ github.token }}
        run: |
          MARKER="<!-- fly-preview-url -->"
          BODY="${MARKER}
          **Fly.io Preview:** ${{ steps.preview-url.outputs.url }}"
          EXISTING=$(gh api "repos/${{ github.repository }}/issues/${{ github.event.number }}/comments" \
            --jq "[.[] | select(.body | startswith(\"${MARKER}\"))] | first | .id" 2>/dev/null)
          if [ -n "$EXISTING" ] && [ "$EXISTING" != "null" ]; then
            gh api "repos/${{ github.repository }}/issues/comments/${EXISTING}" \
              -X PATCH -f body="${BODY}"
          else
            gh pr comment "${{ github.event.number }}" --repo "${{ github.repository }}" --body "${BODY}"
          fi

This uses an HTML comment <!-- fly-preview-url --> as a marker, allowing subsequent pushes to the same PR to update the existing comment instead of creating duplicates.

GitHub Deployments Integration

You can also use the GitHub Deployments API to manage preview environment status on GitHub:

      - name: Create GitHub deployment
        if: github.event.action != 'closed'
        uses: actions/github-script@v7
        with:
          script: |
            const deployment = await github.rest.repos.createDeployment({
              owner: context.repo.owner,
              repo: context.repo.repo,
              ref: context.payload.pull_request.head.sha,
              environment: `preview-pr-${context.payload.number}`,
              auto_merge: false,
              required_contexts: [],
              transient_environment: true,
            });
            await github.rest.repos.createDeploymentStatus({
              owner: context.repo.owner,
              repo: context.repo.repo,
              deployment_id: deployment.data.id,
              state: 'success',
              environment_url: '${{ steps.preview-url.outputs.url }}',
              log_url: `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`,
            });

Setting transient_environment: true tells GitHub to automatically deactivate the deployment when the PR is closed.

Gotcha — Deploy Tokens Lack Sufficient Permissions

Here’s an issue I encountered during testing.

Problem

When I set a deploy token generated with fly tokens create deploy as the FLY_API_TOKEN, the PR preview environment deployment failed with:

Not authorized to deploy this app

Cause

Deploy tokens only authorize operations on a specific app. The PR preview workflow dynamically creates new Fly apps named pr-{number}-{repository-name}, so deploy permissions for the existing app alone are insufficient.

Solution

Use an org-level token:

fly tokens create org

Org-level tokens authorize operations on all apps within the organization, enabling the creation of new apps.

Comparison with Railway

Here’s a comparison from deploying the same app to both Railway and Fly.io.

AspectRailwayFly.io
PR PreviewBuilt-in featureRequires GitHub Actions
Config filesrailway.jsonfly.toml + 2 workflows
DB managementAuto-creates isolated PostgreSQL instance per PRAdds DB to existing cluster
RegionAuto-selectedExplicitly specified (nrt, etc.)
MigrationsVia docker-entrypointVia release_command
Bot PR supportMust enable in DashboardNo distinction (GitHub Actions)
CustomizabilityLow (built-in)High (write workflows freely)

Railway excels in ease of setup, while Fly.io offers greater workflow customizability. With Fly.io, you have fine-grained control over how preview URLs are posted to PRs, GitHub Deployments integration, and more.

Summary

By combining Fly.io and GitHub Actions, you can build PR preview environments for your Rails app.

  • Configured with fly.toml + 2 GitHub Actions workflows
  • superfly/fly-pr-review-apps automates app creation/deletion per PR
  • Deploy to the Tokyo region (nrt)
  • release_command runs db:prepare, avoiding docker-entrypoint conflicts
  • Use an org-level token (deploy tokens cannot create new apps)
  • Preview URL notifications and GitHub Deployments integration are freely customizable in workflows

Compared to Railway’s built-in feature, more config files are needed, but you gain full control over the workflow.

That’s all from the Gemba — deploying Rails to Fly.io and setting up per-PR preview environments.

References