Skip to content

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.


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 proxy

The 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.

{
# 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
}
}
}

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 manual certbot required.

{
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.

Terminal window
sudo apt install certbot python3-certbot-nginx
sudo certbot --nginx -d your-domain.com

Certbot will add the ssl_certificate lines to your config automatically. The examples below include them explicitly so the full config is clear.

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 → HTTPS
server {
listen 80;
server_name your-domain.com;
return 301 https://$host$request_uri;
}
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 → HTTPS
server {
listen 80;
server_name your-domain.com;
return 301 https://$host$request_uri;
}

Note: proxy_read_timeout 0 disables nginx’s read timeout for the proxied connection. Without this, nginx will close idle WebSocket connections after 60 seconds (the default).


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.

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 check

HAProxy expects the certificate and private key concatenated into a single PEM file:

Terminal window
cat /etc/letsencrypt/live/your-domain.com/fullchain.pem \
/etc/letsencrypt/live/your-domain.com/privkey.pem \
> /etc/haproxy/certs/your-domain.pem
chmod 600 /etc/haproxy/certs/your-domain.pem

Add to your certbot renewal hook:

/etc/letsencrypt/renewal-hooks/deploy/haproxy-reload.sh
#!/bin/bash
cat /etc/letsencrypt/live/your-domain.com/fullchain.pem \
/etc/letsencrypt/live/your-domain.com/privkey.pem \
> /etc/haproxy/certs/your-domain.pem
systemctl reload haproxy
Terminal window
chmod +x /etc/letsencrypt/renewal-hooks/deploy/haproxy-reload.sh
Terminal window
haproxy -c -f /etc/haproxy/haproxy.cfg # validate config
systemctl reload haproxy

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.