Skip to content

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

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.

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.

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 0 disables 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
chmod +x /etc/letsencrypt/renewal-hooks/deploy/haproxy-reload.sh

Applying Changes

haproxy -c -f /etc/haproxy/haproxy.cfg   # validate config
systemctl reload haproxy

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.