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.
- Wildcard DNS. A single
A/ALIASrecord for*.preview.example.compoints 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. - Host-header routing. The ingress controller inspects the HTTP
Hostheader and forwards to the matching backend service. Because all previews share one IP, the only thing distinguishing them is the hostname. A per-PRIngressresource mapspr-123.preview.example.comto that PR’s service. - Wildcard TLS. One certificate for
*.preview.example.comterminates 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.
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 || trueVerification: 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 itVerification: 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:
- Upstream — provisioning. Automated preview deployments on pull requests create the namespace and service that this ingress points at.
- Sibling — secrets. The preview app still needs credentials; secrets injection for preview environments delivers them at boot without hardcoding.
- Sibling — parity. Environment parity validation gates ensure the routed preview behaves like production.
- Downstream — cost. Every routed preview consumes compute; automated teardown and compute cost tracking keep the bill bounded.
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.
Related
- Generating Per-Branch Preview URLs with Wildcard DNS — the complete hostname-generation and ingress workflow.
- Automating Preview Environment Teardown to Control Costs — reclaiming routing and compute on PR close and idle.
- Automated Preview Deployments on Pull Requests — the provisioning step this routing sits on top of.
- Secrets Injection for Preview Environments — delivering credentials to the routed preview.