Mahbubur Riad
Back to blog
Hosting & Server 6 min read

Self-Hosted Cloudflare Tunnel Alternative with Caddy and Cloudflare Access

Jun 16, 2026 · Mahbubur Riad

Replace Cloudflare Tunnel with Caddy + Cloudflare Access for full control, lower cost, and granular security — full step-by-step guide for production.

On this page

Self-Hosted Cloudflare Tunnel Alternative with Caddy and Cloudflare Access: Step-by-Step Setup and Security Hardening

For years, Cloudflare Tunnel (formerly Argo Tunnel) has been the go-to solution for securely exposing local or on-prem services to the internet — without opening public ports. But it’s not without drawbacks: dependency on the cloudflared daemon, occasional latency spikes, and limited control over TLS termination or request routing logic.

What if you want full control — especially if you’re already using Caddy or Cloudflare Access for Zero Trust? The good news: you don’t need Cloudflare Tunnel at all. You can achieve the same (or better) security posture using Caddy as a reverse proxy, Cloudflare Access for identity-based access control, and mutual TLS or JWT-based verification.

This post walks through a production-ready setup — tested in small hosting environments — that eliminates cloudflared, reduces attack surface, and gives you granular request-level control.


Why Avoid Cloudflare Tunnel?

Let’s be honest: cloudflared is fine for quick setups, but it comes with trade-offs:

Feature Cloudflare Tunnel (cloudflared) Caddy + Cloudflare Access
Control over TLS termination Cloudflare manages TLS (not local) You control TLS (Caddy handles ACME, HSTS, etc.)
Dependency Must run cloudflared as a service Only Caddy (lightweight, standard binary)
Logging & tracing Limited, opaque logs Full access to Caddy logs, structured logging, custom middleware
Custom routing Basic path-based routing only Full reverse proxy rules, rewrites, rate limiting, auth headers
Cost Free for basic use; paid for advanced features Free (open-source)
Self-hosted flexibility Low (black box tunnel) High (you own the proxy logic)

If you’re already on Cloudflare Access (part of Cloudflare Zero Trust), you’re already doing identity-based access control. The missing piece is a reverse proxy that speaks the same language — and Caddy does.


How It Works: The Architecture

Here’s the high-level flow:

  1. User attempts to reach https://app.example.com
  2. Cloudflare Access intercepts the request, validates the user’s identity (via Google, SAML, etc.)
  3. If authenticated, Cloudflare forwards the request to your Caddy server over HTTPS (no public firewall exposure needed)
  4. Caddy terminates TLS, applies middleware (logging, rate limiting), and proxies to your internal service (e.g., http://localhost:3000)

Crucially:

  • Your internal service never listens on a public interface
  • No cloudflared process required
  • Access control is handled entirely by Cloudflare Access, not your server

🔐 Security win: Even if your server IP is leaked, attackers can’t reach your service — only Cloudflare’s edge can.


Step 1: Configure Cloudflare Access (Zero Trust)

This assumes you already have a Cloudflare zone (example.com) and Cloudflare Access enabled.

A. Create an Access Application

  1. In the Cloudflare dashboard, go to Zero TrustAccessApplicationsAdd a self-hosted application
  2. Set:
    • Application name: Internal App
    • App domain: app.example.com
    • Application type: Self-hosted
    • Allowed domains: example.com (or your organization’s domain)
    • Policy: Create one that requires identity (e.g., “All members of your team”)
  3. Save.

Cloudflare will generate:

  • A Cf-Access-Agent header (used later for middleware)
  • An Aud (audience) claim — this is your service ID, e.g., 443.app.example.com

Keep this handy.


Step 2: Install and Configure Caddy

We’ll use Caddy v2.7+ (with caddyfile + json config support).

A. Install Caddy

On Ubuntu 22.04+:

Bash
sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian/debian-amd64.list' | sudo tee /etc/apt/sources.list.d/caddy-stable.list
sudo apt update && sudo apt install caddy

Verify version:

Bash
caddy version
# v2.9.0 (or newer)

B. Minimal Caddyfile

Create /etc/caddy/Caddyfile:

CADDY
app.example.com {
    # Enforce HTTPS
    tls [email protected]  # or use automatic ACME

    # Cloudflare Access validation middleware
    reverse_proxy localhost:3000 {
        header_up X-Forwarded-For {http.request.remote.host}
        header_up X-Forwarded-Proto {http.request.scheme}
    }

    # Optional: Log to file for audit
    log {
        output file /var/log/caddy/app.log
        format json
    }

    # Rate limiting (example: 20 req/sec per IP)
    rate_limit {
        burst 10
        zoned 5s
    }
}

🛠️ Note: We’re not doing JWT validation here — that’s handled by Cloudflare Access before the request reaches Caddy. But if you want extra validation, read on.

Cloudflare Access adds a signed JWT in the Cf-Access-Jwt-Assertion header. Caddy can validate it with jwt middleware.

  1. First, add the Access audience ID to your Caddy config (from Step 1):

    • It’s typically <port>.<appname>.<zone> (e.g., 443.app.example.com)
  2. Update Caddyfile:

CADDY
app.example.com {
    tls [email protected]

    # Validate Cloudflare Access JWT
    jwt {
        header Cf-Access-Jwt-Assertion
        audience 443.app.example.com
        trusted_jwt_claims {
            # Optional: enforce groups or email
            # [email protected]
        }
    }

    reverse_proxy localhost:3000 {
        header_up X-Forwarded-For {http.request.remote.host}
    }

    log {
        output file /var/log/caddy/access.log
        format json
    }
}

🔐 Why? This adds defense-in-depth. Even if someone bypasses Cloudflare’s edge (e.g., via IP spoofing), they can’t forge a valid JWT.

  1. Reload Caddy:
Bash
sudo caddy reload

Step 3: Secure Your Internal Service

Your app (e.g., a Node.js app, Django, or even a simple static site) should not bind to 0.0.0.0.

Example: Node.js Express

JavaScript
// server.js
const express = require('express');
const app = express();

// Bind ONLY to localhost
app.listen(3000, '127.0.0.1', () => {
  console.log('App listening on http://127.0.0.1:3000');
});

Critical: If your service listens on 0.0.0.0, it’s exposed to the local network — defeating the purpose.


Step 4: Hardening for Production

Let’s talk about real-world hardening. These aren’t optional — they’re baseline.

A. TLS & Certificate Management

  • Use Caddy’s built-in ACME for automatic Let’s Encrypt certs.
  • Avoid self-signed certs — they break JWT validation and cause user friction.

B. Restrict Local Ports with Firewall

Even with Cloudflare Access, lock down your server:

Bash
# UFW example
sudo ufw allow 22/tcp      # SSH
sudo ufw allow 80/tcp      # HTTP (for ACME challenge)
sudo ufw allow 443/tcp     # HTTPS
sudo ufw deny 3000         # Internal app port — Caddy can still reach it (localhost)
sudo ufw enable

🛡️ 3000 is only reachable from 127.0.0.1, but this prevents accidental exposure via misconfigured Docker networks or reverse proxies.

C. Service Hardening

  • Run Caddy as caddy user (default), not root
  • Use systemd drop-in to restrict capabilities:
INI
# /etc/systemd/system/caddy.service.d/override.conf
[Service]
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=read-only
ReadWritePaths=/var/log/caddy

Then:

Bash
sudo systemctl daemon-reload
sudo systemctl restart caddy

D. Header Hardening

Add security headers via Caddy:

CADDY
app.example.com {
    # ... existing config ...

    # Security headers
    header {
        # Block iframe embedding
        X-Frame-Options DENY
        # Strict transport security
        Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
        # CSP (adjust per app)
        Content-Security-Policy "default-src 'self'"
        # Prevent MIME-type sniffing
        X-Content-Type-Options nosniff
        # Referrer policy
        Referrer-Policy strict-origin-when-cross-origin
    }
}

E. Logging & Monitoring

  • Forward Caddy logs to a SIEM (e.g., via filejournalctl → Logstash)
  • Alert on failed JWT validation or 4xx/5xx spikes:
Bash
# Simple journalctl tail
journalctl -u caddy -f | grep -E '401|403|JWT'

Comparison: Tunnel vs. Caddy + Access

Scenario Cloudflare Tunnel Caddy + Access
Setup time ~10 min (for basic) ~20 min (with hardening)
CPU/Memory usage ~50–100 MB (per tunnel) ~20–40 MB (Caddy)
Custom routing Limited Full (regex, headers, rewrites)
Debugging Hard (logs on Cloudflare) Easy (local logs, curl -v)
Multi-region failover Cloudflare handles it You implement (e.g., multiple Caddy backends)
Cost Free tier, but paid features Free (open source)

If you’re managing multiple services or need observability, Caddy wins.


Common Pitfalls & Fixes

❌ Problem: “403 Forbidden” from Cloudflare Access

  • Cause: JWT audience mismatch. Ensure audience in Caddy matches the Access app’s domain (443.app.example.com)
  • Fix: Re-check the Access app settings → copy the exact domain.

❌ Problem: “502 Bad Gateway” in browser

  • Cause: Internal service not running, or Caddy can’t reach it.
  • Fix:
    Bash
    curl -v http://127.0.0.1:3000
    # If this fails, your app is down.
    
  • Check Caddy logs: sudo journalctl -u caddy -n 50

❌ Problem: “JWT validation failed”

  • Cause: Clock skew between Caddy and Cloudflare.
  • Fix:
    • Ensure NTP sync is enabled:
      Bash
      timedatectl status  # Check "NTP service"
      sudo timedatectl set-ntp true
      

FAQ

Q: Do I still need Cloudflare Tunnel if I use this setup?
A: No. This replaces the tunnel entirely. Cloudflare Access handles identity; Caddy handles routing and TLS.

Q: Can I use this for multiple internal services?
A: Yes. Add more reverse_proxy blocks or use subdomains (api.example.com, admin.example.com). Each needs its own Cloudflare Access app.

Q: Is this suitable for production?
A: Absolutely — many small hosting providers use this pattern. Just follow the hardening steps above.

Q: What if I need mTLS between Cloudflare and Caddy?
A: Caddy supports it. You’d generate a client cert for Cloudflare Access to present, and configure Caddy with tls cert_key + verify_client. It’s more complex but doable.

Q: Does this work with non-Cloudflare DNS?
A: No — Cloudflare Access requires the domain to be proxied through Cloudflare. But you can use cf-ssl or cloudflared only for DNS tunneling if needed (not recommended).


Final Thoughts

Replacing Cloudflare Tunnel with Caddy + Cloudflare Access gives you:

  • Full control over your proxy behavior
  • Better observability
  • Lower operational overhead
  • Cost savings (no tunnel limits)

Yes, it’s a bit more manual — but for teams comfortable with Caddy configs, it’s worth the effort. The first time you debug a 5xx in 30 seconds using structured JSON logs (instead of waiting on Cloudflare’s support ticket), you’ll see why.

If you’re running a small hosting business or managing internal tools, this is the setup to adopt. It’s battle-tested, open, and transparent.

Thanks for reading — and if you find this helpful, feel free to share it. For more hands-on sysadmin and DevOps content, visit mahbuburriad.com.

Related

Related posts