Preview environments deploy a feature branch alongside the main deployment. Traefik routes traffic based on a custom X-Branch header: requests with the header go to the branch deployment, requests without it go to the main deployment as usual.
preview labelblog-staging namespaceHeaders('X-Branch', '<branch-slug>') at priority 10 (higher than the main blog's catch-all at priority 1)preview label to the PRThe branch slug (used as the header value) is the branch name lowercased with special characters replaced by dashes. For example, kyle/fix-typo becomes kyle-fix-typo.
With curl:
curl -H "X-Branch: kyle-fix-typo" https://pai.pericak.com
With a browser (ModHeader Chrome extension):
X-Branch = kyle-fix-typohttps://pai.pericak.comRemove the preview label or close/merge the PR. ArgoCD deletes all branch resources automatically within ~30 seconds.
Traefik evaluates IngressRoute rules by priority. The branch IngressRoute has priority 10 and matches on both the hostname and the X-Branch header. The main blog IngressRoute has priority 1 and matches only on the hostname. When both match, the higher-priority branch route wins.
Request with X-Branch header:
pai.pericak.com + X-Branch: kyle-fix-typo
→ priority 10: blog-branch IngressRoute (matches)
→ routes to blog-kyle-fix-typo Service
Request without header:
pai.pericak.com
→ priority 10: blog-branch IngressRoute (no match, header missing)
→ priority 1: blog-staging IngressRoute (matches)
→ routes to blog Service (main)
Cloudflare Tunnel passes custom headers through unchanged, so X-Branch works end-to-end through the tunnel.
The ApplicationSet uses the pullRequest generator, which polls GitHub every 30 seconds for PRs matching the configured label filter. For each matching PR, it templates an ArgoCD Application with the branch name and commit SHA injected as Helm parameters.
Key files:
infra/ai-agents/argocd/blog-branches.yaml — the ApplicationSet definitioninfra/ai-agents/blog-branch/helm/ — the Helm chart for branch deploymentsEach branch deployment runs:
npm install and npm run build (~2-3 min)The preview label requirement ensures branches only deploy when explicitly requested, keeping resource usage bounded on the homelab cluster.
Any app with a K8s Service can use this pattern:
<app>-branch/helm/ chart with parameterized Deployment + Service + IngressRouteHeaders('X-Branch', '<slug>') matchingpreview-api)For apps that use path-prefix routing (like ArgoCD at /argocd), add the path to the match rule:
Host(`pai.pericak.com`) && PathPrefix(`/myapp`) && Headers(`X-Branch`, `<slug>`)