Skip to content

Server Configuration Reference

Complete reference for all fields in server.toml.


All fields live under a [server] section, or at the root level (both are accepted).

FieldTypeDefaultDescription
bind_addressstring"0.0.0.0:443"Address and port to listen on. Use "0.0.0.0:443" for all interfaces or "127.0.0.1:8443" for localhost-only (behind a reverse proxy).
tls_cert_filepath"certs/cert.pem"Path to the TLS certificate (PEM format).
tls_key_filepath"certs/key.pem"Path to the TLS private key (PEM format).
identity_key_filepath"server_identity.key"Path to the server’s X3DH identity key. Generated with rvpn-server keygen.
websocket_pathstring"/api/v1/ws"WebSocket endpoint path. Change this per-deployment to avoid detection.
http_portinteger(disabled)If set, also listen on this port for plain HTTP (e.g. 80) for ACME challenges and HTTP→HTTPS redirects.
redirect_http_to_httpsbooltrueWhen http_port is set, redirect all HTTP requests to HTTPS. Non-WebSocket requests receive a hardcoded nginx-style 404 to blend in with a plain web server.
prekey_bundle_filepath(none)If set, load X3DH keys from this file instead of generating new ones. Use this to restore a backed-up identity.

Controls how many connections an IP address can establish.

FieldTypeDefaultDescription
max_connections_per_ipinteger500Maximum simultaneous connections from a single IP address.
max_handshakes_per_minuteinteger2000Maximum new handshake attempts from a single IP per minute. Helps prevent brute-force probing.
[server.rate_limit]
max_connections_per_ip = 10
max_handshakes_per_minute = 20

Preflight hints used only for startup warnings — the server does not program NAT or hand out IPs from this section. Actual client IPs come from [server.tun].tun_ip; NAT is set up by your operator (see the iptables/pf examples in Server Installation).

FieldTypeDefaultDescription
nat_enabledbooltrueSet to false to suppress the “clients may not reach the internet” startup warning if your egress is handled elsewhere.
dhcp_rangestring"10.200.0.0/24"Subnet the server expects NAT MASQUERADE rules to cover. On boot the server greps iptables-save for a rule matching this subnet and warns if none is found.
[server.network]
nat_enabled = true
dhcp_range = "10.200.0.0/24"

Settings for true TUN-to-TUN mode. When enabled, the server creates a TUN interface and routes client packets through it.

FieldTypeDefaultDescription
enabledboolfalseEnable TUN mode. When false, server uses relay mode (Brook-style).
tun_ipstring"10.200.0.1/24"TUN interface IP address and subnet prefix. Client IPs are handed out from this subnet.
mtuinteger1420MTU for the TUN interface.
interface_namestring"tun0"Name of the TUN interface to create.
dns_serverslist["8.8.8.8"]DNS resolvers pushed to TUN clients over the tunnel.
[server.tun]
enabled = true
tun_ip = "10.200.0.1/24"
mtu = 1420
interface_name = "tun0"

Automatic TLS certificates via Let’s Encrypt — no reverse proxy required. The server obtains and renews certificates itself using the TLS-ALPN-01 challenge, so the same :443 listener handles both live traffic and the ACME dance. No :80 port needed.

FieldTypeDefaultDescription
enabledboolfalseEnable ACME issuance. When true, tls_cert_file must not exist on disk — the server refuses to guess which cert to serve.
domainslist of strings[]Fully-qualified hostnames to obtain certificates for. Must have at least one entry when enabled. First entry is the primary CN; the rest ride along as SANs.
contactslist of strings[]Contact URIs for the ACME account, typically ["mailto:[email protected]"]. Let’s Encrypt uses these for expiry warnings if a renewal ever stalls.
cache_dirpath"acme-cache"Directory to persist the ACME account key and issued certs. Must be writable. Keep this across restarts to avoid rate limits.
stagingboolfalseUse Let’s Encrypt’s staging directory. Certs won’t be trusted by browsers, but issuance doesn’t count against production rate limits. Use while testing DNS + firewall setup.

Prerequisites — check these before setting enabled = true:

  1. Every hostname in domains must resolve (A/AAAA) to this server’s public IP.
  2. Inbound TCP :443 must be reachable from Let’s Encrypt’s validators. If you’re behind a NAT gateway, forward the port.
  3. cache_dir must be writable and persistent — losing it forces re-issuance and burns rate-limit budget.
[server]
bind_address = "0.0.0.0:443"
# tls_cert_file / tls_key_file omitted — ACME provides the cert.
[server.acme]
enabled = true
domains = ["hk.example.com"]
contacts = ["mailto:[email protected]"]
cache_dir = "/var/lib/rvpn/acme"

First-boot latency is ~5–15 seconds while the ACME dance completes. Renewal happens automatically ~30 days before expiry; the driver task logs each event at info level (ACME event: …).

If Let’s Encrypt is unreachable (network glitch, transient LE outage), the driver logs the error and retries — a failed renewal does not tear down the running listener. Watch the logs for repeated ACME error: lines if certs stop rotating.

Renewals are fully automatic and require no operator action.

  • Trigger — the driver monitors the cert’s notAfter and starts a fresh order ~30 days before expiry.
  • Hot swap — the new cert replaces the old one in the in-memory resolver. Existing TLS connections keep the cert they were established with; new handshakes pick up the fresh one. No restart, no dropped connections.
  • Persistence — the fresh cert is written to cache_dir before the swap, so a restart mid-window loads the already-issued cert instead of triggering a new order.
  • No external timer — nothing like certbot.timer exists. The whole state machine lives inside the rvpn-server process.

To confirm a renewal fired normally (~60 days after first issuance), grep the journal:

Terminal window
sudo journalctl -u rvpn-server --since "60d ago" | grep "ACME event"

Expect to see the initial NewAccountDeployedNewCertCertCacheStore chain, plus a second DeployedNewCert / CertCacheStore pair for the renewal.

The most painful failure mode with ACME isn’t the code — it’s Let’s Encrypt’s rate limits. Three matter for this integration:

LimitWhat triggers itHow long you’re locked out
Failed authorizations5 failed TLS-ALPN-01 challenges per hostname per hour. A misconfigured firewall, DNS, or ALPN dispatcher will burn through this in minutes.60 minutes from the last failure
Duplicate certificates5 successful issuances for the exact same set of hostnames per week. Triggered by wiping cache_dir and re-issuing repeatedly.168 hours from the fifth issuance
New orders300 per account per 3 hours. Only relevant if the driver enters a tight retry loop across many domains.3 hours

Full details at Let’s Encrypt rate limits.

When rvpn-server sees an LE 429 response, it downgrades the log line from ERROR to WARN and prints the retry-after time so operators know when to look again:

WARN ACME rate-limited by Let's Encrypt; next retry allowed after 2026-07-11 06:01:16 UTC
Section titled “Staging-first workflow (recommended for new setups)”

Let’s Encrypt’s staging environment issues untrusted certs but has no rate limits worth worrying about. Use it to confirm your DNS + firewall + rvpn-server config are correct before flipping to production:

  1. Start with staging = true:
    [server.acme]
    enabled = true
    staging = true
    domains = ["hk.example.com"]
    contacts = ["mailto:[email protected]"]
    cache_dir = "/var/lib/rvpn/acme-staging"
  2. Restart rvpn-server, watch the journal for ACME event: DeployedNewCert followed by ACME event: CertCacheStore. If that pair fires, everything works.
  3. Verify externally — the browser will complain about the untrusted staging cert; that’s expected:
    Terminal window
    echo | openssl s_client -connect hk.example.com:443 -servername hk.example.com 2>&1 | grep -Ei "issuer|verify return code"
    Issuer should be (STAGING) Let's Encrypt.
  4. Flip to production — change staging = false and use a fresh cache_dir (staging certs live in a different account and cache):
    staging = false
    cache_dir = "/var/lib/rvpn/acme"
  5. Restart, wait, verify. Issuer is now the real Let's Encrypt.

On the first production boot with an empty cache_dir, rvpn-server prints a WARN pointing exactly at this workflow — so if you ever see that warning and haven’t tested with staging first, you know what to try before you burn your rate-limit budget.

Do not wipe cache_dir casually. Every deletion + re-issuance counts against the 5-per-week duplicate-certificate limit. Keep the directory across restarts, and back it up alongside your other server state.


[server]
bind_address = "0.0.0.0:443"
tls_cert_file = "/etc/rvpn/certs/cert.pem"
tls_key_file = "/etc/rvpn/certs/key.pem"
identity_key_file = "/etc/rvpn/server_identity.key"
websocket_path = "/api/v1/ws"
http_port = 80
redirect_http_to_https = true
[server.rate_limit]
max_connections_per_ip = 500
max_handshakes_per_minute = 2000
[server.network]
nat_enabled = true
dhcp_range = "10.200.0.0/24"
[server.tun]
enabled = true
tun_ip = "10.200.0.1/24"
mtu = 1420
interface_name = "tun0"
dns_servers = ["1.1.1.1", "8.8.8.8"]

The server’s identity key is what clients pin on first connect. Rotate it by generating a new keypair and publishing a fresh prekey bundle that is signed by the previous identity, so already-pinned clients can accept the rotation without a manual override.

Terminal window
# Keep the old identity around long enough to sign the rotation
mv server_identity.key old_identity.key
# Generate the new one
rvpn-server keygen --output server_identity.key
# Publish a v2 bundle signed by the old identity
rvpn-server prekey-bundle \
--identity server_identity.key \
--output prekey-bundle.json \
--rotate-from old_identity.key \
--from-version 1

Both --rotate-from and --from-version must be supplied together — the server refuses to publish an unsigned rotation. See Server Identity Pinning for how clients handle the rotated bundle.


When running behind Caddy, nginx, or another TLS-terminating reverse proxy, set bind_address to a localhost port and let the proxy handle TLS. Point the proxy’s WebSocket forward to the same path:

[server]
bind_address = "127.0.0.1:8443"
tls_cert_file = "" # not used — proxy handles TLS
tls_key_file = ""
websocket_path = "/api/v1/ws"

Caddy example:

your.domain.com {
handle /api/* {
reverse_proxy 127.0.0.1:8443
}
handle {
root * /var/www/html
file_server
}
}

The path matcher must be /api/* (prefix match), not /api (exact match only).