Cloudflare Tunnel — Locally-Managed Mode
This guide covers the locally-managed tunnel mode where ingress rules live in a config.yml file alongside your deployment, rather than being configured through the Cloudflare dashboard.
See token mode if you prefer the simpler one-env-var approach.
When to use local mode
| Token mode | Local mode | |
|---|---|---|
| Config location | Cloudflare dashboard / API | config.yml in your repo |
| Bootstrap effort | Paste one token | tunnel login + tunnel create + DNS route |
| Config changes | Dashboard or API call | Edit YAML, restart container |
| Disaster recovery | Re-paste token | Re-mount same credentials + YAML |
| HA / multi-replica | Works out of the box | Credentials file must be replicated to each host |
Local mode is a good fit when you want tunnel configuration reviewable in git and prefer not to make Cloudflare API calls for routine ingress changes.
Prerequisites
A Cloudflare account with at least one domain managed in Cloudflare DNS.
cloudflaredinstalled on the host (not in the container — only the one-time bootstrap runs on the host).sh# Linux (amd64) curl -fsSL https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64 \ -o /usr/local/bin/cloudflared && chmod +x /usr/local/bin/cloudflared # Linux (arm64 / Raspberry Pi) curl -fsSL https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-arm64 \ -o /usr/local/bin/cloudflared && chmod +x /usr/local/bin/cloudflared
Step-by-step bootstrap
All of the following steps run once on the host. The container re-uses the resulting files on every restart.
1. Authenticate with Cloudflare
cloudflared tunnel loginThis opens a browser tab. Select the zone (domain) you want to use. On success, ~/.cloudflared/cert.pem is written. You only need cert.pem for the bootstrap steps below — it is not required at container runtime.
2. Create the tunnel
cloudflared tunnel create cotelOutput includes a tunnel UUID (e.g. a1b2c3d4-…). A credentials file is written to ~/.cloudflared/<UUID>.json. Keep this file safe — it is the secret that authenticates the tunnel.
3. Add DNS routes
Run once for each public hostname you want to expose:
# Dashboard
cloudflared tunnel route dns cotel dash.example.com
# OTLP ingest (Claude Code points here)
cloudflared tunnel route dns cotel ingest.example.comThese create CNAME records in your Cloudflare DNS pointing to <UUID>.cfargotunnel.com.
4. Write ~/.cloudflared/config.yml
tunnel: <UUID> # from step 2
credentials-file: /etc/cloudflared/<UUID>.json # path inside the container
ingress:
- hostname: dash.example.com
service: http://localhost:8080
- hostname: ingest.example.com
service: http://localhost:4318
- service: http_status:404 # catch-all (required)Replace <UUID> with your actual tunnel UUID and example.com with your domain.
Note: the
credentials-filepath is the in-container path (/etc/cloudflared/), not the host path. This is correct — the container reads the file from the mount point.
5. Mount ~/.cloudflared into the container
In docker-compose.yml, uncomment the volume line and set COTEL_PUBLIC_INGEST_URL:
volumes:
- cotel-data:/data
- ~/.cloudflared:/etc/cloudflared:ro # ← uncomment this line
environment:
COTEL_PUBLIC_INGEST_URL: "https://ingest.example.com" # public OTLP URLCOTEL_PUBLIC_INGEST_URL tells the Setup page what endpoint operators should paste into their Claude Code settings. When set, the snippet on the Setup → Getting Started tab substitutes http://localhost:4318 with the public URL. Leave it unset for local dev.
Do not set CLOUDFLARE_TUNNEL_TOKEN. If both are present, token mode takes precedence and the config file is ignored.
6. Start cotel
docker compose up -dThe entrypoint detects /etc/cloudflared/config.yml and starts cloudflared in local-config mode.
Verifying the tunnel
# Container logs — look for "cloudflared started in local-config mode"
docker compose logs cotel
# Tunnel status in the Cloudflare dashboard:
# Zero Trust → Networks → Tunnels → cotel → should show "Healthy"
# Quick smoke test
curl -s https://ingest.example.com/healthz
curl -s https://dash.example.comconfig.yml reference
| Key | Description |
|---|---|
tunnel | Tunnel UUID from cloudflared tunnel create |
credentials-file | Absolute path to <UUID>.json inside the container |
ingress[].hostname | Public hostname (must match a DNS CNAME you created) |
ingress[].service | Backend URL inside the container |
ingress[].originRequest | Optional per-rule origin settings (timeouts, TLS verify, etc.) |
The final ingress entry must be a catch-all (service: http_status:404); cloudflared will reject a config without one.
Adding Cloudflare Access to the dashboard
Cloudflare Access works identically regardless of tunnel mode. In the Zero Trust dashboard:
- Go to Access → Applications → Add an application.
- Choose Self-hosted, enter
dash.example.com. - Create a policy (email OTP, GitHub SSO, etc.).
This gates the dashboard UI without any changes to cotel itself. The ingest endpoint (ingest.example.com) should generally remain open (authentication is handled by cotel bearer tokens).
Known limitations
- HA / multi-replica: the
<UUID>.jsoncredentials file must be present on every host that runs the container. Synchronizing credentials across hosts is out of scope — consider token mode for multi-host setups, as the token is just an env var. cert.pemis only needed for bootstrap. You can delete or archive it after step 3; the container only needs<UUID>.jsonandconfig.ymlat runtime.- Migrating from token mode: create a new tunnel (
tunnel create), add DNS routes, writeconfig.yml, removeCLOUDFLARE_TUNNEL_TOKEN, mount~/.cloudflared, restart. The old token-mode tunnel can be deleted in the Cloudflare dashboard after confirming the new one is healthy.
References
- cloudflared local management docs
- ADR-0006 — why Cloudflare Tunnel was chosen
- Token mode guide — simpler setup if file-based config is not needed