Ephemeral Preview URL Management and Routing

The operational pain this page solves is getting a unique, TLS-terminated URL in front of every pull request without drowning in DNS API calls, certificate provisioning, or subdomain collisions. Once a team runs more than a handful of concurrent preview environments, naive per-PR DNS and per-PR certificates become the slowest and most failure-prone part of the pipeline. This page covers the routing layer that makes hundreds of concurrent previews share one wildcard DNS record and one load balancer, each reachable at a stable, collision-free hostname.


Prerequisites


How Preview Routing Works Under the Hood

A preview URL is three independent concerns stacked together: name resolution (DNS), request routing (ingress), and encryption (TLS). The scalable design decouples all three from the per-PR lifecycle.

  1. Wildcard DNS. A single A/ALIAS record for *.preview.example.com points at the ingress load balancer. Every possible preview subdomain already resolves — creating a new preview requires zero DNS mutations, which removes the slowest and most rate-limited step entirely.
  2. Host-header routing. The ingress controller inspects the HTTP Host header and forwards to the matching backend service. Because all previews share one IP, the only thing distinguishing them is the hostname. A per-PR Ingress resource maps pr-123.preview.example.com to that PR’s service.
  3. Wildcard TLS. One certificate for *.preview.example.com terminates TLS for every preview. Copying that certificate into each namespace at provision time avoids a per-PR ACME challenge, which otherwise adds latency and a failure mode.

The hostname itself must be collision-safe and DNS-valid. Deriving it from the PR number guarantees uniqueness; appending a normalized branch slug adds human readability without risking the 63-character DNS label limit.

Wildcard DNS and Host-Header Preview Routing Multiple preview hostnames resolve via a single wildcard DNS record to one ingress load balancer. The ingress inspects the Host header and forwards each request to the matching per-PR service. A shared wildcard TLS certificate terminates encryption for all of them. pr-123.preview… pr-124.preview… pr-125.preview… Wildcard DNS *.preview → LB Ingress route by Host header wildcard TLS svc pr-123 svc pr-124 svc pr-125

Step-by-Step Implementation

Step 1 — Provision the wildcard DNS record once

# One-time setup, not per PR. Point the wildcard at the ingress LB address.
LB=$(kubectl get svc -n ingress-nginx ingress-nginx-controller \
  -o jsonpath='{.status.loadBalancer.ingress[0].hostname}')
echo "Create DNS: *.preview.example.com  CNAME  $LB"

Verification: dig +short pr-999.preview.example.com resolves to the load balancer even though no PR 999 exists — the wildcard covers every subdomain.

Step 2 — Generate a collision-safe hostname

# In CI: PR number guarantees uniqueness; slug adds readability, clamped to DNS limits.
PR="${PR_NUMBER}"
SLUG=$(echo "${GITHUB_HEAD_REF}" | tr '[:upper:]' '[:lower:]' \
  | sed 's/[^a-z0-9-]/-/g; s/-\+/-/g; s/^-//; s/-$//' | cut -c1-40)
PREVIEW_HOST="pr-${PR}-${SLUG}.preview.example.com"
echo "host=$PREVIEW_HOST" >> "$GITHUB_OUTPUT"

Verification: The hostname’s leftmost label is ≤ 63 characters and matches ^[a-z0-9-]+$. echo "$PREVIEW_HOST" | cut -d. -f1 | wc -c should print ≤ 64 (including the newline).

Step 3 — Create the per-PR ingress

# k8s/preview-ingress.yaml — rendered per PR from the host above
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: preview
  namespace: preview-${PR}
  annotations:
    nginx.ingress.kubernetes.io/ssl-redirect: "true"
spec:
  ingressClassName: nginx
  tls:
    - hosts: ["${PREVIEW_HOST}"]
      secretName: preview-tls        # the copied wildcard cert
  rules:
    - host: "${PREVIEW_HOST}"
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: app
                port: { number: 80 }

Verification: kubectl get ingress -n preview-${PR} shows the host and an assigned address within seconds.

Step 4 — Attach TLS from the shared wildcard certificate

# Copy the wildcard cert secret into the preview namespace — no per-PR ACME challenge.
kubectl get secret wildcard-preview-tls -n cert-manager -o yaml \
  | sed "s/namespace: cert-manager/namespace: preview-${PR}/; /resourceVersion:/d; /uid:/d" \
  | kubectl apply -f -
kubectl -n preview-${PR} get secret preview-tls >/dev/null 2>&1 \
  || kubectl -n preview-${PR} rename secret wildcard-preview-tls preview-tls 2>/dev/null || true

Verification: curl -sI https://${PREVIEW_HOST}/ | head -1 returns HTTP/2 200 with a valid certificate, immediately — no ACME wait.

Step 5 — Tear down on PR close

Routing resources must be reclaimed with the environment. Detailed teardown, including idle sweeps, is covered in automating preview environment teardown to control costs.

kubectl delete namespace preview-${PR} --ignore-not-found   # takes the ingress with it

Verification: After teardown, curl -sI https://${PREVIEW_HOST}/ returns a 404 from the ingress default backend, confirming the route is gone.


Three Ways to Address a Preview A path prefix needs no DNS or certificate work but shares cookies with production, which causes session bleed. A wildcard subdomain needs a wildcard certificate and isolates cookies per preview. A separate apex domain isolates everything but costs the most to set up and maintain. SCHEME · CERTIFICATE · COOKIE SCOPE example.com/preview/482 no new certificate — but shares production cookies, which bleeds sessions pr-482.preview.example.com one wildcard certificate — cookies isolated per preview pr-482.example-preview.dev complete isolation — a second domain to own and renew The middle row is the usual answer: one certificate, isolated cookies, and no second domain to maintain.

Configuration Reference

Option Type Default Effect
Wildcard DNS record DNS record Resolves every preview subdomain to the ingress; created once, not per PR
Hostname source enum PR number PR number guarantees uniqueness; branch slug is optional readability
DNS label length integer 63 Hard limit on the leftmost label; slugs must be clamped below it
secretName (ingress TLS) string The copied wildcard cert; avoids per-PR ACME
ssl-redirect boolean true Forces HTTPS on preview URLs
ingressClassName string nginx Selects the controller that performs host-header routing

Integration with Upstream and Downstream Topics

Routing is one layer of the preview environments & environment parity domain:


Performance and Cost Impact

Activity Naive per-PR DNS + ACME Wildcard approach Saving
DNS mutation per preview 1 API call (rate-limited) 0 Removes a rate-limit failure mode
TLS provisioning 30–120 s ACME challenge < 1 s (cert copy) 30–120 s per preview
Load balancers required often 1 per PR on some platforms 1 shared Large cost reduction at scale
Time-to-first-byte on new preview limited by cert issuance limited by pod readiness Routing no longer the bottleneck
Max concurrent previews bounded by DNS/cert quotas bounded by cluster capacity Scales to hundreds

The single biggest lever is the wildcard certificate: it turns per-PR TLS from a 30–120 second, occasionally-failing step into a sub-second file copy.


Sanitise Once, Then Pass the Result Around A branch named feature/ADD-Login is lowercased, has its slash replaced and is truncated to produce a hostname. Implementing that rule in both the deploy job and the test job invites them to diverge, so the test eventually targets a hostname the deploy never created. feature/ADD-Login sanitise once, in one place lowercase · slash to dash · truncate to 30 feature-add-login.preview… Emit the result as a job output and read it everywhere else. Two implementations of a truncation rule always drift. The failure appears as a test suite pointing at a hostname that resolves to nothing, on long branch names only.

Troubleshooting

Error: 404 Not Found from the ingress default backend

Cause: The Host header does not match any ingress rule — usually the hostname rendered in CI differs from the one in the Ingress resource (a slug normalization mismatch).

Fix: Render the hostname once and pass it as a variable to both the ingress template and the PR comment. Confirm with kubectl get ingress -n preview-${PR} -o jsonpath='{.spec.rules[0].host}'.

Error: net::ERR_CERT_COMMON_NAME_INVALID in the browser

Cause: The preview host is nested deeper than the wildcard covers. *.preview.example.com matches pr-123.preview.example.com but not pr-123.team.preview.example.com — wildcards match a single label only.

Fix: Keep previews one label deep, or issue a certificate for the deeper wildcard (*.*.preview.example.com is not valid; use a specific *.team.preview.example.com).

Symptom: DNS resolves but connection times out

Cause: The wildcard points at a load balancer address that has changed (LB recreated) or a security group blocks 443.

Fix: Re-read the LB address (Step 1) and update the wildcard record; confirm the ingress controller’s service has an external address and that 443 is open.

Symptom: Preview URL still resolves after PR close

Cause: The namespace was deleted but a DNS annotation or external-dns record persisted, or teardown never ran.

Fix: Rely on wildcard DNS (which needs no per-PR record) so there is nothing stale to clean. If using external-dns per host, ensure the teardown deletes the ingress so its record is garbage-collected. See the teardown guide.


Frequently Asked Questions

Should preview URLs be derived from the branch name or the PR number?

Base the routing identity on the PR number, which is guaranteed unique and always valid in a DNS label. Optionally append a normalized branch slug for human readability (pr-123-fix-login). Raw branch names are dangerous as hostnames: they can exceed the 63-character label limit, contain slashes or uppercase, or collide when two branches normalize to the same string. The PR number prefix removes all three risks.

How do I avoid per-PR TLS certificate provisioning latency?

Issue one wildcard certificate for *.preview.example.com and copy it into each preview namespace at provision time (Step 4). A per-PR ACME challenge adds 30–120 seconds and fails when pods are slow to start or the challenge path is not yet routable. A shared wildcard makes TLS a sub-second file copy with no external dependency at deploy time.

What prevents subdomain collisions across hundreds of concurrent previews?

Wildcard DNS plus host-header routing: every preview shares one DNS record and one load balancer, and the ingress differentiates them purely by the Host header. Because hostnames are PR-number-based, each is unique by construction. There is no shared mutable state to collide on — adding the five-hundredth preview is the same operation as adding the first.


Routing Decisions That Are Hard to Reverse

Three choices made when preview routing is first set up are difficult to change later, because everything downstream comes to depend on them.

The hostname shape. Whether previews live at pr-482.preview.example.com, example.com/preview/482, or a separate apex domain determines cookie scope, certificate strategy, and whether a preview can share a session with production. The path-prefix form looks simplest because it needs no DNS or certificate work, and it is the one teams most often regret: cookies set by production are sent to previews and vice versa, so a preview can read a production session and a preview login can overwrite one. Once dozens of links to preview URLs exist in pull-request comments and bookmarks, changing the shape means breaking all of them.

Whether previews are public. A wildcard hostname is not secret — every issued certificate is published to certificate transparency logs, so an unguessable name is discoverable within minutes. Deciding at the start that previews sit behind authentication is a one-line ingress annotation; retrofitting it after a year means auditing which previews were exposed and for how long, and answering questions about whether any carried real data.

How the environment is addressed internally. Tests, webhooks and third-party callbacks all need to reach the preview, and each may need a different address — a public hostname for the browser, an internal service name for a test runner in the same cluster, a publicly reachable URL for an inbound webhook. Deciding early that the deploy job emits all the addresses it created, rather than each consumer deriving one, avoids a class of failure where a consumer’s derivation drifts from what was actually provisioned.

Keeping routing state and reality in agreement

The routing layer accumulates state that nothing naturally cleans up: ingress rules, DNS records where they are used, certificates, and load balancer entries. Each is created by the provisioning job and, in theory, removed by the teardown job. In practice teardown fails occasionally — a transient API error, a job cancelled halfway, a pull request deleted rather than closed — and each failure leaves an orphan that no future event will ever fire for.

The remedy is a scheduled reconciliation rather than more careful teardown. Listing every preview-tagged resource, comparing it against the set of open pull requests, and removing anything with no corresponding pull request takes a few minutes to write and catches every orphan regardless of how it was created. It also surfaces the opposite problem — an open pull request whose environment no longer exists — which otherwise presents as a reviewer reporting a dead link.

Tagging is what makes the reconciliation possible. Every resource created for a preview should carry the pull request number as a tag or label at creation time, so the sweep can match on it rather than parsing names. Name parsing works until the first time a naming convention changes, at which point the sweep silently stops finding the older resources and they persist indefinitely.

Emitting Addresses Rather Than Deriving Them

Several consumers need to reach a preview — a reviewer’s browser, a test runner inside the same Kubernetes cluster, an inbound webhook from a third party — and each may need a different address for the same environment. The failure mode is every consumer deriving its own address from the branch name, because every derivation eventually disagrees with what was actually provisioned.

Branch names are sanitised before they become hostnames: lowercased, slashes replaced, and truncated to a length limit. Implement that rule twice and the two implementations will diverge on the first branch name long enough to be truncated, at which point the test suite targets a hostname that resolves to nothing while the deploy reports success.

Have the provisioning job emit every address it created as a job output, and have every consumer read it. One source of truth, no derivation, and the class of failure disappears.

← Back to Preview Environments & Environment Parity