Anyone who's used Vercel or Netlify has been spoiled by one feature: open a PR, get a unique preview link automatically. Click it and you see exactly what that branch renders. Reviewers don't pull the code or spin up anything locally — they glance at a link and know whether the change is right. Merge or close the PR, and the environment quietly disappears.
Then you go back to the self-hosted world — your own backend, internal services, a small team's homegrown tooling — and that experience is suddenly gone. Want to review a frontend change? Ask for a screenshot, or git fetch it and run it yourself.
I used to think Per-PR preview environments were Vercel-grade platform magic that self-hosting just couldn't reach. Then I actually took one apart. The conclusion: it's really just three things — a dynamic subdomain, a temporary reverse-proxy route, and automatic cleanup. Here's how the pieces fit.
1. Strip the magic: what a preview environment actually is
Peel off the "platform magic" wrapper and a Per-PR preview environment only has to answer three questions:
- Where does this PR's app run? Your CI already builds and deploys a container (or a port) for that branch. The preview doesn't need to spin up a second copy — reuse the artifact this deployment already produced.
-
How does it get its own entrypoint? Give it a dedicated subdomain, e.g.
pr-128.preview.example.com, and point a reverse proxy at the container above. - Who cleans up after the PR is gone? Once the PR merges or closes, something has to reclaim that route and subdomain — otherwise after a few months you've accumulated a pile of zombie environments.
Once you see those three points, you'll notice everything from my earlier posts applies directly: the dynamic subdomain rides on a wildcard certificate (one cert for *.preview.example.com covers every PR subdomain — no per-PR issuance), and the route is just a single reverse_proxy. The rest is wiring them into an automatic flow.
2. Dynamic subdomains: the wildcard cert is the prerequisite
Without a wildcard cert, issuing a fresh certificate for pr-128.preview.example.com on every PR will instantly hit Let's Encrypt's rate limit (50 certs per registered domain per week). Open a few dozen PRs and you've burned through it.
The right move is to issue one wildcard cert for *.preview.example.com (via DNS-01 validation — covered in the last post), and every pr-N.preview.example.com reuses it with zero extra issuance. The Caddy config looks like this:
*.preview.example.com {
tls {
dns cloudflare {env.CF_API_TOKEN}
}
# dynamic routing table: pr-128 -> 10.0.0.5:auto, written by your program
reverse_proxy {
to {http.reverse_proxy.upstream}
}
}
In a real setup that upstream isn't hardcoded — your deploy program writes a route dynamically the moment a PR deployment succeeds: pr-128.preview.example.com → the container IP:port this deploy produced.
3. Wire it to the "deploy succeeded" event
The most natural trigger for a preview environment isn't "PR opened" — it's "this PR's code deployed successfully." The reasoning is practical: when a PR is first opened the code might not even compile, so spinning up an environment is wasted. Once CI actually builds and deploys that branch, the artifact is already running — allocating a subdomain that points at it is nearly free.
So the full chain is:
PR pushed → CI builds → deploy succeeds (container is running)
│
▼
terminal hook: does this run belong to a PR?
├─ parse PR number from branch name (pr-128 / pull/128 / bare digits)
├─ is preview enabled for this project?
└─ find the container/port this deploy produced
│
▼
write a route pr-128.preview.example.com → container
record a preview-env row (PR number, subdomain, status=active)
There's an honest but important design tradeoff here: a run (one deployment) usually doesn't carry the PR number directly. The cleanest engineering move isn't to rip through the whole deploy pipeline stuffing in a PR field — it's to parse it from the branch name by convention: pr-128, pull/128, even a bare-digit branch 128, all parse best-effort; if it doesn't parse, silently skip, and never disrupt a normal deploy. Likewise, the upstream container reuses the project's existing outbound route on the target host rather than inventing a new one. Reuse existing information instead of fabricating data — that's the key to staying out of trouble.
4. Auto-cleanup: the real cure for zombie environments
Spinning up is easy; the hard part is tearing down. Manual cleanup will be forgotten, and a few weeks later your reverse proxy is full of dead routes from long-merged PRs.
The reliable approach is a periodic sweeper: walk all active preview environments on an interval and re-check the corresponding PR's status —
- PR merged / closed → delete the route, mark the env
reclaimed; - PR still open → leave it.
Make reclamation a reconciliation-style periodic check rather than relying on a precise "PR closed" event, because webhooks get dropped and arrive out of order, while periodic reconciliation is idempotent: miss it this round, catch it next round, never pile up. This is one of the most robust patterns in self-hosted land — prefer periodic reconciliation over betting on a one-shot event.
5. A few things that'll bite you
-
Subdomain depth:
*.preview.example.comonly covers one level. Don't usepr-128.app.preview.example.com(two levels) — the wildcard cert won't cover it. Flatten preview subdomains to one level. - Data isolation: previews run real containers. If one points at your production database, a reviewer clicking around in the preview could mutate prod data. Previews should hit a separate test DB, or be explicitly read-only. This matters more than the technical implementation.
- Don't let previews eat all your resources: one container per PR means memory blows up as PRs pile on. Add a cap (e.g. at most N concurrent previews) or a per-preview quota.
- Reclamation must be idempotent: confirm a route still exists before deleting it; don't abort the whole sweeper because "it was already deleted."
6. I eventually built this into a tool
None of the pieces is complex on its own, but you have to wire every link yourself: the wildcard cert, the dynamic route writes, PR-number parsing, the terminal hook, the cleanup sweeper — and you have to guarantee that "allocating a preview" can never drag down a normal deploy (any step failing has to degrade gracefully and skip silently). I packed all of this, alongside the CI builds, multi-host deploys, and Caddy auto-HTTPS from earlier posts, into my Go single-binary deploy tool Pipewright:
- A per-project preview toggle; once on, a successful PR deploy auto-allocates a
pr-<n>subdomain — no hand-written routes; - Fully best-effort: can't parse a PR number, no DNS configured, no container found → silently skip, never block the deploy;
- A preview-env panel showing which PRs have live previews, their subdomains, and status at a glance;
- A periodic sweeper that reclaims by real PR status, so merged/closed environments need no manual cleanup.
It's essentially the "dynamic subdomain + temporary reverse proxy + auto cleanup" from this post, productized to skip the hand-rolling. MIT-licensed, single binary, no runtime deps (frontend baked in via embed.FS, SQLite by default), aimed at individual devs / small teams self-hosting.
Repo: https://github.com/huangchengsir/pipewright
But even if you never touch it, the takeaway stands: Per-PR preview environments aren't out-of-reach platform magic for self-hosting — they're a wildcard cert + a dynamic reverse-proxy route + a periodic reclaimer. Wire those three together and your self-hosted setup gets the "one link per PR" experience too.
Issues and pushback welcome.
Top comments (0)