Reverse Proxy Setup
Running rVPN behind a reverse proxy lets you terminate TLS at the proxy layer, serve a decoy website at the root, and distribute connections across multiple server instances. The proxy forwards only /api/* traffic to rVPN — everything else can serve a normal-looking website.
Server Configuration
Section titled “Server Configuration”When running behind a proxy, omit the TLS certificate and bind to a local port:
[server]bind_address = "127.0.0.1:8443"websocket_path = "/api/v1/ws"identity_key_file = "/etc/rvpn/server_identity.key"# No tls_cert_file / tls_key_file — TLS is terminated at the proxyThe proxy handles TLS and forwards all /api/* requests to 127.0.0.1:8443. This covers the main WebSocket endpoint (/api/v1/ws), the TUN endpoint (/api/v1/ws/tun), and the DNS endpoint (/api/v1/ws/dns).
Caddy is the recommended proxy — it obtains and renews TLS certificates automatically and handles WebSocket upgrades without additional configuration.
Simple (VPN only)
Section titled “Simple (VPN only)”{ # Global server options — required for long-lived WebSocket connections. # These don't affect normal HTTP traffic, only idle connection behavior. servers { timeouts { idle 24h # WebSocket connections are long-lived } keepalive_idle 1m # Start TCP keepalive probes after 1m idle keepalive_interval 30s keepalive_count 5 }}
your-domain.com { tls { protocols tls1.3 }
handle /api/* { reverse_proxy 127.0.0.1:8443 { transport http { read_timeout 0 write_timeout 0 keepalive 0s # no upstream connection reuse } flush_interval -1 # critical for WebSocket — disables response buffering } }}With Decoy Site
Section titled “With Decoy Site”Serve a normal-looking website at the root. Only /api/* is forwarded to rVPN. Casual visitors and automated scanners see a regular website.
{ # Global server options — required for long-lived WebSocket connections. servers { timeouts { idle 24h } keepalive_idle 1m keepalive_interval 30s keepalive_count 5 }}
your-domain.com { tls { protocols tls1.3 }
# VPN traffic — all sub-paths under /api/ go to rvpn-server handle /api/* { reverse_proxy 127.0.0.1:8443 { transport http { read_timeout 0 write_timeout 0 keepalive 0s } flush_interval -1 # critical for WebSocket — disables response buffering } }
# Decoy site — everything else serves static files handle { root * /var/www/html file_server }}Place your decoy site files in /var/www/html. Any valid static HTML site works — a blog, a landing page, a portfolio. The more realistic it looks, the better.
Tip: Caddy automatically provisions a Let’s Encrypt certificate for
your-domain.com. No manualcertbotrequired.
Multiple Servers (with load balancing)
Section titled “Multiple Servers (with load balancing)”{ servers { idle_timeout 24h keepalive_idle 1m keepalive_interval 30s keepalive_count 5 }}
your-domain.com { tls { protocols tls1.3 }
handle /api/* { reverse_proxy 10.0.0.1:8443 10.0.0.2:8443 10.0.0.3:8443 { lb_policy ip_hash # sticky by client IP — required for per-session ratchet state # rvpn-server has no /healthz endpoint. Use passive health checks: # a backend is marked down after unresponsiveness during a real request. fail_duration 30s max_fails 3
transport http { read_timeout 0 write_timeout 0 } flush_interval -1 # critical for WebSocket — disables response buffering } }
handle { root * /var/www/html file_server }}lb_policy ip_hash is important: each client’s X3DH + Double Ratchet session is tied to a specific server instance, so connections from the same client IP must always reach the same backend.
Nginx doesn’t provision certificates itself, but the certbot --nginx plugin handles both initial issuance and automatic renewal — it edits your config to add the certificate paths and its systemd timer reloads nginx after each renewal. No manual renewal hook needed.
sudo apt install certbot python3-certbot-nginxsudo certbot --nginx -d your-domain.comCertbot will add the ssl_certificate lines to your config automatically. The examples below include them explicitly so the full config is clear.
Simple (VPN only)
Section titled “Simple (VPN only)”server { listen 443 ssl; server_name your-domain.com;
ssl_certificate /etc/letsencrypt/live/your-domain.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/your-domain.com/privkey.pem; ssl_protocols TLSv1.3;
# Proxy all /api/* to rvpn-server. # Prefix match covers /api/v1/ws, /api/v1/ws/tun, and /api/v1/ws/dns. location /api/ { proxy_pass http://127.0.0.1:8443; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_read_timeout 0; # never time out — VPN connections are long-lived }}
# Redirect HTTP → HTTPSserver { listen 80; server_name your-domain.com; return 301 https://$host$request_uri;}With Decoy Site
Section titled “With Decoy Site”server { listen 443 ssl; server_name your-domain.com;
ssl_certificate /etc/letsencrypt/live/your-domain.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/your-domain.com/privkey.pem; ssl_protocols TLSv1.3;
# VPN — all paths under /api/ forwarded to rvpn-server location /api/ { proxy_pass http://127.0.0.1:8443; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_read_timeout 0; }
# Decoy site — static files for everything else root /var/www/html; location / { try_files $uri $uri/ =404; }}
# Redirect HTTP → HTTPSserver { listen 80; server_name your-domain.com; return 301 https://$host$request_uri;}Note:
proxy_read_timeout 0disables nginx’s read timeout for the proxied connection. Without this, nginx will close idle WebSocket connections after 60 seconds (the default).
HAProxy (Multi-Server Load Balancing)
Section titled “HAProxy (Multi-Server Load Balancing)”HAProxy is well-suited for distributing connections across multiple rVPN server instances. Use balance source (IP hash) so each client always lands on the same backend — essential since session ratchet state is not shared between instances.
/etc/haproxy/haproxy.cfg
Section titled “/etc/haproxy/haproxy.cfg”global log /dev/log local0 maxconn 50000 ssl-default-bind-options ssl-min-ver TLSv1.3 # TLS 1.3 only
defaults log global mode http option httplog timeout connect 10s timeout client 30s timeout server 30s timeout tunnel 1h # WebSocket connections — must be long
frontend https bind *:443 ssl crt /etc/haproxy/certs/your-domain.pem alpn h2,http/1.1 ssl-min-ver TLSv1.3 bind *:80 http-request redirect scheme https unless { ssl_fc }
# Route /api/* to the rvpn cluster acl is_vpn path_beg /api/ use_backend rvpn_cluster if is_vpn
# Everything else → decoy site default_backend decoy_site
backend rvpn_cluster balance source # IP hash — sticky sessions required for ratchet state option forwardfor option http-server-close
# rvpn-server has no /healthz endpoint. Fall back to plain L4 checks — # HAProxy will mark a backend down after `fall` failed TCP connects. server rvpn1 10.0.0.1:8443 check inter 10s rise 2 fall 3 server rvpn2 10.0.0.2:8443 check inter 10s rise 2 fall 3 server rvpn3 10.0.0.3:8443 check inter 10s rise 2 fall 3
backend decoy_site # Point at a separate host or a distinct port. If http_port = 80 is set in # server.toml AND rvpn-server runs on the same box, sending here loops back # into the built-in 404 page. server web 127.0.0.1:8080 checkTLS Certificate for HAProxy
Section titled “TLS Certificate for HAProxy”HAProxy expects the certificate and private key concatenated into a single PEM file:
cat /etc/letsencrypt/live/your-domain.com/fullchain.pem \ /etc/letsencrypt/live/your-domain.com/privkey.pem \ > /etc/haproxy/certs/your-domain.pemchmod 600 /etc/haproxy/certs/your-domain.pemAdd to your certbot renewal hook:
#!/bin/bashcat /etc/letsencrypt/live/your-domain.com/fullchain.pem \ /etc/letsencrypt/live/your-domain.com/privkey.pem \ > /etc/haproxy/certs/your-domain.pemsystemctl reload haproxychmod +x /etc/letsencrypt/renewal-hooks/deploy/haproxy-reload.shApplying Changes
Section titled “Applying Changes”haproxy -c -f /etc/haproxy/haproxy.cfg # validate configsystemctl reload haproxyWhy IP-Sticky Load Balancing
Section titled “Why IP-Sticky Load Balancing”rVPN establishes an X3DH key agreement and Double Ratchet session per connection. This session state lives in memory on the specific server instance that handled the handshake. If a reconnecting client is routed to a different backend, the handshake must repeat — the old session is lost.
With ip_hash (Caddy) or balance source (HAProxy), clients consistently reach the same backend across reconnects. If that backend goes down, they will re-handshake on a different one automatically.