Development

Preview Environments: Setup, Teardown and Cost Control

Antonio Evans9 min read

A gray abstract workflow scene: a pull request icon on the left, an arrow to a small cluster of labeled boxes (namespace, database, ingress), then an arrow to a trash can for teardown.
Key points

Key points

  1. 1A preview environment is a temporary copy of the app, created when a pull request opens and deleted when it merges or closes.
  2. 2Deterministic naming (namespace, hostname, and tags all derived from the PR number) is what makes both discovery and cost attribution possible.
  3. 3Cleanup needs two layers: a webhook handler for normal PR close events and a daily TTL job for orphans that never fired a webhook.
  4. 4Cost control comes from three levers applied together: per-environment resource requests and limits, scale-to-zero on idle, and labels that roll spend up per PR.

Design the environment before you write the pipeline

The most common failure with per-PR environments is not the CI job. It is the absence of a naming contract. Before any YAML is written, decide three things: what the environment is called, where it lives, and how it is found again when it must be destroyed.

The simplest contract that survives real teams is to derive everything from the pull request number. The common pattern in Kubernetes setups is one namespace per pull request, with the namespace name matching the pull request so that two developers working on "my-billing-feature" and "fix-queue-size" get two isolated namespaces with the same names the Octopus Argo CD walkthrough. That single decision gives you a resource group you can delete in one call, a label you can filter on, and a name that is stable if the PR branch is force-pushed.

For the URL there are two mainstream options. A path-based scheme (example.com/pr-123) is easy because it needs no wildcard DNS or per-preview certificates, but it only works if the application tolerates running under a different root context. A host-based scheme (pr-123.preview.example.com) is more robust but requires a wildcard DNS record and a wildcard certificate pointing at the cluster holding all preview namespaces the Kubernetes namespaces guide. Pick host-based unless you have a specific reason not to: path-based environments break quietly when a frontend hardcodes absolute asset paths, and that failure looks like an application bug rather than an infrastructure choice.

Whatever you choose, write it down once. The CI job that creates the environment and the handler that deletes it must compute the same name from the same inputs, or you will build an orphan factory.

The goal of the whole exercise is the property the twelve-factor methodology calls dev/prod parity: keep development, staging and production as similar as possible so that code that worked in testing does not fail on contact with production 12factor.net. A preview environment per PR is the most aggressive version of parity available, because it tests the change against the same backing service types that production runs, not a local approximation. The methodology explicitly warns that using SQLite locally and PostgreSQL in production produces "tiny incompatibilities" that only surface later 12factor.net.

The CI job that provisions the environment

The trigger is pull_request with the types opened, synchronize and reopened. Use synchronize so that new commits redeploy the same environment rather than leaving reviewers looking at stale code. A minimal GitHub Actions job that does the provisioning through a script looks like this:

name: preview
on:
  pull_request:
    types: [opened, synchronize, reopened]

concurrency:
  group: preview-${{ github.event.pull_request.number }}
  cancel-in-progress: true

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Provision preview
        env:
          PR: ${{ github.event.pull_request.number }}
          SHA: ${{ github.event.pull_request.head.sha }}
        run: ./scripts/preview-up.sh "$PR" "$SHA"
      - name: Comment the URL
        run: ./scripts/preview-comment.sh "$PR"

Two details matter more than they look. First, concurrency with cancel-in-progress prevents two pushes in quick succession from racing to create the same namespace. Second, the environment embeds the commit SHA as an annotation, so when you inspect a preview you know exactly what it is running.

The job should be idempotent. Running it twice must not create a second database or a second namespace. The easiest way to get this is to express the environment declaratively (a manifest set, a Helm chart, or a compose file) and apply it repeatedly rather than scripting imperative create calls. Platforms that host previews for you follow the same model: you define the stack once and every PR gets a full environment, isolated in its own namespace, exposed at a shareable link the Bunnyshell tutorial. Even if you build your own, copy that shape.

If you already run GitOps, the pull request generator in Argo CD exists precisely for this case: it monitors the source repository for PRs and creates an Application per PR, without anyone committing a folder of manifests per branch the Argo CD guide. The tradeoff is that the application definition lives in the cluster, so the CI job shrinks to "annotate the commit" and the reconciliation loop does the rest.

A worked example makes the timing concrete. Suppose your e-commerce app has a storefront, an API, a Postgres database and a Redis cache. A naive full-stack preview costs 15 minutes to deploy, as the selective isolation argument notes for heavy backends the Signadot guide. With 40 open PRs at any time and that deployment time, you are paying for 10 environment-hours of build and deploy work per hour of wall clock, before anything is tested. That number, not the container CPU, is what makes "preview every PR" feel expensive.

Database seeding that stays cheap

A preview without data proves almost nothing. A checkout flow needs products, a cart, a user and an order. The review does not need production's 40 million orders, though, and this is where cost control and quality meet.

There are three viable seeding strategies. The first is a small fixture set: a few hundred rows that exercise the main paths, loaded from SQL or your framework's seed command. It is fast, deterministic and cheap, and it is the right default. The second is a production snapshot trimmed down to a relevant subset, which catches more edge cases but costs storage and transfer per environment. The third is schema-only with data created by the reviewer or by the agent, which is cheapest but shifts work onto the person testing.

StrategyProvision timeCost per environmentCatches whatUse when
Fixed fixture setSecondsLowMain flows, known edge casesDefault for feature PRs
Trimmed production snapshotMinutesMedium to highReal-world data shapes and sizesMigrations, reporting, search
Schema onlySecondsLowestStructure onlyEarly scaffolding, infra PRs
Shared preview databaseInstantNear zeroLittle or nothingNever: shared state hides bugs

The last row is the trap. Sharing one database between previews means two PRs that both run migrations will collide, and a reviewer who deletes a row breaks another branch's test. The developer-friendly walkthrough is explicit that sharing dependencies with production is riskier than the benefit, and that each preview should get its own database or at least a logical one the preview environments guide.

Keep the same database engine as production. The twelve-factor rule is that all deploys, developer environments included, should use the same type and version of each backing service 12factor.net. So a Postgres preview should run Postgres, not SQLite, and the seeding script should be the same script developers run locally.

Resource requests, limits and the idle problem

Every preview environment reserves capacity even when nobody is looking at it. That is the entire cost story in one sentence. A pod with a 500 MB memory request holds that reservation idle if the process inside only uses 300 MB groundcover on idle resources. Multiply by 40 previews and the waste is the budget.

The fix is to set requests low and limits just above observed peak. Requests are what the scheduler reserves; limits are what the container is allowed to burst to. For a preview you want the reservation to reflect the light traffic of one reviewer, not production load. A starting point that holds for most web apps:

resources:
  requests:
    cpu: 100m
    memory: 256Mi
  limits:
    cpu: 500m
    memory: 512Mi

Then scale idle deployments to zero. Any workload that can cold-start in a few seconds should be allowed to do so, which means a reviewer's first page load takes longer and every subsequent one is normal. The alternative, keeping 40 environments warm overnight and on weekends, is the line item that makes finance ask questions.

The Azure Well-Architected cost guidance makes the general principle explicit: environments should be tailored to purpose and cost-optimized individually, and you should treat nonproduction differently from production while still emulating it Microsoft's environment cost guide. Preview environments are the clearest case, because their value is highest in the hours after a PR is opened and near zero a week later if the PR is still open and untouched.

Tag everything. Every resource created for a preview should carry the PR number, the repository and the branch as labels, and the namespace name should match as well the preview environments guide. With those labels you can answer "what is this PR costing" from the billing export without guessing, and you can enforce a hard cap by deleting the previews of a repository that exceed a threshold.

Teardown: webhooks plus a TTL job

Two mechanisms, not one. The webhook handler is your primary path: it listens for pull_request closed events and deletes the namespace and any associated database and DNS records. GitHub's API sends closed whether the PR was merged or abandoned, so one handler covers both cases. Platforms in this space describe the same behavior, auto-destroy on merge or PR close, with a TTL as a backstop the Bunnyshell tutorial.

Webhooks fail. Secrets rotate, the runner is down, the event is delivered once and dropped, or the PR is closed by a script that does not emit the event. So the second mechanism is a scheduled job, running once a day, that lists every preview and deletes any environment whose age exceeds the TTL. A simple policy table keeps this honest:

Preview stateAgeAction
No open PRAnyDelete immediately
Open PR, last commit todayAnyKeep
Open PR, no commits or traffic for 7 days7 daysScale to zero, keep namespace
Open PR, no commits or traffic for 14 days14 daysDelete and comment on the PR
Unknown origin30 daysDelete and alert

The two-tier rule at 7 and 14 days is the detail most setups miss. Scaling to zero preserves the URL and the seeded data, so a reviewer who returns does not wait for a redeploy. Deleting at 14 days keeps the cluster from accumulating abandoned environments while giving the PR author time to finish. Comment on the PR before deleting so nobody is surprised.

AWS Amplify's preview feature shows why quotas matter alongside TTLs: each PR counts toward a limit of 50 branches per app, and the documentation's advice is to close PRs to avoid exceeding it the Amplify preview docs. Whether you build or buy, find out the platform's per-project limit before a busy week hits it, and enforce that number in your own cleanup job.

Should you build or buy?

The build path is roughly 200 lines of shell plus a CI job plus a cron job. It works, it is transparent, and it fits teams already fluent in Kubernetes. The buy path is a managed platform that wires Git events to environment creation and teardown, typically with database forking and per-PR URLs the Northflank platform comparison. The comparison comes down to who debugs the cleanup job at the wrong moment.

CriterionBuild it yourselfManaged preview platform
Setup effortCI job, scripts, DNS, cleanup cronConnect repo, define stack, toggle
Stack coverageWhatever you encodeFull stack if the platform supports jobs and databases
Teardown reliabilityYour webhook plus your cronPlatform's responsibility
Cost visibilityLabels you add yourselfUsually built in per environment
Debugging a broken previewFull accessDepends on logs and shell access
Best fitStrong platform team, unusual stackSmall teams, standard stacks

The decision rule: if you cannot answer "who deletes the orphan" in one name, buy. A preview environment that is never torn down is worse than no preview environment, because it teaches the team that the infrastructure is unreliable.

The other option worth knowing about is selective isolation: copying only the changed services per PR and pointing them at shared real dependencies the Signadot guide. It cuts the bill dramatically for large service graphs and keeps the shared database problem at bay through routing rather than duplication. It is a bigger investment than a namespace-per-PR pattern and only pays back when a single PR touches dozens of services.

If your previews exist mostly so that reviewers and coding agents can exercise the change, and the bottleneck is standing up the underlying app, that is a place where describing the thing you need and having it built is a legitimate alternative to maintaining your own pipeline. Quanta works that way: you describe the site or app you need and a specialist builds it with you, which takes the platform-maintenance cost off your list if previews are a means to an end rather than the product.

FAQ

FAQ

How long should a preview environment live?

Default to the life of the pull request, with a backstop. Set a scale-to-zero threshold around 7 days of inactivity and a hard delete around 14. That covers weekend gaps and review cycles without letting abandoned PRs hold resources indefinitely.

Do I need a separate database per preview?

Yes, or at least a logically separate schema. Two PRs that both run a migration against one shared database will collide, and a reviewer who deletes a row will break another branch's tests. Seeding a fresh database from fixtures takes seconds and removes the whole class of interference bugs.

Why not just use staging?

Staging tests one change at a time and serializes the team behind a single environment, which is exactly the bottleneck previews remove. Staging is still the right place for the final integrated check after merge, and the two complement each other rather than compete the Great Big Guide comparison of staging and preview environments.

How do I stop previews from wrecking the cloud bill?

Three levers together: low CPU and memory requests per environment, scale-to-zero on idle, and labels that carry the PR number so spend can be attributed and capped. Add a TTL job so orphans cannot accumulate silently. The reservation, not the usage, is what usually drives the bill.

What happens if the teardown webhook never fires?

Nothing, until the daily TTL job runs and deletes the environment. That is why the TTL job exists. Alert on any preview older than the hard-delete threshold so a failing webhook shows up as an alert rather than as a line item three months later.

Sources

Sources

  1. Create Temporary Argo CD Preview Environments Based On Pull ...
  2. Unlimited Preview Environments with Kubernetes ...
  3. X. Dev/prod parity - The Twelve-Factor App
  4. Auto-Create Preview Environments for Every PR - Bunnyshell
  5. Preview Environments: What They Are and Platforms ...
  6. How to Set Up Preview Environments for Pull Requests
  7. Cluster Idle Resources: Causes, Costs and Optimization ...
  8. Architecture strategies for optimizing environment costs
  9. Web previews for pull requests - AWS Amplify Hosting
  10. 10 best preview environment platforms in 2026 (frontend ...
  11. What is a staging environment? (and how to set one up) | Blog - Northflank
About the author

About the author

Antonio Evans
Antonio Evans

Antonio Evans is the founder of Quanta Intelligence and a fractional CTO who builds HIPAA-ready AI systems for health and life-science companies. Before that he was technical co-founder of FriendsEAT for twenty years, CEO of Socialdraft, and a data engineer at IBM. Antonio lives with his dog Santino in Lisbon, Portugal.

All articles