DEV Community

huangchengsir
huangchengsir

Posted on

Per-PR preview environments for self-hosted apps aren't actually magic

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:

  1. 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.
  2. 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.
  3. 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}
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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.com only covers one level. Don't use pr-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)