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.


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.


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.


← Back to Preview Environments & Environment Parity