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:
- User attempts to reach
https://app.example.com - Cloudflare Access intercepts the request, validates the user’s identity (via Google, SAML, etc.)
- If authenticated, Cloudflare forwards the request to your Caddy server over HTTPS (no public firewall exposure needed)
- 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
cloudflaredprocess 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
- In the Cloudflare dashboard, go to Zero Trust → Access → Applications → Add a self-hosted application
- 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”)
- Application name:
- Save.
Cloudflare will generate:
- A
Cf-Access-Agentheader (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+:
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:
caddy version
# v2.9.0 (or newer)
B. Minimal Caddyfile
Create /etc/caddy/Caddyfile:
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.
C. Enable Full Cloudflare Access JWT Validation (Optional but Recommended)
Cloudflare Access adds a signed JWT in the Cf-Access-Jwt-Assertion header. Caddy can validate it with jwt middleware.
-
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)
- It’s typically
-
Update
Caddyfile:
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.
- Reload Caddy:
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
// 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:
# 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
🛡️
3000is only reachable from127.0.0.1, but this prevents accidental exposure via misconfigured Docker networks or reverse proxies.
C. Service Hardening
- Run Caddy as
caddyuser (default), notroot - Use
systemddrop-in to restrict capabilities:
# /etc/systemd/system/caddy.service.d/override.conf
[Service]
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=read-only
ReadWritePaths=/var/log/caddy
Then:
sudo systemctl daemon-reload
sudo systemctl restart caddy
D. Header Hardening
Add security headers via 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
file→journalctl→ Logstash) - Alert on failed JWT validation or 4xx/5xx spikes:
# 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
audiencein 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
- Ensure NTP sync is enabled:
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.