Self-Hosting: Caddy Web Server Cloudflare Tunnel
← Caddy Web Server Self-Hosting Hub →
⏳ 12 min read 📊 Intermediate 📅 Updated Jan 2025

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

Browser
Cloudflare Edge
anycast / DDoS / WAF
Encrypted Tunnel
QUIC / HTTP/2
cloudflared daemon
your server
Local service
e.g. Caddy:80
  • No inbound firewall rulescloudflared initiates 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 failovercloudflared maintains 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.
  • cloudflared binary — 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.

1

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.

2

Open Zero Trust and navigate to Tunnels

From the main sidebar, click Zero TrustNetworksTunnels. 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.

3

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.

4

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.

5

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.

6

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.

7

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.

1

Install via Homebrew

Cloudflare publishes an official tap. This installs the latest release and sets up the update mechanism.

brew install cloudflare/cloudflare/cloudflared
2

Verify the installation

cloudflared --version
# cloudflared version 2024.x.x (built ...)
3

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.

4

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.

5

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
6

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.

1

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
2

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
3

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

Verify the installation

cloudflared --version
# cloudflared version 2024.x.x (built ...)
5

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.

6

View live logs

journalctl -u cloudflared --follow

# Last 100 lines with timestamps
journalctl -u cloudflared -n 100 --no-pager
7

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
1

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

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:
3

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
4

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

5

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.

6

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.

1

Open the Public Hostname editor

Dashboard → Zero Trust → Networks → Tunnels → click your tunnel name → Public Hostname tab → "Add a public hostname"

2

Fill in the hostname mapping

Each mapping binds one public hostname to one internal service endpoint:

FieldExample valueNotes
SubdomainappLeave blank to use the root domain
Domainyourdomain.comMust be on Cloudflare DNS
Service TypeHTTPUse HTTPS if your local service has TLS
URLlocalhost:3000Or caddy:80 in Docker
3

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.

4

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
5

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.

Internet
Cloudflare
TLS termination
Tunnel
Caddy:80
HTTP only
App containers

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.

Internet
Cloudflare
Tunnel
Your App:3000

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
  • cloudflared connects 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.
Enterprise

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

1

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.

2

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
3

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
4

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
5

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.