Skip to content

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.

StagingPreview environments
How manyOne, sometimes two or threeOne per open pull request
LifetimePermanentHours to weeks; gone after merge
What it runsThe main branch or a release candidateOne branch's changes on top of its base
Who uses itWhoever is testing the next releaseThe author and reviewers of that pull request
DataOften a large, realistic copySeeded, 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):

  1. 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.
  2. 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.
  3. Review. People click through it, run end-to-end tests against it and comment on the pull request.
  4. 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

ApproachBest forWhat you build and maintainReady inDataPull requests from forks
Hosting previews (Vercel, Netlify, Render)Frontends, serverless, apps already on that hostA connected projectOne buildShared, or empty copiesHeld for a team member's approval by default on Vercel
Kubernetes namespace per PR (Argo CD, Flux, Okteto)Services already on KubernetesImage builds, templates, DNS, TLS, quotas, cleanupImage build plus rolloutSeeded or branchedUsually gated behind a maintainer's label
Infrastructure as code per branch (Terraform)Infrastructure changesModules, state per PR, cleanup jobsAs long as the slowest resourceWhatever you provisionShouldn't run at all: it needs cloud credentials
The app in one box (pyrovue)Reviewing UI and behaviour, open source, demosNothing for most repos, or a pyrovue.jsonAbout a minute for most reposPostgres, MySQL or Redis from a Compose file, inside the boxRuns 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:

  1. Don't give fork code your secrets, in the build or at runtime.
  2. 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.
  3. 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 plan on 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.

Run the repo you bookmarked and never tried.

Hobby is free and needs no card. Paste a link and your first box is up in about a minute.