Preview Environments for Every Pull Request: Ephemeral Kubernetes Deploys
By SeaGit
Every team eventually hits the same wall: a pull request looks fine in review, passes CI, merges — and breaks in a way nobody could see from a diff. A migration that only fails against real data. A CORS rule that only bites over HTTPS. An environment variable that exists on staging but not in the manifest. The fix is not more staging discipline; it is giving every pull request its own running copy of the app, on a real URL, before anyone approves it.
That is what a preview environment is: a complete, isolated deployment built from the exact commit on a branch, reachable at its own hostname, torn down when the branch is done. At SeaGit we run one per non-main deployment slot, and it has quietly become the most-used feature we ship.
Why ephemeral beats a shared staging
A single shared staging environment has a structural problem: it only ever reflects one branch at a time. Two engineers testing two features overwrite each other, the environment drifts from production as manual hotfixes accumulate, and "works on staging" stops meaning anything once staging is three merges behind main.
Ephemeral preview environments invert that. Each one is built fresh from a commit, so it is reproducible by definition — the same SHA always produces the same environment. There is no drift because nothing lives long enough to drift. And because each PR gets its own isolated URL, ten engineers can validate ten features in parallel without a scheduling conversation.
The key property is isolation without fidelity loss. A preview is not a mock or a scaled-down toy. On SeaGit it runs inside the same environment as the main deployment — the same cluster, the same networking, the same DNS zone and TLS issuance — as its own Helm release with its own subdomain, dep-name.your-domain.com. Real infrastructure, real certificate, zero traffic impact on production. That last point is what makes it a true dark release: you can point real integrations at it and nothing reaches your users.
What "on every pull request" actually requires
The phrase hides four hard problems, and skipping any one of them is why hand-rolled preview setups tend to rot:
- Build from the exact commit. The image must be built from the push event's SHA, not "latest on the branch." If a preview shows different code than the commit under review, the whole premise collapses. SeaGit deduplicates on the commit SHA: if a deployment already exists for an incoming SHA, the push re-triggers that slot instead of spawning a duplicate.
- A stable, unique URL. Each preview needs a deterministic hostname so reviewers, and automated checks, can find it. That means programmatic DNS and automatic TLS — a wildcard certificate issued through the DNS-01 challenge so
*.your-domain.comcovers every ephemeral slot without a per-preview certificate dance. - A cap, enforced. Unlimited previews are a cost and quota incident waiting to happen. Every environment carries a
max_deployments_per_instancelimit; once it is reached, a new push must reuse an existing ephemeral slot or wait for one to be removed. The main slot is never consumed by this. - Never disrupt production. CI/CD must target only non-main deployments. On SeaGit the main deployment carries an
is_mainflag and is deliberately excluded from webhook-triggered builds — the automation physically cannot overwrite the live slot. Users decide what gets promoted.
The promotion path
A preview is only half the story; the other half is what happens when it passes review. The anti-pattern is a second, manual deploy process for production that shares no lineage with what you just validated. If "ship to prod" runs different steps than "build the preview," you are testing one thing and shipping another.
We close that gap with two explicit actions. Promote copies a validated ephemeral deployment's configuration and triggers a fresh deploy into the main slot, keeping the main slot's stable hostname unchanged. Mark as main instead re-designates which slot is main, shifting the is_main flag so subsequent CI/CD leaves the newly promoted slot alone. Either way, the artifact and configuration that reach production are the ones a human already saw running at a real URL.
Cost, and the discipline it forces
The honest objection to preview-per-PR is cost: every open pull request is now running infrastructure. Two things keep this sane. First, the per-instance cap bounds the blast radius — you are never running more ephemeral slots than you decided to allow. Second, ephemeral deployments are genuinely ephemeral: tearing down the branch releases the Helm release, its ingress, and its DNS records, so idle cost trends to zero rather than accumulating.
There is a cultural payoff too. When every PR has a URL a reviewer can click, review shifts from reading diffs to using the change. Product managers validate flows without a local checkout. Designers check the real thing on a real device. Bugs that only appear against production-shaped infrastructure surface before merge, not after — which is the entire point.
Getting there incrementally
You do not need to adopt everything at once. Start by giving one service preview environments on a single environment, wire the build to trigger on push, and confirm the URL resolves with a valid certificate. Add the deployment cap before you widen it to the whole team, because an unbounded rollout is how preview environments earn a reputation for surprise bills. Then make promotion a one-click action, so the path from "validated preview" to "live" is short enough that people actually use it.
The goal is simple to state and surprisingly hard to reach by hand: every change, running on real infrastructure, at a URL, before it merges — and a straight line from there to production. Once a team has it, going back to a shared staging box feels like editing code with the lights off.