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.
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.
β Back to Ephemeral Preview URL Management and Routing