What are preview environments? A guide to ephemeral environments
Preview environments are temporary copies of your app for each pull request. How they differ from staging, how to build them, costs, pitfalls and fork security.
By the pyrovue team · · 11 min read
A preview environment is a temporary, isolated copy of your application that is created for one pull request, updated on every push to it, and deleted when the pull request is merged or closed. It has its own URL, so reviewers, designers and testers can use the change running instead of reading the diff and imagining it.
"Ephemeral environment" is the wider term: any environment built on demand and thrown away afterwards. A preview environment is the most common kind, tied to a pull request. Vendors name the same idea differently. Vercel calls them preview deployments, Netlify calls them Deploy Previews, GitLab calls them review apps, and Render and Okteto call them preview environments. (They are not the same thing as a cloud development environment, which is where you write code rather than review it.)
What goes into one varies a lot. For a static frontend, it's a build on a CDN, ready as soon as the build finishes. For a backend with a database, queues and several services, it's a Kubernetes namespace, a database branch and a set of DNS records that something has to create, wire together and remove. This guide covers both ends, what they cost and where they break.
Preview environments vs staging
A staging environment is one long-lived, shared environment in front of production. Preview environments are many short-lived ones, each holding one change.
| Staging | Preview environments | |
|---|---|---|
| How many | One, sometimes two or three | One per open pull request |
| Lifetime | Permanent | Hours to weeks; gone after merge |
| What it runs | The main branch or a release candidate | One branch's changes on top of its base |
| Who uses it | Whoever is testing the next release | The author and reviewers of that pull request |
| Data | Often a large, realistic copy | Seeded, branched or empty |
Shared staging has familiar problems: changes queue for it, one broken deploy blocks everyone, and it drifts from production because people fix things in it by hand. Previews remove the queue because every change gets its own environment.
They don't replace staging. A preview shows one change in isolation, so it can't show how three merged changes behave together, and it rarely has production-sized data or traffic. Teams with previews usually keep a staging environment, or a staged production deploy, for integration tests, load tests and infrastructure changes.
The lifecycle of a preview environment
Whatever builds it, a preview environment goes through four steps, driven by pull request events (on GitHub: opened, synchronize, reopened and closed):
- Create on open. CI builds the branch, provisions the environment, deploys the build and posts the URL on the pull request as a comment or a deployment status.
- Update on push. Each new commit rebuilds and redeploys. Some tools also keep a URL per commit next to a stable URL per branch; Vercel gives you both.
- Review. People click through it, run end-to-end tests against it and comment on the pull request.
- Destroy on merge or close. Everything created for the environment is deleted: containers, databases, DNS records, certificates and credentials.
Step 4 is the one that fails quietly. A closed event can be missed when CI is down, and some pull requests stay open for months. Give every environment a time limit as well: GitLab review apps take auto_stop_in: 1 week, Render's previews.expireAfterDays deletes a preview after that many days without activity, and a nightly job that compares open pull requests with running environments catches the rest.
How to build preview environments
Hosting platforms' preview deployments
If your app is a frontend or runs on serverless functions, your host probably does this already. Vercel creates a preview deployment for every push to a non-production branch and every pull request, with a branch URL (<project-name>-git-<branch-name>-<scope-slug>.vercel.app) and one per commit. Netlify's Deploy Previews live at addresses like deploy-preview-42--mysitename.netlify.app and update as you push. Render goes further: a previews block in render.yaml copies every service and datastore for each pull request, on its Pro workspace plan and above, billed like any other service.
The setup is a setting and the cost is low. The limit is that only what the host runs gets previewed. If your API lives elsewhere, the frontend preview talks to a shared staging API, and a pull request that changes both sides can't be previewed as one.
A Kubernetes namespace per pull request
For services already on Kubernetes, the usual pattern is GitOps: deploy each pull request into its own namespace. Argo CD's ApplicationSet has a Pull Request generator that lists open pull requests and templates one Application for each. CI builds an image tagged with the commit SHA; Argo CD deploys it:
apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata:
name: previews
namespace: argocd
spec:
goTemplate: true
goTemplateOptions: ["missingkey=error"]
generators:
- pullRequest:
github:
owner: myorg
repo: myapp
tokenRef:
secretName: github-token
key: token
labels:
- preview
requeueAfterSeconds: 300
template:
metadata:
name: 'myapp-pr-{{.number}}'
spec:
project: default
source:
repoURL: 'https://github.com/myorg/myapp.git'
targetRevision: '{{.head_sha}}'
path: deploy/chart
helm:
parameters:
- name: image.tag
value: '{{.head_sha}}'
- name: ingress.host
value: 'pr-{{.number}}.preview.example.com'
destination:
server: https://kubernetes.default.svc
namespace: 'pr-{{.number}}'
syncPolicy:
automated:
prune: true
syncOptions:
- CreateNamespace=true
Three details from the docs matter. The generator polls (every 30 minutes by default) unless you set up its webhook. When a pull request closes or loses the label, its Application is deleted along with the resources it deployed. The namespace made by CreateNamespace=true isn't tracked by Argo CD, though, so empty pr-* namespaces pile up unless something removes them.
Flux users get the same result from the Flux Operator: a ResourceSetInputProvider of type GitHubPullRequest finds labelled pull requests, and a ResourceSet renders a HelmRelease for each, which is uninstalled after the merge (Flux Operator guide). Okteto sells the whole pattern as a product, with CI steps that deploy a preview when a pull request opens and destroy it when it closes.
This is the most flexible approach and the most work: image builds per commit, a wildcard DNS record and certificate, resource quotas per namespace, and a plan for the database.
Infrastructure as code per branch
When the change is infrastructure (a new queue, a bucket policy, a database parameter), only real cloud resources will show it. Terraform workspaces give each pull request its own state:
# On open and on every push
terraform workspace select -or-create "pr-${PR_NUMBER}"
terraform apply -auto-approve -var "env_name=pr-${PR_NUMBER}"
# On close
terraform workspace select "pr-${PR_NUMBER}"
terraform destroy -auto-approve -var "env_name=pr-${PR_NUMBER}"
terraform workspace select default
terraform workspace delete "pr-${PR_NUMBER}"
This is the closest thing to production, and the slowest and most expensive: an environment takes as long as its slowest resource to create, and each one is billed until it's destroyed. HashiCorp also points out that CLI workspaces share one backend, so they aren't an isolation boundary when environments need different credentials. Many teams run terraform plan on every pull request and build full per-branch environments only for the changes that need them.
Databases for preview environments
The database is where previews get hard. Four options, from cheapest to closest to production:
- The shared staging database. Free, but a migration in one pull request breaks every other preview and staging with it.
- A fresh database per preview, seeded from fixtures. A Postgres container next to the app, migrated and seeded on start. Isolated and repeatable, but only as realistic as your seed script. Render's preview copies of datastores also start empty.
- A branching database. Neon creates a copy-on-write branch of a Postgres database that starts with the parent's data, without copying it up front. Its GitHub Actions create a branch per pull request and delete it on close.
- A restored snapshot. Realistic, slow and costly; usually worth it only for testing a risky migration.
A Neon branch per pull request:
name: Preview database
on:
pull_request:
types: [opened, reopened, synchronize, closed]
jobs:
create:
if: github.event.action != 'closed'
runs-on: ubuntu-24.04
steps:
- id: branch
uses: neondatabase/create-branch-action@v6
with:
project_id: ${{ vars.NEON_PROJECT_ID }}
api_key: ${{ secrets.NEON_API_KEY }}
branch_name: preview/pr-${{ github.event.number }}
parent_branch: anonymised # a branch holding masked data, not production
# Pass steps.branch.outputs.db_url to your deploy step as DATABASE_URL.
delete:
if: github.event.action == 'closed'
runs-on: ubuntu-24.04
steps:
- uses: neondatabase/delete-branch-action@v3
with:
project_id: ${{ vars.NEON_PROJECT_ID }}
api_key: ${{ secrets.NEON_API_KEY }}
branch: preview/pr-${{ github.event.number }}
The create action returns the existing branch when one with that name exists, so pushes don't make duplicates. Note the parent: a branch of your production database is a full copy of your customers' data behind a preview URL. Branch from an anonymised parent, or use the action's schema-only branch type or masking rules.
Preview environment approaches compared
| Approach | Best for | What you build and maintain | Ready in | Data | Pull requests from forks |
|---|---|---|---|---|---|
| Hosting previews (Vercel, Netlify, Render) | Frontends, serverless, apps already on that host | A connected project | One build | Shared, or empty copies | Held for a team member's approval by default on Vercel |
| Kubernetes namespace per PR (Argo CD, Flux, Okteto) | Services already on Kubernetes | Image builds, templates, DNS, TLS, quotas, cleanup | Image build plus rollout | Seeded or branched | Usually gated behind a maintainer's label |
| Infrastructure as code per branch (Terraform) | Infrastructure changes | Modules, state per PR, cleanup jobs | As long as the slowest resource | Whatever you provision | Shouldn't run at all: it needs cloud credentials |
| The app in one box (pyrovue) | Reviewing UI and behaviour, open source, demos | Nothing for most repos, or a pyrovue.json | About a minute for most repos | Postgres, MySQL or Redis from a Compose file, inside the box | Runs isolated, with none of your secrets |
Costs and pitfalls of ephemeral environments
Cost grows with open pull requests. Twenty open pull requests means twenty environments, most of them idle. Scale idle previews to zero or put them to sleep, set resource quotas, and expire them.
Secrets. A preview needs credentials for its database and any APIs it calls. Give previews their own low-privilege, test-mode credentials; Vercel and Netlify both let you set different values for previews than for production. Never reuse production keys.
OAuth and third-party callbacks. Login providers check the redirect URL against a list you register in advance, and many don't accept wildcards: Google rejects * in redirect URIs outright. A new hostname per pull request therefore breaks sign-in. The usual fixes are one fixed callback host that forwards the user back to the right preview, or a small pool of registered preview hostnames. Webhooks from payment providers, Git hosts and chat apps have the same problem: each preview's endpoint must be registered, or a test account must forward events to it.
DNS and TLS. Point a wildcard record such as *.preview.example.com at your ingress. A wildcard certificate from Let's Encrypt needs the DNS-01 challenge; HTTP-01 can't issue one. A DNS label holds at most 63 characters, so build hostnames from the pull request number, or slug and truncate the branch name (Argo CD's branch_slug does this). Cookie domains, CORS allow-lists and CSP rules also need to accept the preview hosts.
Cleanup. Run teardown on closed, add a time limit, and reconcile nightly. Orphans cost money, and old previews running old dependencies are an attack surface.
Visibility. Previews are public URLs full of unreleased features unless you protect them. Put them behind your host's deployment protection or SSO, and keep them out of search engines (Vercel's previews aren't indexed by default; check your own setup).
Preview environments for pull requests from forks
On an open source project anyone can open a pull request, and building a preview runs their code: install scripts, build plugins and the app itself. If that build can read your secrets, the pull request can send them anywhere.
GitHub's defaults protect you if you keep them. For a workflow triggered by a pull request from a fork, secrets aren't passed to the runner except GITHUB_TOKEN, which is read-only. That's why the Neon workflow above fails on fork pull requests, which is the right outcome. The trap is pull_request_target, which runs in the base repository's context with its secrets and a write token. GitHub Security Lab's write-up on "pwn requests" shows how checking out and building the pull request's code in that context hands over the repository.
Hosts add their own guards. Vercel requires a team member to authorise the deployment of a fork's pull request unless you turn Git Fork Protection off. Netlify's sensitive variable policy decides whether untrusted deploys on public repositories can read sensitive variables.
Rules that hold for any setup:
- Don't give fork code your secrets, in the build or at runtime.
- If a maintainer approves fork previews with a label, the label approves the commit they read, not the next one. Remove it on push, or review again.
- Run fork previews where they can't reach your internal network or the cloud metadata endpoint, with CPU and memory limits, so a crypto miner or a port scan stays contained. The same reasoning applies to running a stranger's repo on your laptop.
When one box is enough, and when you need staging
pyrovue takes a smaller approach. Open https://pyrovue.com/github.com/OWNER/REPO/pull/NUMBER, or paste the pull request's URL, and it shows the title, the author and whether the code comes from a fork, then runs the latest commit in an isolated box. It works out how to install and start the repo and gives you a live preview at its own address, a terminal and the files. Rebuild picks up new pushes. Postgres, MySQL and Redis from the repo's Compose file run inside the same box. A small workflow on pull_request_target comments the run link on each new pull request without checking out or running its code, and whoever opens the link runs the box on their own plan.
Be clear about what that is: the app from the repository, in one container, with an empty or seeded database. It isn't a copy of your production stack. Your managed services, other microservices, production configuration and real data aren't there, there's no Docker inside the box, and GitLab merge requests can't be run yet.
That's enough when the question is "does this change look and behave right": UI review, checking a bug fix, open source projects whose pull requests come from forks, and demos for people who won't install anything. You need a staging-like environment when the change touches infrastructure, depends on several services or real integrations, or must be tested against production-sized data. The pull request previews page has the details, and how to test a pull request covers checking one out and running it locally.
Where to start
- A frontend on Vercel, Netlify or Render: turn on previews, give them their own environment variables, and set up one fixed OAuth callback.
- Services on Kubernetes: an ApplicationSet or Flux ResourceSet behind a label, a database per preview, and a nightly cleanup job.
- Infrastructure changes:
terraform planon every pull request, and full per-branch environments only where a plan isn't enough. - Open source, or no preview infrastructure yet: start with a run link per pull request and build more only when a review needs it.