Generating Per-Branch Preview URLs with Wildcard DNS
You need every pull request to get its own stable, TLS-terminated URL like pr-123-fix-login.preview.example.com, generated automatically and guaranteed not to collide — this is the complete hostname-and-ingress workflow built on the wildcard routing described in ephemeral preview URL management and routing.
When to use this pattern
- You run per-PR preview environments on Kubernetes with an ingress controller.
- You want readable, branch-derived URLs without risking DNS-invalid or colliding hostnames.
- You have a wildcard DNS record and a wildcard TLS certificate for the preview domain.
Prerequisites
Complete working example
# .github/workflows/preview-url.yml
name: Preview URL
on:
pull_request:
types: [opened, synchronize]
jobs:
url:
runs-on: ubuntu-latest
permissions:
pull-requests: write
contents: read
steps:
- uses: actions/checkout@v4
- name: Compute collision-safe preview host
id: host
run: |
PR="${{ github.event.pull_request.number }}"
# Normalize the branch: lowercase, non-alnum → '-', collapse repeats, trim.
SLUG=$(echo "${{ github.head_ref }}" | tr '[:upper:]' '[:lower:]' \
| sed 's/[^a-z0-9-]/-/g; s/-\+/-/g; s/^-//; s/-$//')
# Reserve room for the "pr-<n>-" prefix so the whole label stays ≤ 63 chars.
PREFIX="pr-${PR}-"
MAXSLUG=$(( 63 - ${#PREFIX} ))
SLUG=$(echo "$SLUG" | cut -c1-"$MAXSLUG" | sed 's/-$//')
HOST="${PREFIX}${SLUG}.preview.example.com"
echo "host=$HOST" >> "$GITHUB_OUTPUT"
echo "Preview host: $HOST"
- name: Render and apply per-PR ingress
env:
HOST: ${{ steps.host.outputs.host }}
PR: ${{ github.event.pull_request.number }}
run: |
export KUBECONFIG=/dev/stdin
echo "${{ secrets.KUBE_CONFIG }}" | base64 -d > kubeconfig && export KUBECONFIG=kubeconfig
cat <<YAML | kubectl apply -f -
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: ["${HOST}"]
secretName: preview-tls
rules:
- host: "${HOST}"
http:
paths:
- path: /
pathType: Prefix
backend:
service: { name: app, port: { number: 80 } }
YAML
- name: Comment the preview URL on the PR
uses: actions/github-script@v7
with:
script: |
const host = "${{ steps.host.outputs.host }}";
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: `Preview ready: https://${host}`,
});Step-by-step walkthrough
Computing the host. The branch name is lowercased, every character outside [a-z0-9-] becomes a hyphen, repeated hyphens collapse, and leading/trailing hyphens are trimmed. The pr-<number>- prefix guarantees uniqueness; the slug is appended only for readability.
Respecting the 63-character DNS label limit. The leftmost DNS label cannot exceed 63 characters. The script reserves space for the prefix (MAXSLUG=$(( 63 - ${#PREFIX} ))) and truncates the slug to fit, then trims any trailing hyphen left by the cut. This is the step most naive implementations skip, producing invalid hostnames on long branch names.
Rendering the ingress. The host is substituted into an ingress that references the shared preview-tls secret (the copied wildcard certificate). Because DNS is a wildcard, no DNS API call is made — the ingress Host header is the only routing identity.
Commenting the URL. github-script posts the URL back to the PR so reviewers can open it directly. On synchronize the comment logic can be made idempotent by updating an existing comment instead of adding new ones.
Verification
# The generated host resolves via the wildcard and returns 200 over TLS
HOST="pr-123-fix-login.preview.example.com"
dig +short "$HOST" # → the ingress LB address (from the wildcard)
curl -sI "https://$HOST/" | head -1 # → HTTP/2 200
# The leftmost label is within the DNS limit
echo "$HOST" | cut -d. -f1 | awk '{ print length }' # → ≤ 63Expected: resolution succeeds via the wildcard, the URL serves over valid TLS, and the label length is at most 63.
Common pitfalls
- Forgetting the label-length clamp. A 70-character branch name yields an invalid hostname and a broken preview. Always reserve space for the prefix and truncate, as in the
MAXSLUGcalculation. - Trailing hyphen after truncation. Cutting mid-word can leave
pr-123-fix-.preview…, which is invalid. Thesed 's/-$//'after the cut removes it. - Assuming per-PR DNS is needed. Adding a DNS record per preview reintroduces rate limits and cleanup. The wildcard makes routing purely a matter of the ingress
Host, as covered in the parent guide.
Why the wildcard approach beats per-preview records
The alternative — creating a DNS record for each preview and deleting it at teardown — is the obvious first design and it fails in three specific ways that a wildcard avoids entirely.
The first is propagation. A newly created record is not immediately visible everywhere, and the runner that will test the preview may have cached a negative answer for that name seconds earlier. That produces a NXDOMAIN on the first attempt and success on a retry, which is indistinguishable from ordinary flakiness and gets treated as such for months. A wildcard record was created once, long ago, and is already cached everywhere.
The second is quota. DNS providers rate-limit record creation, and certificate authorities rate-limit issuance far more strictly. A busy repository opening twenty pull requests a day will hit a certificate issuance limit within a week if each preview requests its own certificate, at which point new previews simply have no working TLS and the failure is opaque. One wildcard certificate covers every preview and is renewed on the normal schedule.
The third is orphan cleanup. Every per-preview record is a resource that teardown must remove, and teardown fails occasionally. Orphaned DNS records accumulate, point at addresses that have been reassigned, and eventually resolve to something unexpected — which is a security problem as well as a tidiness one. A wildcard has nothing to orphan.
What you give up
Wildcards are not free of trade-offs. Because every name under the wildcard resolves, a request for a preview that does not exist reaches the ingress rather than failing at DNS, and the response depends on what the ingress does with an unmatched host. Configure a default backend that returns a clear 404 rather than whatever the first matching rule happens to be, or a mistyped preview name will silently serve another preview’s content.
Wildcard certificates also cover exactly one label, so a nested hostname such as api.pr-482.preview.example.com is not covered and fails TLS while resolving perfectly. Keeping preview hostnames to a single label under the wildcard avoids this, and is worth deciding before anyone builds a multi-service preview that wants nested names.
Finally, the certificate’s private key covers every preview. That is a broader blast radius than per-preview certificates, and it is the one genuine argument in the other direction. In practice it is an acceptable trade for most teams, because the alternative’s rate-limit failures are certain and the key-compromise scenario is not — but it is worth stating rather than discovering.
Confirming the wildcard is doing what you think
Two checks separate a working wildcard from one that appears to work.
The first is a request for a preview that does not exist. Because every name under the wildcard resolves, that request reaches the ingress rather than failing at DNS, and what happens next depends entirely on the default backend. Without one configured, a mistyped hostname can be served by whichever rule the controller matches first — which means one preview quietly serving another’s content, with no error anywhere. Curl a deliberately wrong hostname and confirm you get a clear 404.
The second is TLS on the exact hostname shape you use. A wildcard covers one label, so pr-482.preview.example.com is covered and api.pr-482.preview.example.com is not, and the second fails TLS while resolving perfectly — a failure that reads as a certificate problem rather than a naming one. If any part of the preview needs a nested hostname, either flatten it or issue a second wildcard for that level before the first person hits it.
Related
- Ephemeral Preview URL Management and Routing — the parent guide with the full routing architecture.
- Automating Preview Environment Teardown to Control Costs — reclaiming the ingress and namespace on PR close.
- Automated Preview Deployments on Pull Requests — the provisioning workflow this URL step complements.
- Preview Environments & Environment Parity — the section overview.