Mahbubur Riad
Back to blog
Hosting & Server 6 min read

Deploying a Redundant Self‑Hosted Cloudflare Tunnel Alternative with WireGuard and Nginx Proxy Manager: High‑Availability Guide

Jun 16, 2026 · Mahbubur Riad

Build a redundant, self-hosted tunnel alternative using WireGuard + Nginx Proxy Manager for Zero Trust access — with HA failover, DNS automation, and real-world config examples.

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-quick and docker-compose.yml examples

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-dns or provider-specific)
  • Optional: keepalived for floating VIP (if using bare-metal/LAN)

Traffic flow:

Text
[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+):

Bash
apt update && apt install -y wireguard

Generate keys on Node A (primary):

Bash
umask 077
wg genkey | tee privatekey | wg pubkey > publickey

Do the same on Node B (secondary):

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

INI
[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:

INI
[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:

Bash
wg-quick up wg0
systemctl enable --now wg-quick@wg0

Verify:

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

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

Bash
docker compose up -d

Important: Use separate ./data and ./db directories 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:

Bash
curl https://get.acme.sh | sh -s -- --install-cloudflare
# or --install-digitalocean, --install-aws, etc.

Set up API keys:

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

Bash
~/.acme.sh/acme.sh --issue --dns dns_cf -d "*.tunnel.example.com" -d "tunnel.example.com"

Install cert to NPM:

Bash
~/.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):

Bash
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 allow directive 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):

NGINX
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):

CONF
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 A record to 192.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:

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

Bash
# /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

  1. Deploy services on Node A.
  2. Confirm https://app.tunnel.example.com works.
  3. Simulate failure: systemctl stop wg-quick@wg0 or docker compose down.
  4. Watch DNS TTL expire or keepalived failover.
  5. Within 30–60s, https://app.tunnel.example.com should still work—served by Node B.

If not, check:

  • WireGuard routes (wg show wg0AllowedIPs)
  • 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, then docker 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

Related

Related posts