On this page
Deploying a Redundant Self‑Hosted Cloudflare Tunnel Alternative with WireGuard and Nginx Proxy Manager: High‑Availability Guide
Let’s be honest: Cloudflare Tunnel (argo) is great—when it works. But if you’ve ever seen that “Tunnel is not connected” alert in the Cloudflare dashboard during a critical outage, you know it’s not always reliable for production workloads. And if you’re running a Zero Trust architecture, you don’t want your internal services to hinge on a third-party tunnel endpoint.
This guide walks through building a self-hosted, redundant tunnel alternative using WireGuard for transport-layer security and Nginx Proxy Manager (NPM) for reverse proxy and DNS automation. The goal: a resilient, low-cost, and transparent replacement for Cloudflare Tunnel—without vendor lock-in or recurring fees.
We’ll focus on high availability (HA) from day one. You’ll get:
- WireGuard mesh topology with automatic failover
- NPM with DNS-01 challenge automation for Let’s Encrypt certs
- Shared config and health-based failover
- Real
wg-quickanddocker-compose.ymlexamples
No fluff. Just what works in production.
Why Not Just Use Cloudflare Tunnel?
Before we dive in, here’s the honest tradeoff:
| Feature | Cloudflare Tunnel | WireGuard + NPM |
|---|---|---|
| Cost | Free tier limited; paid tiers start at $5/mo per tunnel | Free (self-hosted) |
| Reliability | Dependent on Cloudflare edge, occasional flakiness | Depends on your infrastructure—more control |
| Zero Trust | Built-in (if using Cloudflare Access) | Requires manual policy setup (e.g., mTLS, ACLs) |
| DNS Automation | Built-in (via CNAME) | Achievable via ACME + DNS provider plugin |
| HA Support | Manual tunnel redundancy (multiple instances) | Native (mesh + keepalived or systemd failover) |
Cloudflare Tunnel abstracts away complexity. But if you want full control—especially for internal tooling, legacy apps, or multi-cloud deployments—self-hosting is the way.
Architecture Overview
We’ll deploy two redundant tunnel nodes, each running:
wireguard(kernel module or userspace)nginx-proxy-manager(Docker)acme.sh+ DNS plugin (e.g.,acme-dnsor provider-specific)- Optional:
keepalivedfor floating VIP (if using bare-metal/LAN)
Traffic flow:
[Client] → [Public IP:443] → [NPM] → [WireGuard tunnel] → [Backend service (e.g., http://internal:8080)]
Both nodes listen on the same public IP (via DNS round-robin, BGP, or floating VIP). If Node A goes down, Node B picks up traffic instantly.
Step 1: WireGuard Mesh Setup
We’ll use a star topology for simplicity: one central “hub” node, and multiple “spoke” nodes. But for HA, both nodes act as both hub and spoke—each maintains full mesh routes.
Install WireGuard
On both nodes (Ubuntu 22.04+):
apt update && apt install -y wireguard
Generate keys on Node A (primary):
umask 077
wg genkey | tee privatekey | wg pubkey > publickey
Do the same on Node B (secondary):
umask 077
wg genkey | tee privatekey | wg pubkey > publickey
Store the public keys—you’ll need them for peer config.
Configure WireGuard Interfaces
Create /etc/wireguard/wg0.conf on Node A:
[Interface]
PrivateKey = <Node-A-private-key>
Address = 10.99.0.1/24
ListenPort = 51820
PostUp = iptables -A FORWARD -i wg0 -j ACCEPT; iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE
PostDown = iptables -D FORWARD -i wg0 -j ACCEPT; iptables -t nat -D POSTROUTING -o eth0 -j MASQUERADE
[Peer]
PublicKey = <Node-B-public-key>
AllowedIPs = 10.99.0.2/32
Endpoint = <Node-B-public-IP>:51820
PersistentKeepalive = 25
Do the same on Node B, swapping IPs and keys:
[Interface]
PrivateKey = <Node-B-private-key>
Address = 10.99.0.2/24
ListenPort = 51820
PostUp = iptables -A FORWARD -i wg0 -j ACCEPT; iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE
PostDown = iptables -D FORWARD -i wg0 -j ACCEPT; iptables -t nat -D POSTROUTING -o eth0 -j MASQUERADE
[Peer]
PublicKey = <Node-A-public-key>
AllowedIPs = 10.99.0.1/32
Endpoint = <Node-A-public-IP>:51820
PersistentKeepalive = 25
💡 Why persistent keepalive? NAT/firewall state timeouts often break long-lived tunnels. Keepalives (every 25s) prevent this.
Bring up the tunnel:
wg-quick up wg0
systemctl enable --now wg-quick@wg0
Verify:
wg show
ping 10.99.0.2 # from Node A
If ping succeeds—you have a working tunnel.
Step 2: Nginx Proxy Manager (NPM) Setup
We’ll run NPM in Docker on both nodes. HA means no shared state—NPM runs independently on each node, but shares config via a shared volume (e.g., NFS, rsync, or Git).
Docker Compose
Create docker-compose.yml on both nodes:
version: '3'
services:
app:
image: 'jc21/nginx-proxy-manager:latest'
restart: unless-stopped
ports:
- '80:80'
- '81:81'
- '443:443'
volumes:
- ./data:/data
- ./letsencrypt:/etc/letsencrypt
environment:
- DB_HOST=postgres
- DB_USER=npm
- DB_PASS=supersecretpassword
- DB_NAME=npm
depends_on:
- postgres
postgres:
image: postgres:15-alpine
restart: unless-stopped
volumes:
- ./db:/var/lib/postgresql/data
environment:
- POSTGRES_USER=npm
- POSTGRES_PASSWORD=supersecretpassword
- POSTGRES_DB=npm
Run:
docker compose up -d
✅ Important: Use separate
./dataand./dbdirectories per node. Shared DB (e.g., remote PostgreSQL) is optional but recommended if you want central management.
Step 3: DNS-01 ACME Automation (Let’s Encrypt)
NPM doesn’t natively support DNS-01 challenges. But we can automate it externally using acme.sh.
Install acme.sh:
curl https://get.acme.sh | sh -s -- --install-cloudflare
# or --install-digitalocean, --install-aws, etc.
Set up API keys:
export CF_API_TOKEN="your-cloudflare-api-token"
export CF_ACCOUNT_ID="your-account-id" # optional, if using API tokens with scope
Issue a wildcard cert for *.tunnel.example.com:
~/.acme.sh/acme.sh --issue --dns dns_cf -d "*.tunnel.example.com" -d "tunnel.example.com"
Install cert to NPM:
~/.acme.sh/acme.sh --install-cert -d "*.tunnel.example.com" \
--key-file /data/ssl/key.pem \
--fullchain-file /data/ssl/fullchain.pem \
--reloadcmd "docker compose restart app -f /path/to/npm/docker-compose.yml"
🔄 Set up a cron job to auto-renew (e.g., daily):
crontab -e
# Add:
0 0 * * * "/root/.acme.sh/acme.sh" --cron --home "/root/.acme.sh"
Now, point DNS for app.tunnel.example.com → <Node-A-public-IP> and <Node-B-public-IP> (A records). DNS round-robin + keepalived (optional) gives you basic HA.
Step 4: Reverse Proxy & Zero Trust Policies
In NPM, create a Proxy Host for app.tunnel.example.com:
- Domain:
app.tunnel.example.com - Forward Hostname:
10.99.0.3(your internal backend, e.g., a dev server on Node A’s LAN) - Forward Port:
8080 - SSL: Force SSL, Use Let’s Encrypt cert (auto-detected)
- Advanced: Add mTLS or IP allowlists if needed
🔐 Zero Trust tip: For internal services, use Nginx’s
allowdirective to restrict access to known WireGuard IPs (e.g.,allow 10.99.0.0/24; deny all;).
Example Nginx config (view in NPM Advanced tab):
location / {
allow 10.99.0.0/24;
deny all;
proxy_pass http://10.99.0.3:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
Now, only clients inside the WireGuard mesh can reach your internal app—even if exposed publicly via DNS.
Step 5: Failover & Health Checks
For true HA, we need automatic failover.
Option A: Floating VIP (Keepalived)
On bare-metal/LAN deployments, use keepalived to share a virtual IP (e.g., 192.168.1.50). Node A is master; Node B becomes master if A fails.
/etc/keepalived/keepalived.conf (Node A):
vrrp_instance VI_1 {
state MASTER
interface eth0
virtual_router_id 51
priority 100
advert_int 1
authentication {
auth_type PASS
auth_pass yourpassword
}
virtual_ipaddress {
192.168.1.50/24
}
}
Node B: state BACKUP, priority 90.
🌐 Public-facing: Point DNS
Arecord to192.168.1.50(if LAN-based) or use BGP/Anycast for cloud.
Option B: DNS Round-Robin + Passive Health Checks
Set both nodes in DNS:
app.tunnel.example.com IN A 203.0.113.10
app.tunnel.example.com IN A 203.0.113.20
Then add a lightweight health check on each node:
# /usr/local/bin/npm-health.sh
curl -sf http://localhost:81/api/tokens > /dev/null && exit 0 || exit 1
Run every 30s in cron. If failing, auto-remove node from DNS (e.g., via nsupdate or cloud DNS API).
⚠️ DNS TTL < 60s recommended for fast failover.
Testing Failover
- Deploy services on Node A.
- Confirm
https://app.tunnel.example.comworks. - Simulate failure:
systemctl stop wg-quick@wg0ordocker compose down. - Watch DNS TTL expire or
keepalivedfailover. - Within 30–60s,
https://app.tunnel.example.comshould still work—served by Node B.
If not, check:
- WireGuard routes (
wg show wg0→AllowedIPs) - Nginx proxy config (forward host/port)
- Firewall (port 443/80 open on both nodes)
- Cert validity (
openssl s_client -connect app.tunnel.example.com:443)
Maintenance & Scaling
- Backups:
rsync -avz /data /etc/wireguard/ /etc/nginx-proxy-manager/to a backup node. - Updates: Update
docker-compose.yml, thendocker compose pull && docker compose up -d. - Scale later: Add more nodes (e.g., 3+), but keep mesh config simple. Avoid full mesh beyond 4 nodes—use BGP route reflectors if needed.
FAQ
Q1: Can I use this for external-facing public apps?
Yes—but only if you accept that both nodes must be publicly reachable. For truly public apps, consider Cloudflare Tunnel or a dedicated CDN (e.g., Fastly, Cloudfront). Our setup is best for controlled internal services or dev/test environments.
Q2: What if WireGuard fails but NPM stays up?
WireGuard failure means backend services are unreachable. That’s why HA at the WireGuard layer (via keepalived + mesh) is critical. NPM alone can’t route traffic to unreachable backends.
Q3: Do I need a database for NPM HA?
No. NPM’s SQLite backend is file-based and not shared across nodes. For HA, run independent NPM instances per node. If you need shared configs, use a remote PostgreSQL and sync via CI/CD or manual export/import.
Q4: How do I add more internal services?
Just add new proxy hosts in NPM pointing to new WireGuard IPs (e.g., 10.99.0.10, 10.99.0.11). Update WireGuard AllowedIPs on peers to route them.
Q5: Is this cheaper than Cloudflare Tunnel?
Yes—for 2+ nodes. Cloudflare Tunnel Free = 1 tunnel. Pro = $5/mo per tunnel. Our cost: one VPS ($5–$10/mo) × 2 + domain + DNS = ~$10–$20/mo, but you own the infra forever. For 5+ tunnels, self-hosting wins.
Final Thoughts
This isn’t a drop-in replacement for Cloudflare Tunnel. It’s a different tradeoff: more responsibility, but full control, transparency, and no surprise fees. If you’re comfortable with Linux, Docker, and networking fundamentals, this setup will outperform most third-party tunnels in uptime and cost.
It’s not for everyone—but if you value resilience, privacy, and zero vendor lock-in, it’s absolutely worth the effort.
Thanks for reading. If you build this and hit a snag, drop me a line—I’m always happy to help.
— mahbuburriad.com