t1k:web:devops:selfhost-cloudflare
| Field | Value |
|---|---|
| Module | devops |
| Version | 1.10.0 |
| Effort | medium |
| Tools | — |
Keywords: access-whitelist, cloudflare access, cloudflare tunnel, cloudflared, deploy, docker compose, expose local app, github actions runner, permission-service, rbac sidebar, self-hosted, systemd runner, zero trust, zone sharing
How to invoke
Section titled “How to invoke”/t1k:web:devops:selfhost-cloudflare[runner|tunnel|access|all]Self-Hosted Deploy behind Cloudflare Tunnel + Access
Section titled “Self-Hosted Deploy behind Cloudflare Tunnel + Access”Take a Docker-Compose app from a local box to a public, owner-gated URL without a cloud VM:
push → systemd GitHub Actions runner → docker compose up -d --build → Cloudflare Tunnel → Cloudflare Access (Zero Trust) → app.
Every step here is field-proven on a single Arch/Linux host. It is the cheapest path to a real staging/preview URL that you fully control.
Handles: self-hosted GitHub Actions runner (systemd, label-targeted), push-to-deploy via a deploy workflow, cloudflared tunnel ingress + DNS, Cloudflare Access (Zero Trust) apps/policies via API, secure-cookie re-homing, optional permission-service grant mirror for sidebar RBAC, and the recurring gotchas.
Does NOT handle: cloud VM provisioning (GCP/AWS), Kubernetes/ARC pods (wrong tool for a single-host compose deploy — see Gotchas), app-level RBAC, TLS certs (Cloudflare terminates TLS at the edge). For Workers/R2/D1/serverless, use /t1k:web:devops:core. For one-shot platform deploys (Vercel/Netlify/Fly), use /t1k:web:devops:deploy.
Decision Tree
Section titled “Decision Tree”| Intent | Read |
|---|---|
| ”Set up push-to-deploy on my own machine” | references/selfhosted-runner.md |
| ”Expose my local app to the internet” / “add a Cloudflare Tunnel” | references/cloudflare-tunnel-dns.md |
| ”Gate the site so only I (or my team) can reach it” / “Zero Trust” | references/cloudflare-access.md |
| ”Whitelist someone into ONLY one page/zone of an iframe-SPA dashboard” | references/cloudflare-access.md § 5b (multi-destination app) |
| “Share a zone/page with a teammate” / “sync CF Access with permission-service” / “drive sidebar RBAC from zone grants” | references/permission-service-sync.md |
| ”It deployed but something broke” / 401s / push fails / dashboards vanished | references/gotchas.md |
| Full greenfield setup, all four | Read all four, in order above |
Prerequisites
Section titled “Prerequisites”- A host you control that runs the app via Docker Compose (a reverse proxy like Caddy/nginx fronting the app on one local port — e.g.
localhost:<proxy-port>). - A Cloudflare account with the target zone (domain) onboarded, plus Zero Trust enabled (free tier is fine).
cloudflaredinstalled on the host;ghCLI + repo admin to register a runner;docker+docker compose.- A GitHub repo for the app.
Workflow (greenfield “all”)
Section titled “Workflow (greenfield “all”)”- Runner + deploy — register a systemd self-hosted runner with a distinct label, add a
deploy.ymlthat runs on push to your deploy branch and calls yourmake deploy/docker composetarget. Stage a canonical.envfrom a stable host path into the runner checkout. Re-home cookies to the public host (secure cookies ON). →references/selfhosted-runner.md - Tunnel + DNS — add an ingress rule (
hostname → http://localhost:<proxy-port>) to a sharedcloudflaredconfig, route DNS (cloudflared tunnel route dns), reload thecloudflaredsystemd service. →references/cloudflare-tunnel-dns.md - Access (Zero Trust) — create a self-hosted Access application for the hostname, reuse existing IdPs (GitHub + Email OTP), attach an owner-only policy. Add per-page path-scoped apps later for non-owners. →
references/cloudflare-access.md - Verify — unauthenticated request → 302 to the Cloudflare Access login; authenticated → app renders; all app routes return 200 locally behind the proxy.
Security
Section titled “Security”- The tunnel means the app’s local port is never exposed directly — only Cloudflare’s edge reaches it. Keep the app bound to
localhost/the docker network, never0.0.0.0on a public interface. - No inbound ports need opening — the tunnel connects outbound-only. If a host firewall is active, leave inbound 80/443 closed.
- With secure cookies ON, auth works over the tunnel’s HTTPS only; do NOT also serve the app over plain
localhost:httpto real users (cookie won’t set). - Cloudflare Access is the effective gate when the app does not hard-force its own login. If the app does enforce login, Access stacks in front of it (defence in depth), it does not replace it.
- Treat the Access service-token / API token as a secret: store mode-600 on disk, never paste into chat or commit. Rotate if exposed.
Gotchas
Section titled “Gotchas”Full list with fixes: references/gotchas.md. The six that bite hardest:
- Stale global git
extraheaderin~/.gitconfig401s both your push AND the runner’sactions/checkout(Duplicate header: Authorization). Remove it. docker compose --remove-orphansreaps every separately-managed stack sharing the host (dashboards, sidecar containers). Drop the flag, or fold all compose files into one project.- GitHub deploy-concurrency supersedes queued runs — rapid pushes cancel each other’s pending deploy. To apply a fix fast, rebuild the affected containers manually; the committed code still deploys on the next push.
- Kubernetes/ARC is the wrong tool for a single-host compose deploy (ephemeral pods, dind isolation, shared-pool security). Use a plain systemd runner.
- Secure cookies need the public host — re-home
COOKIE_DOMAIN/COOKIE_SECUREvia env overrides staged by the deploy job, not baked into the image. - SPA per-page Access must also allow shared assets (
/_next/*,/assets/*) or the gated page renders blank.