← Back to blog

Branch-based environments — how we stopped sharing staging

Rico Twesten-Weber Principal DevOps Engineer
gitopsplatform-engineeringazure-devopshelm

Our staging environment was broken more often than it worked. Not broken in the “something crashed” sense. Broken in the “three teams deployed conflicting feature branches on the same Tuesday and nobody could tell whose changes caused the login page to return a 500” sense.

We had a Slack channel called #staging-status. Its only purpose was for people to announce “I’m deploying to staging, please don’t touch it for the next hour.” That channel had more traffic than our actual product channels.

The problem nobody wants to name

Shared staging environments are a lie we tell ourselves. The premise sounds reasonable: maintain one environment that mirrors production, let teams deploy there to test, promote to prod when it works. In practice, it’s a bottleneck disguised as a best practice.

Here’s what actually happens. Team A deploys their feature branch. It needs a database migration. Team B deploys their feature branch thirty minutes later. It needs a different database migration. Now staging has both half-applied migrations, and neither team’s feature works. Both teams spend two hours debugging before someone checks the deployment history and realizes what happened.

This isn’t a coordination problem. It’s an architecture problem. You can’t solve it with better communication or deployment schedules. I’ve tried. We had a shared calendar for staging slots at one point. It lasted about two weeks before people started ignoring it.

How branch-based environments actually work

The concept is simple: every pull request gets its own isolated environment. Push a branch, get an environment. Merge or close the PR, the environment disappears.

Our implementation runs on Azure DevOps. When a developer pushes to a feature branch, a pipeline triggers that does four things. It builds the container image and tags it with the branch name. It generates a Helm values overlay specific to that branch. It deploys the Helm chart to a dedicated namespace in our Kubernetes cluster. And it posts the environment URL as a comment on the PR.

The namespace naming follows a pattern: feature-{sanitized-branch-name}. A branch called feature/user-auth-redesign gets namespace feature-user-auth-redesign. DNS is handled by a wildcard record: *.preview.ourapp.dev points to the cluster’s ingress controller, and each environment gets a subdomain matching its namespace.

Teardown is just as automated. When a PR merges or closes, a pipeline deletes the namespace. Kubernetes garbage collection handles the rest.

The Helm overlay pattern

This is the part that made it all work cleanly. We have a base Helm chart with sensible defaults for all services. Production uses values-prod.yaml with production-grade resource limits, real database connection strings, and full replica counts.

Each branch environment uses a generated values-feature-xyz.yaml that overrides only what it needs to. Resource requests drop to minimums since these environments don’t need to handle real traffic. The database points to a shared dev instance with a branch-specific schema prefix. Replica counts drop to one. External service integrations point to sandbox endpoints.

The key insight: branch environments don’t need to mirror production exactly. They need to be functionally correct with minimal resources. A feature environment running with 128Mi memory limits and a single replica is fine for testing whether the login flow works. You don’t need three replicas to verify a CSS change.

The pipeline generates this overlay dynamically. A template file with placeholders gets processed during the build, substituting the branch name, image tag, and namespace. It’s about 40 lines of YAML templating, not a framework.

What changed when we shipped this

The #staging-status channel went silent within a week. Not because we archived it, but because nobody needed it anymore.

Developers stopped asking “is staging free?” They stopped coordinating deployment windows. They pushed their branch, got a URL, tested their feature in isolation, and merged when it worked. Code review improved because reviewers could click a link and see the actual running feature instead of reading the diff and imagining what it might look like.

QA changed too. Our testers could verify multiple features in parallel, each in its own environment. No more “I need to test feature A but feature B is deployed and it’s interfering.” Every PR was independently testable from the moment the pipeline finished.

Onboarding got easier. New developers didn’t need to learn the staging deployment ritual. They just pushed code and got an environment. The platform did the rest.

The trade-offs are real

I’d be dishonest if I pretended this was free. There are real costs, both financial and operational.

Resource consumption goes up. Each branch environment runs its own set of pods. With ten active PRs, you have ten parallel deployments. We mitigated this with aggressive resource limits on preview environments and by using spot/preemptible nodes for the preview namespace pool. Our monthly cost increase was around 15%, which was less than the productivity cost of the old staging bottleneck. But you need to budget for it.

DNS and routing complexity increases. Wildcard certificates, ingress rules per namespace, cleanup of DNS records. We automated all of it, but it was a week of work upfront to get the plumbing right. TLS was the most annoying part since we used cert-manager with Let’s Encrypt, and hitting rate limits during a busy sprint with 20+ active branches was a real problem until we switched to a wildcard cert.

Abandoned branches are a cleanup headache. A developer opens a PR, gets distracted, and the branch sits there consuming resources for weeks. We added a TTL: if a branch environment hasn’t received a push in 72 hours, a scheduled pipeline tears it down. The developer can re-trigger it by pushing any commit.

Database state management is tricky. Each environment needs some baseline data to be useful. We solved this with a seed job that runs as a Helm post-install hook, populating the branch-specific schema with test data. It’s not perfect, and tests that depend on specific data states still occasionally break.

If your staging has a waiting list

Here’s what I’d tell any team still running shared staging: the coordination tax you’re paying is invisible because you’ve normalized it. You don’t notice the 30-minute wait for staging to be free. You don’t count the hours lost to debugging conflicts between branches. You’ve accepted “staging is broken” as a weather condition rather than a fixable problem.

Branch-based environments aren’t complicated. The hardest part is the initial pipeline and Helm overlay setup, and that’s a one-time cost. After that, every developer gets their own isolated world, and the arguments about who broke staging just stop.

If your staging environment has a waiting list, you don’t need a bigger staging environment. You need one per branch.

Rico Twesten-Weber

Principal DevOps Engineer. I build platforms that run themselves, and write about DevOps and AI.

Explore

Connect

© 2026 Rico Twesten-Weber Impressum Datenschutz