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 }'   # β†’ ≀ 63

Expected: 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 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.

← Back to Ephemeral Preview URL Management and Routing