On this page
Introduction
Zero‑Trust networking has become the default for remote work, SaaS integrations, and edge‑centric workloads. Cloudflare Tunnel (formerly Argo Tunnel) does an excellent job of exposing internal services without opening inbound ports, but many organisations prefer a self‑hosted alternative for cost, compliance, or custom‑logic reasons.
Running a single tunnel server is a single point of failure. In a production environment you need a disaster‑recovery blueprint that automatically redirects traffic when a node goes down—without manual intervention and without breaking existing client sessions.
In this tutorial we’ll wire together two battle‑tested Linux tools:
- Keepalived – provides VRRP‑based virtual IP failover.
- HAProxy – does the actual TCP/HTTP load‑balancing and health‑checking of tunnel back‑ends.
By the end of the guide you’ll have:
- Two identical tunnel nodes (e.g.,
tunnel‑01andtunnel‑02). - A floating virtual IP (VIP) that always points to the active node.
- HAProxy listening on the VIP, routing traffic to the healthy tunnel daemon.
- Automatic promotion of the standby node when the primary fails.
The result is a high‑availability, zero‑trust tunnel that survives host crashes, network blips, or even full‑site outages.
Prerequisites
| Item | Minimum | Recommended |
|---|---|---|
| OS | Ubuntu 20.04 LTS or Debian 11 | Ubuntu 22.04 LTS |
| CPUs | 2 vCPU | 4 vCPU |
| RAM | 2 GB | 4 GB |
| Network | Two NICs (one for management, one for client traffic) | Separate VLANs for management and data |
| Packages | keepalived, haproxy, curl, git |
keepalived, haproxy, jq, net-tools |
| Access | Root or sudo on both nodes | Password‑less sudo for the admin user |
| Tunnel software | Any Cloudflare‑Tunnel‑compatible daemon (e.g., cloudflared, inlets, caddy with tls proxy) |
Same as left |
Both tunnel nodes must be identical in hardware, OS version, and installed packages. Keep them in the same layer‑2 domain (or use a layer‑2 overlay like VXLAN) so that the VRRP advertisement can be exchanged without additional routing tricks.
Architecture Overview
+-------------------+ +-------------------+
| Client / Edge | TCP | HAProxy (VIP) |
| (Browser, API) | <------> | 10.0.0.100 (VRRP) |
+-------------------+ +-------------------+
^ ^
| |
(Internet) +-----------+
| Keepalived|
+-----------+
|
+-------------------+-------------------+
| |
+-------------+ +-------------+
| tunnel-01 | | tunnel-02 |
| (active) | | (standby) |
+-------------+ +-------------+
- HAProxy binds to the VIP (
10.0.0.100) and forwards inbound traffic to the tunnel daemon on the local host (127.0.0.1:8443). - Keepalived runs VRRP on both nodes. The master claims the VIP; the backup monitors the master and takes over when it disappears.
- The tunnel daemon (e.g.,
cloudflared) establishes an outbound connection to the Zero‑Trust controller, exposing internal services.
Step 1 – Install Keepalived
Run the following on both nodes:
sudo apt update
sudo apt install -y keepalived
Verify the service is active:
sudo systemctl status keepalived
If the service fails to start, check /etc/keepalived/keepalived.conf for syntax errors (the file doesn’t exist yet, so you’ll create it next).
Step 2 – Configure VRRP with Keepalived
Create /etc/keepalived/keepalived.conf on both nodes. The only difference will be the priority value.
Master (tunnel-01)
vrrp_instance VI_1 {
state MASTER
interface eth0 # management NIC
virtual_router_id 51
priority 150 # higher = master
advert_int 1
authentication {
auth_type PASS
auth_pass 3cR3tK3y
}
virtual_ipaddress {
10.0.0.100/24 dev eth0 label eth0:vip
}
track_interface {
eth0
}
}
Backup (tunnel-02)
vrrp_instance VI_1 {
state BACKUP
interface eth0
virtual_router_id 51
priority 100 # lower than master
advert_int 1
authentication {
auth_type PASS
auth_pass 3cR3tK3y
}
virtual_ipaddress {
10.0.0.100/24 dev eth0 label eth0:vip
}
track_interface {
eth0
}
}
Key points
| Parameter | Meaning |
|---|---|
virtual_router_id |
Must be identical on both nodes; 1‑255 range. |
priority |
Determines which node becomes master. |
advert_int |
VRRP advertisement interval (seconds). |
auth_pass |
Simple shared secret; keep it strong. |
track_interface |
If the interface disappears, the node drops out of the VRRP cluster. |
Reload Keepalived:
sudo systemctl restart keepalived
sudo systemctl enable keepalived
Check that the VIP is present on the master:
ip addr show eth0 | grep 10.0.0.100
On the backup you should not see the VIP. If you do, there is a priority clash—double‑check the config.
Step 3 – Install HAProxy
Again, on both nodes:
sudo apt install -y haproxy
We will configure HAProxy to listen on the VIP only when it is present. HAProxy can be started on both nodes; the one without the VIP will simply bind to 0.0.0.0:0 (i.e., not listen), which is harmless.
HAProxy Global Settings
Create /etc/haproxy/haproxy.cfg (replace existing content):
global
log /dev/log local0
log /dev/log local1 notice
daemon
maxconn 2000
tune.ssl.default-dh-param 2048
defaults
log global
mode tcp
option tcplog
timeout connect 5s
timeout client 30s
timeout server 30s
Frontend & Backend for the Tunnel
frontend tunnel_front
bind 10.0.0.100:443 # VIP, HTTPS port
default_backend tunnel_back
backend tunnel_back
# Health check: connect to the local tunnel daemon
option tcp-check
tcp-check connect port 8443
tcp-check send PING\r\n
tcp-check expect string PONG
server tunnel01 127.0.0.1:8443 check fall 3 rise 2 weight 10
Explanation
- The frontend binds only to the VIP. If the node does not own the VIP, HAProxy will fail to start—this is fine because the backup will never own it.
- The backend points to the tunnel daemon running locally on port
8443. Adjust if your daemon listens elsewhere. - The health check uses a simple TCP ping. Replace with an HTTP request (
option httpchk GET /health) if your tunnel offers an HTTP health endpoint.
Reload HAProxy:
sudo systemctl restart haproxy
sudo systemctl enable haproxy
Verify the listener:
sudo ss -ltnp | grep 443
You should see HAProxy bound to 10.0.0.100:443 on the master node.
Step 4 – Deploy the Self‑Hosted Tunnel Daemon
The tutorial is agnostic to the specific tunnel implementation. Below we use cloudflared as an example, but you can swap inlets-pro or any compatible binary.
# Install cloudflared (Ubuntu example)
curl -L https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64.deb -o cloudflared.deb
sudo dpkg -i cloudflared.deb
Create a service file /etc/systemd/system/cloudflared.service:
[Unit]
Description=Cloudflare Tunnel Daemon
After=network.target
[Service]
Type=simple
User=root
ExecStart=/usr/local/bin/cloudflared tunnel run my-tunnel
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
Replace my-tunnel with your tunnel UUID or name. Enable and start:
sudo systemctl daemon-reload
sudo systemctl enable cloudflared
sudo systemctl start cloudflared
Confirm it’s listening on 8443 (or the port you configured in HAProxy):
sudo ss -ltnp | grep 8443
Tip: If you use a daemon that only accepts inbound connections (e.g., inlets-pro server), flip the HAProxy direction accordingly: HAProxy becomes the client to the tunnel server.
Step 5 – Test Automatic Failover
-
Baseline – From a client machine, curl the VIP:
Bash curl -k https://10.0.0.100You should receive the response from the internal service exposed through the tunnel.
-
Force Master Failure – Stop Keepalived on the master:
Bash sudo systemctl stop keepalivedWithin a few seconds the backup should claim the VIP.
-
Verify – On the backup node:
Bash ip addr show eth0 | grep 10.0.0.100HAProxy should now be listening on the VIP:
Bash sudo ss -ltnp | grep 443 -
Client Test Again – Re‑run the curl from step 1. The request should succeed, proving that traffic seamlessly switched to the standby node.
-
Return to Normal – Restart Keepalived on the original master. By default, it will regain the VIP (pre‑empt mode). If you prefer manual takeover, set
nopreemptin the VRRP config.
Step 6 – Monitoring & Alerting
| Tool | What it monitors | Example config |
|---|---|---|
keepalived logs |
VRRP state changes | journalctl -u keepalived -f |
haproxy stats socket |
Backend health, connection counts | listen stats 0.0.0.0:8404 in haproxy.cfg |
systemd watchdog |
Service restarts | systemctl status haproxy |
| Prometheus + node_exporter | CPU, memory, network latency | Scrape /metrics from both nodes |
Simple alert via systemd (master node):
Create /etc/systemd/system/keepalived-fail.service:
[Unit]
Description=Alert when Keepalived stops
After=network.target
[Service]
ExecStart=/usr/bin/logger "⚠️ Keepalived stopped on $(hostname)"
Restart=no
[Install]
WantedBy=multi-user.target
Enable a path unit to trigger it when the keepalived service file disappears:
[Unit]
Description=Watch Keepalived unit file
[Path]
PathChanged=/run/systemd/unit/keepalived.service
[Install]
WantedBy=multi-user.target
This is a lightweight way to push a syslog entry to your central logging system.
Comparison: Keepalived + HAProxy vs. Alternative HA Solutions
| Feature | Keepalived + HAProxy | Corosync + Pacemaker | Kubernetes Service (LoadBalancer) |
|---|---|---|---|
| Complexity | Low – simple config files, familiar tooling | Medium – requires quorum, resource agents | High – needs a full K8s cluster |
| VRRP support | Native, proven in networking gear | Emulated via resources | Not applicable (uses kube-proxy) |
| Health checks | HAProxy’s flexible TCP/HTTP checks | Pacemaker resource monitors | Liveness probes in pods |
| Failover time | 1–3 seconds (advert_int + detection) | 2–5 seconds (cluster consensus) | 1–2 seconds (IPVS) |
| Scalability | Ideal for 2‑5 nodes | Good for large clusters | Excellent for many pods, but adds overhead |
| Learning curve | Minimal for sysadmins familiar with iptables |
Moderate – need to understand CRM concepts | High – requires K8s expertise |
| Licensing | BSD (Keepalived) + GPL (HAProxy) | GPL (Corosync) + LGPL (Pacemaker) | Apache 2.0 (K8s) |
For a two‑node tunnel setup, Keepalived + HAProxy remains the sweet spot: quick to deploy, low overhead, and fully transparent to the upstream Zero‑Trust controller.
FAQ
1. Can I use IPv6 for the virtual IP?
Yes. Set virtual_ipaddress { 2001:db8::100/64 dev eth0 label eth0:vip } in the Keepalived config and adjust HAProxy’s bind line accordingly.
2. What happens to existing TCP connections during a failover?
VRRP only moves the IP; existing connections on the failed node are lost. To mitigate, keep session state in a shared cache (Redis) or use a short client‑side retry interval.
3. Do I need to configure nopreempt if I want manual control?
Set nopreempt inside the vrrp_instance block on the backup node. Then the original master will stay master until you manually force a takeover.
4. How do I secure the HAProxy management socket?
Add stats socket /var/run/haproxy.sock level admin expose-fd listeners and restrict file permissions to 640 with ownership haproxy:haproxy. Use socat with Unix socket for local queries.
5. Can I add a third node for extra redundancy?
Absolutely. Just replicate the Keepalived config on the third server, give it a lower priority, and add another server line in the HAProxy backend. VRRP will elect the highest‑priority node as master.
Conclusion
Building a disaster‑recovery‑ready Zero‑Trust tunnel doesn’t have to involve proprietary SaaS. By pairing Keepalived (for fast VRRP failover) with HAProxy (for robust health‑checking and traffic routing), you get a lean, battle‑tested solution that fits neatly into any Linux‑centric stack.
The steps above cover installation, configuration, testing, and monitoring. Adjust the IP ranges, ports, and tunnel daemon to match your environment, and you’ll have an automated failover pipeline that keeps internal services reachable even when a host disappears.
For more deep‑dive articles on resilient networking and cloud‑native tooling, check out the tutorials at mahbuburriad.com. Happy tunneling!