Cloudflare Tunnel + Traefik: Remote Access Setup
Cloudflare Tunnel + Traefik: Remote Access Setup
Architecture
Internet
→ pai.pericak.com → Cloudflare Access (One-time PIN auth) → Cloudflare Tunnel → Traefik → service
→ wh.pericak.com → Cloudflare Tunnel (no Access) → Traefik → webhook handler
- Cloudflare Tunnel: outbound-only connection from the cluster to Cloudflare's edge. No inbound firewall rules needed. One tunnel (
pai-m1), two public hostnames. - Traefik: in-cluster reverse proxy. Routes by hostname + path prefix.
- Cloudflare Access (pai.pericak.com only): zero-trust auth at the Cloudflare edge. Auth method: One-time PIN (email-based — Google OAuth requires mandatory 2-step verification as of March 2026, which is unusable for family members).
- wh.pericak.com: open to internet — no Cloudflare Access. Webhook handlers must verify a shared secret.
Allowed users (pai.pericak.com): [email protected], [email protected], [email protected]
| Hostname | Access protection | Purpose |
|---|---|---|
pai.pericak.com |
pericak-family Allow policy (One-time PIN) |
ArgoCD UI, OpenObserve, and other authenticated services |
wh.pericak.com |
None — open to internet | Webhooks; application-level secret auth required |
Part 0: Zero Trust First-Time Setup
If this is the first time using Zero Trust on this account, Cloudflare will show an onboarding screen.
- Log in to dash.cloudflare.com
- In the left sidebar under Protect & Connect, click Zero Trust
- Click Get started
- Choose your team name — this becomes
<team>.cloudflareaccess.com(the auth portal URL). Enterpericak→ click Next - Select the Free plan → click Proceed
- You'll land on the Zero Trust dashboard
This onboarding only runs once.
Part 1: Create the Cloudflare Tunnel (Dashboard — Imperative)
1.1 Navigate to Networks → Tunnels
- In the Zero Trust sidebar, click Networks → Tunnels
- Click + Create a tunnel
1.2 Configure the tunnel
- Select Cloudflared as the connector type → click Next
- Name the tunnel:
pai-m1→ click Save tunnel - Cloudflare generates a tunnel token — the install command shown is:
The actual token is everything aftersudo cloudflared service install eyJ...install(just the JWT, starting witheyJ). - Store the token in
exports.shasCLOUDFLARE_TUNNEL_TOKEN(JWT only, no command prefix).
1.3 Configure public hostnames
Add two routes — both point to Traefik:
Route 1 — authenticated services:
| Field | Value |
|---|---|
| Subdomain | pai |
| Domain | pericak.com |
| Type | HTTP |
| URL | traefik.kube-system.svc.cluster.local:80 |
Route 2 — webhooks (no Access protection):
| Field | Value |
|---|---|
| Subdomain | wh |
| Domain | pericak.com |
| Type | HTTP |
| URL | traefik.kube-system.svc.cluster.local:80 |
Traefik handles hostname + path routing internally. The tunnel always points to Traefik for both hostnames.
Click Save hostname after each, then Done.
Part 2: Cloudflare Access — Auth Gate (Dashboard — Imperative)
2.1 Set up One-time PIN identity provider
One-time PIN is built-in to Cloudflare Access — no external IdP setup needed. It sends a numeric code to the user's email address. The email does not need to be a Gmail address.
- Zero Trust sidebar → Settings → Authentication
- Under Login methods, confirm One-time PIN is listed (it is enabled by default)
2.2 Create the Access application
- Zero Trust sidebar → Access controls → Applications
- Click Add an application
- Select Self-hosted
- Fill in:
- Application name:
pai - Application domain: subdomain
pai, domainpericak.com→ full domain:pai.pericak.com
- Application name:
- Click through Experience settings and Advanced settings with defaults
- Click Save
2.3 Create the Allow policy
- In the Zero Trust sidebar, go to Access controls → Policies
- Click Add a policy
- Policy name:
pericak-family - Action: Allow
- Under Include, add one rule with selector Emails and enter all three addresses:
[email protected][email protected][email protected](Type each and press Enter to add as a chip)
- Click Save
2.4 Assign the policy to the Access application
- Go to Access controls → Applications → click pai → Configure
- Click the Policies tab
- Click Select existing policies
- Check
pericak-family→ click Confirm - Click Save application
2.5 Webhook bypass — free plan limitation and workaround
The Cloudflare free plan does not support path-scoped Access policies. You cannot restrict a Bypass policy to /webhooks/* only — it would bypass auth for all traffic to the application.
Solution: Use a second tunnel hostname (wh.pericak.com) with no Access application. Webhook senders call wh.pericak.com; Traefik routes by hostname. No Cloudflare Access challenge is triggered for wh.pericak.com because there is no Access application protecting it.
Security: wh.pericak.com is open to the internet. Webhook handlers must verify a shared secret at the application level (e.g., verify X-Hub-Signature-256 for GitHub webhooks).
The webhooks-bypass policy (Action: Bypass, Everyone) exists as a reusable policy but is not assigned to any application — it's kept as a reference in case of a paid plan upgrade.
If on a paid plan and want to consolidate to a single hostname with path-scoped bypass:
- Access controls → Applications → pai → Configure → Policies
- Select existing policies → check
webhooks-bypass→ Confirm - Set path scope to
/webhooks/* - In the Policies tab, ensure Bypass is ordered above Allow
- Save application
Part 3: Traefik in K8s
K3s ships Traefik as a built-in component in the kube-system namespace. No separate Traefik deployment is needed — it is already running and already has the ingressroutes.traefik.io CRDs installed.
Key details:
- Service:
traefik.kube-system.svc.cluster.local:80 - Watches all namespaces for IngressRoute / Middleware resources
- Managed by K3s's HelmChart controller (do not manage via helmfile)
The Cloudflare tunnel points to traefik.kube-system.svc.cluster.local:80. IngressRoutes in any namespace are picked up automatically.
Part 4: Deploy cloudflared in K8s (IaC)
4.1 Store tunnel token in Vault
# After unlocking Vault and sourcing exports.sh:
source apps/blog/exports.sh
# Then run store-secrets.sh — it prompts for CLOUDFLARE_TUNNEL_TOKEN
infra/ai-agents/bin/store-secrets.sh
# Or pass as env var:
CLOUDFLARE_TUNNEL_TOKEN="$CLOUDFLARE_TUNNEL_TOKEN" infra/ai-agents/bin/store-secrets.sh
The token is the JWT from the Cloudflare dashboard (Zero Trust → Networks → Connectors → pai-m1). It starts with eyJ. Do not include the sudo cloudflared service install prefix.
4.2 Create K8s secret (bootstrap.sh)
bootstrap.sh reads the token from Vault and creates the cloudflared-token K8s secret automatically. Re-run bootstrap.sh after storing the token:
infra/ai-agents/bin/bootstrap.sh
4.3 ArgoCD deploys cloudflared
The infra/ai-agents/argocd/cloudflared.yaml ApplicationSet deploys cloudflared to clusters labeled cloudflare-tunnel=true. This label is applied by bootstrap.sh during cluster registration.
The local chart is at infra/ai-agents/cloudflared/helm/. It runs cloudflare/cloudflared:latest with 2 replicas, reading the tunnel token from the cloudflared-token K8s secret.
Part 5: Traefik IngressRoutes (IaC)
IngressRoutes are managed by ArgoCD via infra/ai-agents/argocd/traefik-routes.yaml, which deploys infra/ai-agents/traefik/argocd-ingress.yaml from the main branch.
The ArgoCD IngressRoute exposes ArgoCD at pai.pericak.com/argocd. ArgoCD is configured with server.rootpath: /argocd (set in infra/ai-agents/helmfile.yaml via configs.params.server\.rootpath) so it serves the UI correctly under the path prefix. Do not use a strip-prefix middleware — ArgoCD expects to receive the full /argocd path.
Traefik Middleware gotcha: If an IngressRoute references a Middleware CRD that doesn't exist in the same namespace, Traefik silently drops the entire route with no 404 or error logged. Always verify the Middleware resource exists before referencing it.
Adding new routes
Authenticated service (user-facing, requires login):
- Add an
IngressRoutematchingHost(pai.pericak.com) && PathPrefix(/your-path)in the target namespace - Protected automatically by the existing
pai.pericak.comAccess application (pericak-familyAllow policy)
Webhook endpoint (machine-to-machine, no Cloudflare Access):
- Add an
IngressRoutematchingHost(wh.pericak.com) && PathPrefix(/your-webhook)in the target namespace - No Cloudflare Access challenge — traffic reaches the pod directly
- Must verify a shared secret at the application level (e.g.,
X-Hub-Signature-256for GitHub)
Verification
# Tunnel should show as healthy in Cloudflare dashboard
# Zero Trust → Networks → Connectors → pai-m1 → status: Active
# Verify K3s Traefik is up (in kube-system, not a separate namespace)
kubectl get pods -n kube-system -l app.kubernetes.io/name=traefik
# Verify cloudflared is connected
kubectl get pods -n cloudflared
kubectl logs -n cloudflared deploy/cloudflared | grep -i "connection"
# Check IngressRoute is loaded
kubectl get ingressroute -n argocd
# Hit ArgoCD remotely — should 302 to Cloudflare Access login
curl -I https://pai.pericak.com/argocd
# ArgoCD admin password
kubectl -n argocd get secret argocd-initial-admin-secret \
-o jsonpath='{.data.password}' | base64 -d && echo