Deploy a Rails App to Fly.io and Get Preview Environments for Every Pull Request
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.
- GitHub: rails-preview-demo
What is Fly.io
Fly.io is a platform for deploying applications globally across distributed regions.
| Feature | Description |
|---|---|
| Machines | KVM-based hardware-virtualized containers. Launch only when needed |
| Global Deployment | Deploy across 18 regions. Tokyo (nrt) supported |
| Auto Stop/Start | Stop Machines when idle to reduce costs |
| Fly Postgres | Create managed PostgreSQL clusters in your chosen region |
| CLI-centric | Complete app creation, deployment, and management via the flyctl CLI |
| Security | Rust/Go stack. SOC2 Type 2 attestation |
Tech Stack
The tech stack used for this verification. The same app from the Railway article is used.
| Technology | Version |
|---|---|
| Ruby | 3.4.8 |
| Rails | 8.1.2 |
| PostgreSQL | 17 |
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| Setting | Value | Description |
|---|---|---|
primary_region | nrt | Deploy to the Tokyo region |
release_command | bin/rails db:prepare | Run migrations before starting the app |
internal_port | 3000 | Rails (Puma) default port |
auto_stop_machines | stop | Stop Machines when idle |
auto_start_machines | true | Auto-start Machines on request |
min_machines_running | 0 | Minimum running count 0 (allow full stop) |
path (checks) | /up | Rails 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 deployWhen 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-onlyWhen 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
| Secret | Description |
|---|---|
FLY_API_TOKEN | Fly.io org-level token |
RAILS_MASTER_KEY | Value from config/master.key |
Get the FLY_API_TOKEN with:
fly tokens create orgThe 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:
| Event | Action |
|---|---|
opened / reopened | Create a new Fly app for the PR and deploy |
synchronize | Redeploy on every push to the PR branch |
closed | Delete the PR’s Fly app |
Internally, superfly/fly-pr-review-apps performs these steps:
| Step | Description |
|---|---|
| 1. Create app | Create a Fly app named pr-{number}-{repository-name} |
| 2. Attach DB | Create a new database in the existing PostgreSQL cluster and attach it |
| 3. Set secrets | Set RAILS_MASTER_KEY and other secrets on the Fly app |
| 4. Build & deploy | Build the Docker image on Fly.io’s remote builders and start |
| 5. DB migration | Run 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}"
fiThis 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 orgOrg-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.
| Aspect | Railway | Fly.io |
|---|---|---|
| PR Preview | Built-in feature | Requires GitHub Actions |
| Config files | railway.json | fly.toml + 2 workflows |
| DB management | Auto-creates isolated PostgreSQL instance per PR | Adds DB to existing cluster |
| Region | Auto-selected | Explicitly specified (nrt, etc.) |
| Migrations | Via docker-entrypoint | Via release_command |
| Bot PR support | Must enable in Dashboard | No distinction (GitHub Actions) |
| Customizability | Low (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-appsautomates app creation/deletion per PR- Deploy to the Tokyo region (
nrt) release_commandrunsdb: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.