🌐 How Cloudflare Tunnel Works
The Architecture
Cloudflare Tunnel (cloudflared) creates an outbound-only encrypted connection from your server to Cloudflare's global edge network. Your firewall can block all inbound traffic — no ports need to be opened, no public IP is required, and no dynamic DNS service is needed.
Users connect to Cloudflare's anycast network, which provides DDoS protection, a WAF, and caching. Cloudflare then routes requests through the persistent tunnel to your cloudflared daemon, which forwards them to your local service.
anycast / DDoS / WAF
QUIC / HTTP/2
your server
e.g. Caddy:80
- No inbound firewall rules —
cloudflaredinitiates all connections outbound on port 7844 (UDP preferred, TCP fallback). Your router's NAT is never touched. - No public IP — works behind CGNAT, home ISP NAT, or any network that blocks inbound connections.
- Automatic failover —
cloudflaredmaintains multiple connections to different Cloudflare PoPs for redundancy. - TLS everywhere — Cloudflare terminates TLS from the browser; the tunnel itself uses QUIC or HTTP/2 with TLS between Cloudflare and your daemon.
Prerequisites
Before you begin, you need three things. The tunnel itself is completely free on Cloudflare's free tier.
- Cloudflare account — sign up at
dash.cloudflare.com. Free tier includes the tunnel, Zero Trust, WAF (basic), and DDoS protection. - Domain on Cloudflare DNS — your domain's nameservers must point to Cloudflare. This is required because Cloudflare automatically creates CNAME records for your tunnel hostnames. You cannot use Cloudflare Tunnel with a domain managed elsewhere. Nameserver delegation typically propagates within 24 hours.
cloudflaredbinary — the connector daemon that runs on your server. Available for Linux (amd64/arm64), macOS, Windows, and as a Docker image. See the OS-specific installation steps in Section 3.- Zero Trust enabled — navigate to Zero Trust in the Cloudflare dashboard and complete the one-time onboarding (free, no credit card required for the free plan).
Free Tier Coverage
Cloudflare Tunnel is free with no bandwidth limits. Cloudflare Access (identity-based auth in front of your apps) is free for up to 50 users. The WAF managed ruleset requires at least the Pro plan ($20/mo).
⚙ Account & Tunnel Setup (One-Time)
These steps are done once in the Cloudflare dashboard. The result is a tunnel token — a long base64 string that embeds all your tunnel credentials. You'll pass this token to cloudflared on your server. No config files required for the basic setup.
Verify your domain is active
Log into dash.cloudflare.com → click your domain → the overview page should show Status: Active with Cloudflare nameservers. If it still shows "Pending," wait for DNS propagation and re-check.
Open Zero Trust and navigate to Tunnels
From the main sidebar, click Zero Trust → Networks → Tunnels. If this is your first time, you'll be prompted to set a Zero Trust team name (e.g., myorg) — this can be anything and is used for Access policies.
Create a tunnel
Click "Create a tunnel". Choose connector type: Cloudflared (not WARP connector). Give your tunnel a descriptive name like homeserver or homelab-prod. Click Save tunnel.
Copy your tunnel token
Cloudflare generates a tunnel token that looks like: eyJhIjoiMDdjNGQ4N...very-long-string...Zn0=
This token contains your tunnel credentials encoded in base64. Treat it like a password — anyone with this token can route traffic through your tunnel. Do not commit it to git. Store it in a secrets manager, environment variable, or Docker secret.
Install and run cloudflared
Follow the OS-specific instructions in Section 3 below to install the cloudflared binary and run it with your token. The dashboard shows a live connector status indicator that updates within ~10 seconds of the daemon starting.
Verify the tunnel is Healthy
Once cloudflared is running with your token, return to the Tunnels list in the dashboard. The tunnel status should change from "Inactive" to Healthy. A "Degraded" status means the daemon connected but some connectors are down — usually a transient network issue.
Configure public hostnames
Click your tunnel → "Public Hostname" tab → "Add a public hostname". Map app.yourdomain.com to http://localhost:3000. Cloudflare automatically creates a CNAME DNS record. Your service is now reachable at https://app.yourdomain.com. Repeat for each service you want to expose.
💾 Install cloudflared
Choose your platform. The token-based approach shown here works across all platforms — no separate login step or config file required.
Install via Homebrew
Cloudflare publishes an official tap. This installs the latest release and sets up the update mechanism.
brew install cloudflare/cloudflare/cloudflared
Verify the installation
cloudflared --version # cloudflared version 2024.x.x (built ...)
Run with your tunnel token (foreground test)
Paste your token from the dashboard. This runs in the foreground so you can verify it connects before installing as a service.
cloudflared tunnel run --token eyJhIjoiMDc...your-token...
You should see output like Registered tunnel connection connIndex=0 location=SJC. Press Ctrl+C once confirmed.
Install as a persistent macOS service (launchd)
This registers a launchd plist so cloudflared starts automatically on boot and restarts on crash.
sudo cloudflared service install --token eyJhIjoiMDc...your-token... sudo launchctl start com.cloudflare.cloudflared
The plist is written to /Library/LaunchDaemons/com.cloudflare.cloudflared.plist. The service runs as root under launchd.
Check service status and logs
# Check if service is loaded sudo launchctl list | grep cloudflared # View logs (macOS writes to system log) log stream --predicate 'process == "cloudflared"' --info # Or check the launchd exit code (0 = running, blank = not running) sudo launchctl print system/com.cloudflare.cloudflared
Uninstall the service (if needed)
sudo cloudflared service uninstall
Alternative: Browser Login Method
Instead of a token, you can authenticate via OAuth: run cloudflared tunnel login. This opens a browser, you authorize the domain, and cloudflared writes a certificate to ~/.cloudflared/cert.pem. Then create and run tunnels with cloudflared tunnel create <name> and a config file. The token approach is simpler for most setups.
Install on Debian / Ubuntu (via apt package)
# Download the .deb package curl -L --output cloudflared.deb \ https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64.deb # Install it sudo dpkg -i cloudflared.deb # Clean up rm cloudflared.deb
Install on RHEL / Fedora / Rocky Linux (via rpm)
curl -L --output cloudflared.rpm \ https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-x86_64.rpm sudo rpm -ivh cloudflared.rpm rm cloudflared.rpm
Or install the binary directly (any distro, ARM support)
# amd64 curl -L https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64 \ -o cloudflared chmod +x cloudflared sudo mv cloudflared /usr/local/bin/ # arm64 (Raspberry Pi, ARM servers) curl -L https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-arm64 \ -o cloudflared chmod +x cloudflared sudo mv cloudflared /usr/local/bin/
Verify the installation
cloudflared --version # cloudflared version 2024.x.x (built ...)
Install as a systemd service
This registers a systemd unit file so cloudflared starts on boot and restarts on failure.
sudo cloudflared service install --token eyJhIjoiMDc...your-token... # Enable and start the service sudo systemctl enable --now cloudflared # Verify it's running sudo systemctl status cloudflared
The service installer writes your token to /etc/cloudflared/config.yml and creates a systemd unit at /etc/systemd/system/cloudflared.service.
View live logs
journalctl -u cloudflared --follow # Last 100 lines with timestamps journalctl -u cloudflared -n 100 --no-pager
Update cloudflared
# If installed via dpkg/rpm, re-download and reinstall the package. # If installed as a binary: sudo cloudflared update # Then restart the service sudo systemctl restart cloudflared
Run with token — simplest one-liner
No config files, no volumes. The token is passed directly. Good for testing.
docker run cloudflare/cloudflared:latest \ tunnel --no-autoupdate run \ --token eyJhIjoiMDc...your-token...
Production docker-compose.yml alongside Caddy
Use a .env file to keep the token out of your compose file. Create .env with TUNNEL_TOKEN=eyJ..., then add it to .gitignore.
services:
cloudflared:
image: cloudflare/cloudflared:latest
restart: unless-stopped
command: tunnel --no-autoupdate run
environment:
- TUNNEL_TOKEN=${TUNNEL_TOKEN}
networks:
- web
caddy:
image: caddy:latest
restart: unless-stopped
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
networks:
- web
networks:
web:
volumes:
caddy_data:
caddy_config:
Start the stack
# Start in background docker compose up -d # Verify both containers are running docker compose ps # Follow cloudflared logs docker compose logs -f cloudflared
Important: service name DNS in Docker
When cloudflared and Caddy share a Docker network, cloudflared must route to http://caddy:80 (the Docker service name), not http://localhost:80. localhost inside a Docker container refers to that container only.
In the Cloudflare dashboard, set your public hostname's service URL to: http://caddy:80
Using Docker secrets for the token (Swarm / Compose v3)
# docker-compose.yml with secrets
services:
cloudflared:
image: cloudflare/cloudflared:latest
restart: unless-stopped
command: tunnel --no-autoupdate run
environment:
- TUNNEL_TOKEN_FILE=/run/secrets/cf_token
secrets:
- cf_token
secrets:
cf_token:
file: ./cf_token.txt # contains only the token string
Note: cloudflared supports reading the token from a file path via the TUNNEL_TOKEN env var when prefixed with file://, or use Docker's built-in secret injection and pass the path. The .env approach is simpler for single-host setups.
Update cloudflared image
# Pull the latest image docker compose pull cloudflared # Recreate the container with the new image docker compose up -d cloudflared
Pin image versions in production
Using cloudflare/cloudflared:latest is convenient but can cause unexpected behavior on updates. For stability, pin to a specific version tag: cloudflare/cloudflared:2024.11.0. Check the releases page for the latest tag.
🌐 Public Hostname Configuration
Public hostnames map an internet-facing URL to a service on your server. This is configured in the Cloudflare dashboard when using the managed (token) approach — no config files needed. Cloudflare automatically creates and manages the CNAME DNS records.
Open the Public Hostname editor
Dashboard → Zero Trust → Networks → Tunnels → click your tunnel name → Public Hostname tab → "Add a public hostname"
Fill in the hostname mapping
Each mapping binds one public hostname to one internal service endpoint:
| Field | Example value | Notes |
|---|---|---|
| Subdomain | app | Leave blank to use the root domain |
| Domain | yourdomain.com | Must be on Cloudflare DNS |
| Service Type | HTTP | Use HTTPS if your local service has TLS |
| URL | localhost:3000 | Or caddy:80 in Docker |
Cloudflare creates the DNS record automatically
After saving, check your domain's DNS settings. A CNAME record appears:
app.yourdomain.com CNAME <tunnel-uuid>.cfargotunnel.com (Proxied)
This CNAME is Cloudflare-proxied (orange cloud), which means all traffic routes through Cloudflare's network. DNS propagation is near-instant because Cloudflare controls both sides.
Multiple services on different subdomains
Add a public hostname entry for each service. There is no limit on the number of hostnames per tunnel on the free plan.
# Examples of what you'd configure in the dashboard: grafana.yourdomain.com → http://localhost:3001 nextcloud.yourdomain.com → http://localhost:8080 homeassistant.yourdomain.com → http://localhost:8123 jellyfin.yourdomain.com → http://localhost:8096
Path-based routing (Advanced)
Use the optional Path field when adding a hostname to route specific URL prefixes to different services on the same domain. For example, route yourdomain.com/api to a local API server while the root serves static content from Caddy.
Note: path-based routing in the dashboard requires Cloudflare's Load Balancing add-on for complex scenarios. For most cases, subdomain-per-service is simpler and free.
🔒 Integration with Caddy
Cloudflare Tunnel and Caddy complement each other well. Cloudflare handles internet-facing TLS termination, DDoS protection, and edge caching. Caddy handles virtual hosting, reverse proxying, access control, and request logging on the server side.
Approach A: Tunnel → Caddy (Recommended)
Cloudflare Tunnel connects to Caddy. Caddy acts as a local reverse proxy and virtual host router. This is the best setup when you have multiple apps on one server or need Caddy features like basic auth, path rewriting, or access logs.
TLS termination
HTTP only
Set your Cloudflare tunnel public hostname URL to http://localhost:80 (bare metal) or http://caddy:80 (Docker). Caddy uses auto_https off since TLS is handled at the Cloudflare edge.
:80 {
@app1 host app1.yourdomain.com
handle @app1 {
reverse_proxy app1:3001
}
@app2 host app2.yourdomain.com
handle @app2 {
reverse_proxy app2:3002
}
# Optional: strip /api prefix before forwarding
@vllm host api.yourdomain.com
handle @vllm {
uri strip_prefix /v1
reverse_proxy ml-backend:8002
}
}
Caddy's host matcher reads the Host HTTP header, which Cloudflare preserves correctly by default.
Approach B: Tunnel → Direct App (Simple)
Skip Caddy entirely and point the tunnel directly at your application. Best for a single service or when you want the simplest possible setup.
In the dashboard: set Service Type to HTTP and URL to localhost:3000. No Caddy needed.
Trade-offs vs Approach A:
- No virtual hosting — one tunnel hostname per app port
- No Caddy access logs or log formatting
- No Caddy-level basic auth or rate limiting
- No request rewriting or path manipulation
- Simpler — fewer moving parts, easier to debug
When to use this approach
Use Approach B when you're exposing a single app (e.g., Portainer, Home Assistant, Vaultwarden) and don't need Caddy's routing features. Add Caddy later if your needs grow.
Passing the real client IP to Caddy
Cloudflare adds a CF-Connecting-IP header with the real visitor IP (the tunnel replaces the remote IP with Cloudflare's). To make Caddy log the real IP and pass it to apps, add to your Caddyfile global block:
{
auto_https off
servers {
trusted_proxies static 173.245.48.0/20 103.21.244.0/22 \
103.22.200.0/22 103.31.4.0/22 141.101.64.0/18 \
108.162.192.0/18 190.93.240.0/20 188.114.96.0/20 \
197.234.240.0/22 198.41.128.0/17 162.158.0.0/15 \
104.16.0.0/13 104.24.0.0/14 172.64.0.0/13 \
131.0.72.0/22
}
}
This tells Caddy to trust the X-Forwarded-For header when requests arrive from Cloudflare's IP ranges, so {remote_host} in log templates resolves to the real visitor IP.
🛡 Security Configuration
Cloudflare Tunnel gives you a solid security baseline by default. These additional controls let you harden access further without running any additional software on your server.
👤 Cloudflare Access (Zero Trust Auth)
Put identity-based authentication in front of any tunnel hostname. Users must authenticate before any request reaches your server — your app never sees unauthenticated traffic.
Zero Trust → Access → Applications → "Add an application" → Self-hosted
- Identity providers — Google, GitHub, Microsoft, email OTP (magic link), SAML 2.0, OIDC. Free tier supports one provider.
- Free for 50 users — ideal for personal homelab or small team use.
- Session duration — configure how long a login session lasts (24h default). Users re-auth after session expires.
- Per-app policies — different rules for different apps. Allow only your Google account for personal apps, allow your whole domain for team apps.
# Example Access policy (configured in dashboard): # Application: grafana.yourdomain.com # Policy: Allow # Rule: Emails → [email protected]
🚫 Block All Direct Inbound Traffic
Once your tunnel is working, lock down your firewall so that your services are only reachable through Cloudflare. This eliminates direct attack surface.
# UFW (Ubuntu/Debian) # Block all inbound on 80 and 443 sudo ufw deny 80/tcp sudo ufw deny 443/tcp # Allow outbound (cloudflared needs port 7844) # UFW allows outbound by default — no action needed. # Verify cloudflared can still reach Cloudflare curl -I https://region1.v2.argotunnel.com
cloudflaredconnects outbound on UDP port 7844. If UDP is blocked, it falls back to TCP 443.- You can verify with:
curl https://region1.v2.argotunnel.com— should return HTTP 400 (tunnel protocol, not HTTP).
🛡 Cloudflare WAF
The Web Application Firewall blocks OWASP Top 10 attacks, SQL injection, XSS, and known CVEs before traffic reaches your server. Available on the Pro plan ($20/mo) and above.
Domain → Security → WAF → Managed Rules → enable Cloudflare Managed Ruleset and OWASP Core Ruleset
- Cloudflare Managed Ruleset — Cloudflare's own rules for common web attacks, updated continuously.
- OWASP Core Ruleset — industry-standard rules. Set anomaly threshold to "Medium" to start — "Low" generates false positives for many apps.
- Custom rules — block specific countries, user-agents, IP ranges, or request patterns with firewall rules (free tier: up to 5 rules).
🔓 IP Allowlist via Access Policies
Restrict an application to specific IP ranges using Access policy rules. Useful for admin panels that should only be reachable from your office or home IP.
Zero Trust → Access → Applications → your app → Policies → add a rule:
- Rule type:
IP ranges - Value:
203.0.113.0/24(your office/home CIDR) - Combine with an email rule using AND logic: must be from allowed IP and authenticated identity.
# Or block at the WAF level with a custom rule:
# Field: ip.src
# Operator: not in
# Value: {203.0.113.5, 198.51.100.0/24}
# Action: Block
🔑 mTLS for API Authentication
Mutual TLS (mTLS) requires clients to present a valid certificate. Useful when you have internal services or automation calling your APIs — ensures only authorized systems can connect, regardless of network position.
Zero Trust → Access → Service Auth → mTLS
- Cloudflare acts as the CA — it issues client certificates you distribute to your services.
- Configure a hostname-level mTLS policy: requests without a valid cert get a 403 before reaching your origin.
- Combine with Access policies for layered auth: mTLS (machine identity) + JWT (user identity).
📥 Browser Isolation & DLP
For Enterprise use cases: Cloudflare's Remote Browser Isolation (RBI) renders web pages in Cloudflare's cloud, sending only safe pixels to the end user. Data Loss Prevention (DLP) scans uploads/downloads for sensitive data patterns (PII, credit card numbers, etc.).
- RBI — prevents malicious JavaScript from executing in corporate endpoints. Requires Enterprise plan.
- DLP — requires Zero Trust Gateway with an appropriate plan.
- For homelab / small team: these features are beyond what you need. Stick to Access + WAF.
📄 Config File Approach (IaC / GitOps)
The dashboard-managed token approach is easiest for getting started. For GitOps workflows where you want tunnel configuration in version control alongside your Caddyfile and docker-compose files, use the config file approach instead.
Prerequisite: authenticate first
The config file approach requires authenticating with cloudflared tunnel login (browser OAuth) rather than a dashboard-generated token. This writes a certificate to ~/.cloudflared/cert.pem. Run this once per machine.
Authenticate and create the tunnel
# Authenticate (opens browser) cloudflared tunnel login # Create a named tunnel cloudflared tunnel create homeserver # List tunnels and get the UUID cloudflared tunnel list
This creates a credentials JSON file at ~/.cloudflared/<tunnel-uuid>.json containing the tunnel secret.
Create the config file
Create ~/.cloudflared/config.yml (Linux/macOS user install) or /etc/cloudflared/config.yml (system service):
tunnel: <your-tunnel-uuid> # from `cloudflared tunnel list`
credentials-file: /root/.cloudflared/<tunnel-uuid>.json
ingress:
- hostname: app1.yourdomain.com
service: http://localhost:3001
- hostname: app2.yourdomain.com
service: http://localhost:3002
- hostname: grafana.yourdomain.com
service: http://localhost:3000
originRequest:
noTLSVerify: true # only if origin uses self-signed cert
- hostname: nextcloud.yourdomain.com
service: http://localhost:8080
originRequest:
connectTimeout: 30s # longer timeout for large uploads
http2Origin: true # enable HTTP/2 to origin
# Catch-all rule — REQUIRED, must be last
- service: http_status:404
Run the tunnel with the config file
cloudflared tunnel run homeserver # Or with explicit config path: cloudflared tunnel --config /etc/cloudflared/config.yml run homeserver
Create DNS records programmatically
Instead of creating DNS records via the dashboard, use the CLI. This creates the CNAME in Cloudflare DNS automatically.
# Route each hostname to the tunnel cloudflared tunnel route dns homeserver app1.yourdomain.com cloudflared tunnel route dns homeserver app2.yourdomain.com cloudflared tunnel route dns homeserver grafana.yourdomain.com
Install as a service using the config file
# Copy credentials to /etc/cloudflared/ sudo mkdir -p /etc/cloudflared sudo cp ~/.cloudflared/config.yml /etc/cloudflared/ sudo cp ~/.cloudflared/<tunnel-uuid>.json /etc/cloudflared/ # Install the systemd service (Linux) sudo cloudflared service install sudo systemctl enable --now cloudflared
GitOps tip
Check config.yml into git alongside your Caddyfile and docker-compose.yml. Keep the credentials JSON out of git (add to .gitignore) — it contains the tunnel secret. The config file alone is safe to version since it only contains the tunnel UUID and ingress rules, not credentials.
🔧 Troubleshooting
Common issues and how to resolve them. Check cloudflared logs first — they are verbose and usually point directly at the problem.
| Symptom | Likely Cause | Fix |
|---|---|---|
| Tunnel shows "Degraded" | cloudflared can't reach Cloudflare edge on all connectors | Check outbound internet on port 7844 (UDP/TCP). Test: curl https://region1.v2.argotunnel.com — any HTTP response (even 400) means the route is open. Check for outbound firewall rules blocking UDP 7844. |
| 502 Bad Gateway | cloudflared can't reach the local service | Verify your app is running: curl http://localhost:3000. Check the service URL in the dashboard (or config file). In Docker, use the service name (http://caddy:80) not localhost. |
| ERR_TOO_MANY_REDIRECTS | Cloudflare SSL mode mismatch causing redirect loops | Go to your domain → SSL/TLS → Overview. If Caddy serves HTTP internally, set mode to Flexible. If Caddy also has HTTPS, set to Full. "Full (Strict)" requires a valid cert on origin — don't use with auto_https off. |
| Tunnel "Inactive" / not Healthy | cloudflared not running or wrong token | Linux: sudo systemctl status cloudflared. macOS: sudo launchctl list | grep cloudflared. Docker: docker compose logs cloudflared. Verify the token matches the one in the dashboard exactly — regenerate if unsure. |
| DNS not resolving for hostname | Cloudflare DNS CNAME not created yet | Dashboard → your domain → DNS → check for the CNAME record with value <uuid>.cfargotunnel.com. If missing, re-save the public hostname in the tunnel config. Dashboard-created records propagate within 1–5 minutes. |
| Large file uploads fail or timeout | Cloudflare Free plan 100 MB request body limit | The Free plan limits individual request bodies to 100 MB. Upgrade to Pro ($20/mo) for a 500 MB limit, or Business for larger limits. For self-hosted file services (Nextcloud, etc.), consider whether proxying large uploads through Cloudflare makes sense vs. a direct connection. |
| WebSocket connections drop | Cloudflare WebSocket proxying not enabled | Dashboard → your domain → Network → enable WebSockets. This is off by default on some plan levels. Also ensure your app and Caddy support WebSocket upgrade headers. |
| Real client IP shows Cloudflare IP | Caddy not configured to trust Cloudflare as a proxy | Add the trusted_proxies block to your Caddyfile global options with Cloudflare's IP ranges (see Section 5). Then use {http.request.header.CF-Connecting-IP} or {remote_host} in logs. |
| Access policy not enforcing | Access application domain doesn't match tunnel hostname | The Access application "Application domain" must exactly match the public hostname (e.g., both should be app.yourdomain.com). Wildcards like *.yourdomain.com are supported but require a Business plan. |
Enable debug logging in cloudflared
# Run with debug output cloudflared tunnel --loglevel debug run --token eyJ... # Or for systemd service, edit /etc/cloudflared/config.yml and add: # loglevel: debug # then restart: sudo systemctl restart cloudflared
Debug logs show every connection attempt, TLS negotiation, and origin request — invaluable for diagnosing 502s and tunnel connectivity issues.