Skip to content

t1k:web:devops:selfhost-cloudflare

FieldValue
Moduledevops
Version1.10.0
Effortmedium
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

/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.

IntentRead
”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 vanishedreferences/gotchas.md
Full greenfield setup, all fourRead all four, in order above
  • 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).
  • cloudflared installed on the host; gh CLI + repo admin to register a runner; docker + docker compose.
  • A GitHub repo for the app.
  1. Runner + deploy — register a systemd self-hosted runner with a distinct label, add a deploy.yml that runs on push to your deploy branch and calls your make deploy / docker compose target. Stage a canonical .env from a stable host path into the runner checkout. Re-home cookies to the public host (secure cookies ON). → references/selfhosted-runner.md
  2. Tunnel + DNS — add an ingress rule (hostname → http://localhost:<proxy-port>) to a shared cloudflared config, route DNS (cloudflared tunnel route dns), reload the cloudflared systemd service. → references/cloudflare-tunnel-dns.md
  3. 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
  4. Verify — unauthenticated request → 302 to the Cloudflare Access login; authenticated → app renders; all app routes return 200 locally behind the proxy.
  • 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, never 0.0.0.0 on 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:http to 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.

Full list with fixes: references/gotchas.md. The six that bite hardest:

  1. Stale global git extraheader in ~/.gitconfig 401s both your push AND the runner’s actions/checkout (Duplicate header: Authorization). Remove it.
  2. docker compose --remove-orphans reaps every separately-managed stack sharing the host (dashboards, sidecar containers). Drop the flag, or fold all compose files into one project.
  3. 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.
  4. Kubernetes/ARC is the wrong tool for a single-host compose deploy (ephemeral pods, dind isolation, shared-pool security). Use a plain systemd runner.
  5. Secure cookies need the public host — re-home COOKIE_DOMAIN/COOKIE_SECURE via env overrides staged by the deploy job, not baked into the image.
  6. SPA per-page Access must also allow shared assets (/_next/*, /assets/*) or the gated page renders blank.