Reverse Proxy Setup¶
Running r-vpn 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 r-vpn — everything else can serve a normal-looking website.
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 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¶
Caddy is the recommended proxy — it obtains and renews TLS certificates automatically and handles WebSocket upgrades without additional configuration.
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¶
Serve a normal-looking website at the root. Only /api/* is forwarded to r-vpn. 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)¶
{
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
health_uri /healthz
health_interval 10s
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¶
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.
Certbot 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)¶
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;
}
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 → HTTPS
server {
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)¶
HAProxy is well-suited for distributing connections across multiple r-vpn 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¶
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
# Health check: rvpn-server returns 200 on GET /healthz
option httpchk GET /healthz
http-check expect status 200
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
server web 127.0.0.1:80 check
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.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
Applying Changes¶
Why IP-Sticky Load Balancing¶
r-vpn 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.