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}`,
            });
How a Wildcard Hostname Reaches the Right Preview A wildcard DNS record resolves every preview hostname to the same ingress address. The ingress reads the Host header and routes to the service whose rule matches. Nothing needs a per-preview DNS record, which is what removes propagation delay from the critical path. pr-482.preview.example.com matched by *.preview one ingress address every preview resolves here Host header routing rule per preview, created with it pr-482 service No per-preview DNS record means no propagation wait — the hostname works the moment the ingress rule exists. The wildcard certificate is issued once and covers every preview, which also removes per-preview issuance limits.

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.

A Wildcard Covers Exactly One Label A certificate for star dot preview dot example dot com covers pr-482 dot preview dot example dot com but does not cover api dot pr-482 dot preview dot example dot com, because wildcards match a single label. Nested preview hostnames therefore fail TLS despite the DNS resolving correctly. CERTIFICATE: *.preview.example.com pr-482.preview.example.com covered — one label matched api.pr-482.preview.example.com not covered — TLS fails, DNS is fine Keep preview hostnames to a single label, or issue a second wildcard for the nested level.

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 }'   # → ≤ 63

Expected: resolution succeeds via the wildcard, the URL serves over valid TLS, and the label length is at most 63.

A Wildcard Preview Is Public Unless You Make It Otherwise An unguessable hostname is not access control: certificate transparency logs publish every issued name. Basic authentication is trivial to add and adequate for most teams. An identity-aware proxy ties access to the existing single sign-on and is the right answer where previews carry real data. unguessable hostname not access control — certificate transparency logs publish it basic authentication one ingress annotation adequate for most teams identity-aware proxy ties access to existing sign-on required where previews hold real data Whichever you choose, choose one: the default state of a wildcard preview is reachable by anyone who reads a public log.

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 MAXSLUG calculation.
  • Trailing hyphen after truncation. Cutting mid-word can leave pr-123-fix-.preview…, which is invalid. The sed '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.

← Back to Ephemeral Preview URL Management and Routing