A per-PR preview environment is a short-lived copy of an application, with its own running app and its own database, that a CI pipeline creates when a pull request opens, redeploys on every push, and destroys when the pull request closes.
A coding agent opens a pull request at 2am. The diff is 400 lines across a migration, two API handlers and a React form. The unit tests pass. None of that says whether the form submits or the migration runs against a real Postgres.
A preview URL answers those questions in a minute. When agents open pull requests faster than anyone can check out branches locally, that link becomes the only practical way to review the work.
What Is a Per-PR Preview Environment?
A per-PR preview environment is a complete deployment tied to one pull request, with its own app, database, secrets and URL, created and deleted automatically. The important word is isolated: two pull requests never share a database, so a migration in one cannot corrupt the data another is tested against.
That separates it from a frontend deploy preview, which gives a URL for the static build and nothing behind it. It also separates it from staging, one long-lived copy that branches take turns deploying to, where whoever deployed last wins.
Render describes its preview environments as a fresh copy of production, including datastores, created for every pull request and destroyed on merge or close; the datastore copies start without production data.[1]
Why Do Coding Agents Need Their Own Preview Environments?
Coding agents need preview environments because their pull requests arrive in volume and are written by something that cannot be asked what it meant. Passing tests prove the code does what the tests check, and agents are good at satisfying tests while missing the point of the feature. A running environment lets the reviewer watch the feature work instead of imagining it from 400 lines of diff.
Isolation matters more with agents because the agent controls the code being run, migrations included. Against a shared staging database, one agent’s bad migration breaks every other open pull request’s environment; a database per pull request contains it. The same logic drives agent sandboxes generally: whatever the agent produces runs somewhere it can only hurt itself.
A preview is also where an agent can check its own work: an agent that can hit the URL and read the logs finds its runtime errors before a person looks.
How Do You Create a Preview Environment for Every Pull Request With GitHub Actions?
You create one with a workflow that creates an app and a database on a pull request’s first push, deploys on every push, and destroys both on close. GitHub’s pull_request event only fires for opened, synchronize and reopened by default, so closed has to be listed explicitly or the teardown job never runs.[2]
Here is the complete .github/workflows/preview.yml for a Fly.io app, with a Managed Postgres cluster per pull request:
name: Preview environment
on:
pull_request:
types: [opened, reopened, synchronize, closed]
env:
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
FLY_ORG: my-org
FLY_REGION: iad
APP: myapp-pr-${{ github.event.number }}
DB: myapp-pr-${{ github.event.number }}-db
concurrency:
group: preview-${{ github.event.number }}
jobs:
deploy:
if: github.event.action != 'closed'
runs-on: ubuntu-latest
environment:
name: preview
url: https://${{ env.APP }}.fly.dev
steps:
- uses: actions/checkout@v4
- uses: superfly/flyctl-actions/setup-flyctl@master
- name: Create app and database on first push
run: |
if ! flyctl status -a "$APP" > /dev/null 2>&1; then
flyctl apps create "$APP" --org "$FLY_ORG"
flyctl mpg create --name "$DB" --org "$FLY_ORG" \
--region "$FLY_REGION" --plan basic
CLUSTER_ID=$(flyctl mpg list --org "$FLY_ORG" --json \
| jq -r --arg n "$DB" '.[] | select(.Name == $n) | .Id')
flyctl mpg attach "$CLUSTER_ID" -a "$APP"
fi
- name: Deploy
run: flyctl deploy -a "$APP" --remote-only --ha=false
teardown:
if: github.event.action == 'closed'
runs-on: ubuntu-latest
steps:
- uses: superfly/flyctl-actions/setup-flyctl@master
- name: Destroy app and database
run: |
flyctl apps destroy "$APP" -y || true
CLUSTER_ID=$(flyctl mpg list --org "$FLY_ORG" --json \
| jq -r --arg n "$DB" '.[] | select(.Name == $n) | .Id' || true)
if [ -n "$CLUSTER_ID" ]; then
flyctl mpg destroy "$CLUSTER_ID" -y
fi
What each part does
The concurrency group serializes runs for one pull request, so two quick pushes cannot race to create the app twice. The create step only provisions on the first run; later pushes go straight to flyctl deploy. flyctl mpg attach sets DATABASE_URL as a secret on the preview app.
App names are global on Fly.io, so the name carries the repository as well as the number, and the URL follows from it: https://myapp-pr-42.fly.dev, posted on the pull request by environment.url.
The token
FLY_API_TOKEN must be able to create apps and databases, which an app-scoped deploy token cannot do. Create an org deploy token with fly tokens create org and store it as a repository secret. The packaged superfly/fly-pr-review-apps action in the review apps guide handles the app lifecycle in one step.
How Do You Give Each Pull Request Its Own Database?
Each pull request gets its own database by creating it in the job that creates the app and destroying it in teardown. In the workflow above every pull request gets a dedicated Managed Postgres cluster, so each preview has an isolated app and an isolated database: separate Machines, separate Postgres, separate DATABASE_URL.
Seeding
A new database is empty. Run migrations in the app’s release step and load a small fixture set. Copying production data in is risky: preview URLs get shared, and an agent with access to the preview can read its database.
Object storage
Apps that write files need the same isolation for their bucket. Tigris supports bucket forks: creating a bucket with a fork source gives a new bucket that starts from a snapshot of the source, and the source cannot be deleted while forks depend on it.[3] Forks are created through Tigris’s S3 API and SDKs, so this runs as a short script in the create job.
The shared-cluster alternative
A cluster per pull request is the cleanest isolation and the most expensive way to get it. The cheaper pattern is one long-lived preview cluster with a database per pull request: fly mpg databases create <cluster> --name pr_42, then fly mpg attach <cluster> -a myapp-pr-42 --database pr_42. The tradeoff is that previews share the cluster’s CPU and memory, and flyctl has no command to delete a database, so teardown drops it with SQL. Choosing where Postgres runs for previews is mostly this cost question.
How Much Do Preview Environments Cost When Agents Open Dozens of PRs?
Preview environments cost whatever their resources cost for as long as they exist, so the levers are making idle previews cheap and making closed ones disappear.
The app: stop it when nobody is looking
On Fly.io, the app half is handled by Fly Proxy’s autostop and autostart. With auto_stop_machines = "stop" and min_machines_running = 0 in fly.toml, the preview’s Machine stops when traffic goes quiet and starts again on the next request. While a Machine is stopped, CPU and memory are not billed; only its root filesystem is, at $0.15 per GB per month according to Fly.io’s pricing docs. A reviewer clicking the link a day later waits for the Machine to start.
The database: the cost that does not stop
Managed Postgres is priced as a monthly plan, starting at $38 per month for Basic, plus storage. A dedicated cluster is therefore the dominant cost of a preview. Teams with a few open pull requests can afford a cluster each; agents opening dozens a day call for one preview cluster with a database per pull request.
Teardown is the real cost control
The most expensive preview is the one nobody deleted. The closed trigger handles normal closes; a scheduled workflow that destroys preview apps whose pull request is no longer open catches the rest. Render handles the same problem with an expireAfterDays setting that deletes a preview after a period with no new commits, and it requires a Pro workspace plan or higher to use preview environments at all.[1] Fly.io’s per-second rates mean any preview fleet can be priced up front in the calculator.
How Does a Coding Agent Read the Preview URL and Logs?
A coding agent reads the preview URL from the pull request or from the predictable app name, and reads the logs through an MCP server that exposes the platform’s CLI as tools.
Logs are what make the loop work. Fly.io’s flyctl MCP server exposes flyctl command groups, including apps, logs, machine, secrets and status, as tools an agent can call. Registering it with Claude Code is one command:
fly mcp server --claude
After pushing, the agent waits for the deploy job, requests the preview URL, then calls the logs tool for myapp-pr-42. A stack trace from a failed migration is now something the agent sees and fixes in the same session.
Scope what the agent can touch
The MCP server acts with whatever token it is given, and the machine, secrets and volumes groups can change things. For an agent that only needs to observe its previews, give the server a token from fly tokens create readonly, which is limited to reading one organization and its resources. The agent can read logs and status for every preview and cannot destroy anything, redeploy or change secrets.
Per-PR Preview Environments on Fly.io
On Fly.io, a per-PR preview environment is an app and a database created by name from a GitHub Actions workflow: flyctl apps create, flyctl mpg create and flyctl mpg attach on the first push, flyctl deploy on every push, and flyctl apps destroy plus flyctl mpg destroy when the pull request closes. Each preview runs on Fly Machines, which are Firecracker microVMs, so every pull request’s code runs in its own hardware-isolated VM rather than a container sharing a kernel with other previews. The microVMs and containers tradeoffs apply as for any untrusted workload.
Autostop means an idle preview costs its root filesystem storage, not CPU and memory. Managed Postgres gives each preview a real Postgres with DATABASE_URL wired in, and the flyctl MCP server lets the agent read its own preview’s logs with a read-only token. For a side-by-side with Render, see Fly.io vs Render.
Frequently Asked Questions
What is the difference between a preview deployment and a production deployment?
A preview deployment runs the code from one unmerged branch so it can be checked before merging, while a production deployment runs the merged code that real users hit. On Fly.io, a preview is a separate app with its own Machines and its own Managed Postgres database, so it shares nothing with the production app.
Is a preview environment the same as a staging environment?
No. A staging environment is one long-lived copy that branches take turns deploying to, while a preview environment exists for exactly one pull request and is destroyed when it closes. On Fly.io, each preview is its own app named after the pull request number, so previews never wait on each other.
Should CI still run unit tests if every pull request gets a preview environment?
Yes. Unit tests catch logic errors in seconds and run on every commit, while a preview environment shows whether the whole app works together once deployed. A typical Fly.io setup runs the test job first and only runs the preview deploy job when the tests pass.
Should preview environments use production data?
No, not by default. Preview URLs get shared in pull requests and chat, and a coding agent with access to a preview can read whatever is in its database. On Fly.io, the preview’s Managed Postgres database starts empty, and migrations plus a fixture script in the release step give it realistic data without the risk.
Does every pull request need a full-stack preview environment?
No. A pull request that only changes documentation or a README gains nothing from a running app. On Fly.io, the preview workflow is ordinary GitHub Actions, so any trigger filter GitHub supports applies to it.
Are preview environments enough to secure AI-generated code?
No. A preview environment contains the damage agent-written code can do while it runs, but it does not review the code for vulnerabilities that would ship to production after merging. What Fly.io adds is the containment: each preview runs in its own Firecracker microVM with its own database, so a bad pull request breaks only its own preview.
How are stale preview environments cleaned up?
Stale previews are cleaned up by a teardown job on the pull request closed event, plus a scheduled job that catches anything the first one missed. On Fly.io, the teardown runs flyctl apps destroy and flyctl mpg destroy, and a nightly job can destroy any preview app whose pull request is no longer open.
Can a coding agent create its own preview environment?
Yes, but it is usually better to let CI create it and give the agent read access. On Fly.io, the agent reads the preview’s logs and status through the flyctl MCP server, using a read-only token, so it can check its own work without being able to destroy anything.
Sources
- ^ Render, “Preview Environments”. render.com/docs/preview-environments. Checked 2026-10-12.
- ^ GitHub, “Events that trigger workflows”. docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows. Checked 2026-10-12.
- ^ Tigris, “Bucket snapshots and forks”. tigrisdata.com/docs/buckets/snapshots-and-forks. Checked 2026-10-12.