One shared staging environment is a bottleneck. Give every pull request its own.



Most teams test on one shared staging environment, and most teams quietly accept the queue, the drift, and the "it works on staging" arguments that come with it.

The cost is measurable. One developer-productivity study found that 69 per cent of developers lose eight or more hours a week to technical inefficiencies like waiting on environments and dealing with broken builds, which is a full working day every week (Atlassian and Wakefield Research, reported by Release, 2025). A shared staging environment spends much of its life either contended or out of date.

The alternative is to stop sharing. Spin up a fresh, isolated, production-like environment for every pull request, automatically, from the same manifests you already deploy with, and tear it down when the pull request closes. This post shows two ways to do that on Kubernetes, one with GitHub Actions and one with Argo CD, plus the parts everyone underestimates: data, and cost.

Why one shared staging environment becomes the bottleneck

A single staging environment is fine when you have two engineers. As the team grows it turns into a choke point. Developers queue for a slot. The environment drifts, because the last person's changes and test data linger into the next person's run. And because everyone shares it, QA ends up testing several merged features at once, so when something breaks you cannot easily tell which change caused it.

This is now widely described as the real bottleneck in the delivery pipeline, not the people and not the tooling that writes the code (Bunnyshell, 2025). For a hundred-person engineering team, even a conservative eight lost hours per developer each week works out to roughly a 20 per cent productivity loss (The New Stack, 2025). And the always-on environment itself is rarely free: the same analysis notes that 91 per cent of organisations report wasted cloud spend, much of it sitting in environments nobody is watching closely (The New Stack, 2025).

The trade-off is honest. One shared environment is simpler to run and perfectly adequate at small scale. It only becomes a tax once you have enough engineers and enough services that the queue and the drift start to bite, which is exactly the point most growing teams are at.

A developer-productivity study put the cost of working around environments and broken builds at eight or more hours a week, for 69 per cent of developers (Release, 2025). A preview environment per pull request is staging without the queue.

A preview environment per pull request, with GitHub Actions

The most direct path uses the CI you already run. On every pull request, create a namespace named after the PR, deploy the chart into it with a PR-specific image tag and hostname, and on close, delete the namespace. Building on your existing pipeline rather than a new platform is usually the fastest route to adoption (Signadot, 2025).

# .github/workflows/preview.yml
on:
  pull_request:
    types: [opened, synchronize, reopened, closed]

jobs:
  deploy-preview:
    if: github.event.action != 'closed'
    runs-on: ubuntu-latest
    env:
      NS: preview-pr-${{ github.event.number }}
    steps:
      - uses: actions/checkout@v4
      - name: Deploy this PR into its own namespace
        run: |
          kubectl create namespace "$NS" --dry-run=client -o yaml | kubectl apply -f -
          helm upgrade --install "web-$NS" ./charts/web \
            --namespace "$NS" \
            --set image.tag=pr-${{ github.event.number }} \
            --set ingress.host=$NS.preview.example.com

  teardown-preview:
    if: github.event.action == 'closed'
    runs-on: ubuntu-latest
    steps:
      - name: Delete the preview namespace
        run: kubectl delete namespace "preview-pr-${{ github.event.number }}" --ignore-not-found

The workflow keys off the pull request lifecycle. While the PR is open or updated, the first job runs and deploys into a namespace unique to that PR, so two open pull requests get two isolated environments and never collide. When the PR is closed or merged, the second job deletes the namespace and everything in it. The reviewer gets a real URL to click, which turns code review from reading a diff into using the change (Signadot, 2025).

The trade-off is ownership. You now maintain this workflow, the cluster permissions it needs, and the ingress and DNS wildcard behind it. It is simple to start and genuinely fragile to run at scale, because the lifecycle logic lives in scripts you have to keep correct as edge cases appear.

The GitOps version: Argo CD's pull request generator

If you already run Argo CD, you do not need to script any of that. The ApplicationSet pull request generator watches your Git provider for open pull requests and creates one Application per PR, then deletes it automatically when the PR closes (OneUptime, 2026).

apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata:
  name: preview-environments
  namespace: argocd
spec:
  generators:
    - pullRequest:
        github:
          owner: your-org
          repo: web
        tokenRef:
          secretName: github-token
          key: token
        labels:
          - preview
        requeueAfterSeconds: 60
  template:
    metadata:
      name: preview-pr-{{number}}
    spec:
      project: previews
      source:
        repoURL: https://github.com/your-org/web.git
        targetRevision: "{{head_sha}}"
        path: charts/web
        helm:
          parameters:
            - name: image.tag
              value: pr-{{number}}
            - name: ingress.host
              value: pr-{{number}}.preview.example.com
      destination:
        server: https://kubernetes.default.svc
        namespace: preview-pr-{{number}}
      syncPolicy:
        automated:
          prune: true
          selfHeal: true
        syncOptions:
          - CreateNamespace=true

The generator queries the GitHub API for open pull requests carrying the preview label, and for each one produces a set of values: the PR number, the head commit, and so on. The template turns those into an Application that deploys the chart at that exact commit into its own preview-pr-{{number}} namespace, with a per-PR image tag and hostname. The same generator works with GitLab, Gitea, and Bitbucket too (OneUptime, 2026), and the benefits of GitOps, an auditable trail and easy diffing between environments, now extend to your previews (Codefresh, 2024).

The trade-off is the dependency. This only helps if you are already invested in Argo CD, and the generator watches your application repository, so PRs that only touch the manifest repository need a slightly different setup. In return you delete a pile of custom scripting and let the controller own the lifecycle.

The hard part is data, not deployment

Spinning up a stateless service per pull request is the easy 80 per cent. The database is the other 80 per cent. A preview environment is only useful if it has data to exercise, and a stateful dependency is a genuinely harder problem than a stateless service change, one you have to design for rather than assume (Signadot, 2025). The cleanest option for many teams is an ephemeral database scoped to the namespace, seeded with a known fixture set.

# values-preview.yaml, layered on top of the base chart
postgres:
  enabled: true        # an ephemeral database, scoped to this namespace
  persistence:
    enabled: false     # no volume, so the data dies with the namespace

seed:
  enabled: true        # run a one-off Job to load a known fixture set
  image: ghcr.io/your-org/web-seed:latest

Here the preview values turn on a database that lives inside the PR's namespace, with persistence off so it is thrown away with everything else, and a seed job that loads a fixed dataset. Every preview then starts from the same clean, known state, which is exactly what a shared staging environment can never promise. The two alternatives are a shared database with a schema or row set per PR, which is cheaper but no longer truly isolated, or a sanitised snapshot of production data, which is realistic but slow and risky to copy.

The trade-off is speed against fidelity. An ephemeral, seeded database is isolated and reproducible but slower to start and lighter than real data. A shared database is fast but reintroduces the contention you were trying to escape. Pick per service, and do not let the database quietly become the new shared bottleneck.

Make them disappear, or they will bankrupt you

Creating preview environments is the easy part. Destroying them reliably is what separates a cost saving from a runaway cloud bill. Lifecycle managed only by hand-written scripts and the occasional CronJob to sweep stale namespaces is fragile once you are running many of them (Qovery, 2026). The teardown on close is your main mechanism. A time-to-live is your safety net for when it does not fire.

# A safety net on the namespace, in case cleanup is ever missed
syncPolicy:
  managedNamespaceMetadata:
    annotations:
      janitor/ttl: "72h"   # auto-expire the namespace after 72 hours

It is also worth alerting before the environments quietly multiply, so a forgotten one becomes a notification rather than a line item you find at the end of the quarter.

groups:
  - name: preview
    rules:
      - alert: TooManyPreviewEnvironments
        expr: count(kube_namespace_labels{label_preview="true"}) > 20
        annotations:
          summary: "More than 20 preview environments are active"

This matters because untracked environments are where money leaks. One company found a single forgotten test database quietly costing over fifteen thousand dollars a year, and around 70 per cent of CTOs admit they do not track environment maintenance costs well (Release, 2025). A time-to-live and an alert turn that hidden cost into a managed one.

A single forgotten test database was found quietly costing over fifteen thousand dollars a year, and roughly 70 per cent of CTOs do not track environment costs well (Release, 2025). The teardown is not optional. It is the half of this that keeps it cheap.

The part worth sitting with

So look again at the one staging environment your team shares, and the queue in front of it. Every engineer waiting for a slot, every "it works on staging" that really means it worked an hour ago before someone else changed it, every bug that slips through because QA tested five merged features at once. That is the tax of a shared environment, and a study put it at a full working day a week per developer. A preview environment per pull request does not remove the testing work, it moves it earlier, to where it is cheap, and gives every change its own clean room to be proven in. The teams still sharing one staging environment are not simpler. They are paying for the queue, the drift, and the late bugs, and they are paying daily. Give every pull request its own environment, and make it disappear when the pull request does.

Author note

I am Mohan Gopi, an Associate DevOps Engineer at Frigga Cloud Labs, working across AWS, GCP, and Azure with GitHub Actions as my deployment backbone. I wrote this because the shared staging environment is the bottleneck teams complain about constantly and rarely fix. The pattern I keep seeing is a single staging cluster, a queue of engineers waiting to test, and a steady stream of works-on-staging bugs because the environment never matches anyone's change cleanly. A preview environment per pull request is not a luxury. It is what lets a team test in parallel and trust the result. The trick is not creating them. It is remembering to destroy them. Let us connect on LinkedIn → Mohan Gopi.

Post a Comment

Previous Post Next Post