VPN server monitoring: what to check, how often, from where
VPN server monitoring means proving, from outside, that a real client can complete the protocol handshake and send traffic through the server. Around that core check you watch what breaks it: expiring certificates, DNS, a blocked IP and the host itself. Run the handshake check every 1 to 5 minutes from more than one network, confirm a failure before you page anyone, and treat an open port as a hint, not proof that the server works.
Tahir Mehmood7 min read
This is the reference I point people to when they ask how to monitor a VPN fleet. It is protocol-neutral: the same layers apply to WireGuard, OpenVPN, IKEv2, OpenConnect and the Xray and sing-box family. Each section starts with the short answer, then the commands.
What should I check on a VPN server?
Check seven things: that the host is reachable, that a real client can connect and pass traffic, how fast that path is, when certificates expire, that DNS works, whether the IP is blocked where your users are, and the health of the host itself. Here is the whole list in one table. The rest of the post explains each row.
| Check | What it catches | How I run it | How often |
|---|---|---|---|
| Reachability | Host down, firewall rule, routing | ICMP ping, TCP connect | Every minute |
| Real handshake and traffic | Wrong keys, dead VPN process, broken NAT, expired certs | A real client with a monitoring user | Every 1 to 5 minutes |
| Latency and throughput | Overloaded server, bad route, throttling | Timing through the tunnel, iperf3 | Latency on every check, throughput daily |
| Certificate expiry | TLS certs, OpenVPN CA, server and client certs, CRL | openssl, blackbox_exporter | Daily |
| DNS | Hostname not resolving, dead resolver on the server | dig from outside and through the tunnel | Every few minutes |
| IP blocking | A censor blocking the IP, the SNI or the protocol | Tests from inside the affected country | Hourly, and on user reports |
| Host health | Full disk, memory, conntrack table, clock, crash loops | node_exporter, systemd | Every 15 to 60 seconds |
The second row matters most, and it is the one most setups skip.
Why is a port check not enough for a VPN server?
An open port tells you a process is listening, not that a client can connect, because most VPN protocols are designed to say nothing to a client that cannot authenticate. WireGuard drops invalid packets silently, so a UDP probe gets no answer from a healthy server and no answer from a broken one. I wrote up that case in detail in why WireGuard port monitoring misses real outages. QUIC-based protocols such as Hysteria2 and TUIC behave the same way on UDP (the Hysteria2 explainer covers why).
TCP protocols have the opposite problem: they answer too well. An Xray server with REALITY passes unauthenticated connections to the site in its target setting (called dest in older configs), so any TCP or TLS check sees a real website. A TLS certificate check against a REALITY port reports the certificate of the site you borrowed, which tells you nothing about your own server. OpenVPN with tls-crypt drops every packet that isn't protected with its shared key. Trojan and VLESS behind a fallback serve your web page to anyone who sends the wrong password or UUID.
Port checks are still useful as a cheap first layer. If the port is closed, you learn it at once and you know where to look. Just don't let a port check be the only thing that decides whether the server is up.
How do I check that a client can really connect?
Run a real client against the server with a dedicated monitoring user, send a request through the tunnel to the internet, and count anything short of a completed request as a failure. That one check covers DNS resolution of the server name, the transport, the handshake, user authentication, the server's forwarding and NAT, and its outbound DNS.
Create the monitoring credentials with the same tooling you use for real users. If your key generation or subscription pipeline breaks, the monitoring user breaks with it, and you hear about it before your users do.
For proxy protocols (VLESS, VMess, Trojan, Shadowsocks, Hysteria2, TUIC), run sing-box or Xray on a check host with a local SOCKS inbound and the server as the outbound, then send one request through it:
curl -sS -m 15 -o /dev/null -w '%{http_code} %{time_total}\n' \
--socks5-hostname 127.0.0.1:2080 https://www.gstatic.com/generate_204
A 204 means the server completed the handshake, accepted the user, resolved the hostname and fetched the page. --socks5-hostname matters here: curl sends the hostname to the proxy instead of resolving it locally, so the server's own DNS is part of the test.
For WireGuard, the cheapest real check I know is a canary peer: a small machine on another network that stays connected all the time.
[Interface]
PrivateKey = <canary-private-key>
Address = 10.66.0.250/32
[Peer]
PublicKey = <server-public-key>
Endpoint = vpn.yourdomain.com:51820
AllowedIPs = 10.66.0.1/32
PersistentKeepalive = 25
AllowedIPs covers only the server's tunnel address, so the canary's own traffic doesn't go through the tunnel. The keepalive sends a packet every 25 seconds, and WireGuard starts a new handshake when it sends on a session older than 2 minutes, so on the server this peer's latest handshake should never be much older than that. prometheus_wireguard_exporter exports it per peer, and one rule turns it into an alert:
- alert: WireGuardCanaryNoHandshake
expr: time() - wireguard_latest_handshake_seconds{public_key="<canary-public-key>"} > 300
for: 5m
labels:
severity: page
The help text says "Seconds from the last handshake", but the value is a Unix timestamp, which is why the rule subtracts it from time(). Start the exporter with -d true and you also get wireguard_latest_handshake_delay_seconds, which is already the age. The canary proves the handshake works. It doesn't prove the server forwards traffic to the internet, so I also run a request through the tunnel from a full client now and then.
For OpenVPN, IKEv2 and OpenConnect, the pattern is the same with their own clients: start the client in a throwaway container, wait for it to report the tunnel is up (OpenVPN logs Initialization Sequence Completed), then send a request through the tunnel interface.
What should I measure besides up and down?
Measure how long the tunnel takes to come up, the round trip through it, and, less often, throughput, because a server that is up but slow causes as many complaints as one that is down. I keep a baseline per server and alert on a sustained change, such as round-trip time doubling for 15 minutes, never on a single slow check.
Throughput is expensive to measure, so I measure it rarely. With iperf3 running on the server, bound to its tunnel address, a client connected through the tunnel can test the download direction:
# on the server
iperf3 -s -B 10.66.0.1
# on a client connected through the tunnel
iperf3 -c 10.66.0.1 -t 10 -R
Run this daily or after changes, not every minute. A throughput test every minute creates the load it is trying to measure, and on a metered server it costs real money. When throughput drops but latency stays normal, look at the path (throttling, a congested peering link) before you look at the server.
How do I catch certificate and key expiry before users do?
Read the expiry date from the certificate the server actually presents, and from the files your clients carry, and alert two weeks ahead. For anything that speaks TLS over TCP (Trojan, VLESS or VMess with TLS, OpenConnect), openssl reads it from outside:
echo | openssl s_client -connect vpn.yourdomain.com:443 -servername vpn.yourdomain.com 2>/dev/null \
| openssl x509 -noout -enddate -checkend 1209600
-checkend 1209600 exits non-zero if the certificate expires within 14 days, so the same line works in a cron job. QUIC protocols such as Hysteria2 and TUIC don't speak TLS over TCP, so check the certificate file on the server with openssl x509 -in <cert file> -noout -checkend 1209600 instead.
In Prometheus, the blackbox_exporter TCP prober does the same with TLS turned on:
modules:
tls_443:
prober: tcp
timeout: 5s
tcp:
tls: true
tls_config:
server_name: vpn.yourdomain.com
Alert on probe_ssl_earliest_cert_expiry - time() < 14 * 86400. Remember the REALITY exception from above: on a REALITY port this metric is about someone else's certificate.
OpenVPN needs more attention because it has several certificates: the CA, the server certificate, every client certificate inside the .ovpn files, and the CRL if you use crl-verify. The defaults in easy-rsa's vars.example are 3650 days for the CA, 825 for certificates and 180 for the CRL, so on many servers the CRL is the first thing to expire. Check it with:
openssl crl -in /etc/openvpn/server/crl.pem -noout -nextupdate
WireGuard keys don't expire. The risk there is a key rotation that misses some peers, which the canary check above catches.
How do I check DNS for a VPN server?
There are two separate DNS questions: does the server's hostname resolve to the right address, and does the resolver you give to clients answer through the tunnel. Check the first from outside, against more than one public resolver, and compare with the address you expect:
dig +short A vpn.yourdomain.com @1.1.1.1
dig +short A vpn.yourdomain.com @8.8.8.8
This catches a forgotten record after a server move and a domain that has expired. It can't catch DNS tampering inside a censoring country, which needs a test from inside.
The second question matters for WireGuard and OpenVPN, where clients use the resolver you set with DNS = or push "dhcp-option DNS ...". If that resolver dies, the handshake still works, ping to an IP works, and users report "connected but no internet". From a connected test client, query it directly with dig @10.66.0.1 example.com. With proxy protocols the server resolves names itself, and the --socks5-hostname request above already covers it.
How do I know if my server's IP is blocked?
You can only see a block from inside the network that applies it, so the test has to run there. A checker in another country keeps reporting the server as healthy. The usual sign is a split: monitoring from abroad is green, while users on one ISP or in one country can't connect.
A block can target the IP (TCP connections time out on every port), the domain or TLS SNI (the connection resets right after the TLS ClientHello), the protocol itself (the handshake starts and then dies), or the speed (everything works, but slowly). Each one looks different, so the test from inside should record which step failed, not only that something failed. OONI Probe, RIPE Atlas probes and users who agree to run a small test script are the realistic ways to get that view.
What should I watch on the host itself?
Watch the things that break a VPN server without stopping its process: disk, memory, the connection tracking table, the clock, and whether the service keeps restarting. node_exporter covers most of it:
| Metric | Why it matters on a VPN server |
|---|---|
node_filesystem_avail_bytes |
Logs on a busy proxy server fill a disk faster than people expect |
node_memory_MemAvailable_bytes |
Memory pressure kills the VPN process or slows every connection |
node_nf_conntrack_entries / node_nf_conntrack_entries_limit |
A NAT server tracks every flow. When the table is full, the kernel logs nf_conntrack: table full, dropping packet and new connections fail while old ones keep working |
node_timex_sync_status, node_timex_offset_seconds |
Xray's docs say VMess needs the clock within 120 seconds of real time, and TLS validation fails on a badly wrong clock |
systemd's Restart=on-failure hides crash loops: the service always looks active. systemctl show -p NRestarts xray prints how many times systemd has restarted it, and a number that keeps growing is worth an alert.
How often should each check run?
Run the handshake check every 1 to 5 minutes, host metrics every 15 to 60 seconds, DNS every few minutes, and expiry checks once a day. The interval sets how fast you can know about an outage: with a check every 2 minutes and 2 failures needed to confirm, you hear about an outage between 2 and 4 minutes after it starts. A 1-minute interval halves that.
For hundreds of servers, faster checks cost you more check traffic and more log lines on the servers, not more accuracy. I pick the interval from how long users will wait before they complain, and leave it there.
Where should monitoring checks run from?
Run checks from outside your hosting provider, from at least two networks, and from the countries your users are in when you can. A check from the same data center misses upstream and routing problems, a whole class of outages that the server itself never sees.
Two vantage points also tell you where a failure is. If the server fails from both, the server is the problem. If it fails from one, the path is the problem, and that alert can wait for working hours. For users in countries that block VPN traffic, no data center abroad can see what they see. That needs the in-country methods above.
When should a VPN alert page someone?
Page only on a confirmed failure of the real handshake: two or three failed checks in a row, or failures from two locations at once. Everything else goes to a ticket, a chat channel or a dashboard. In Prometheus, the for: clause does the confirming: per the alerting rules docs, an alert with for: 5m stays pending until its condition has held for five minutes, and keep_firing_for stops it from flapping back to resolved on one good result.
| Condition | Where it goes |
|---|---|
| Handshake fails, confirmed, from every location | Page |
| Handshake fails from one location only | Chat channel, check during working hours |
| Certificate expires in less than 14 days | Ticket |
| Certificate expires in less than 3 days | Page |
| Conntrack table over 80%, disk over 90% | Ticket |
| The checker itself fails | A separate alert to you, never "server down" |
The last row is easy to get wrong. If your check host loses its own network, every server looks down at once. Group alerts by provider or location (Alertmanager's group_by does this), so one upstream outage sends one message, not fifty.
Should I build VPN monitoring myself or use a service?
Build it yourself if you already run Prometheus and have a handful of servers. The hard part is the real-client checks, which you write and maintain yourself. The usual self-hosted pieces:
- blackbox_exporter (0.29.x at the time of writing): TCP connect, TLS certificate expiry, ICMP, DNS, HTTP and gRPC probes. It doesn't do VPN handshakes, and apart from DNS it has no UDP probe.
- prometheus_wireguard_exporter: per-peer handshake times and byte counters, read on the server with
wg. Its last release is 3.6.6, from November 2022. - node_exporter: the host metrics above.
- Your own scripts for the real-client checks, written to node_exporter's textfile collector:
# run from cron every 2 minutes
dir=/var/lib/node_exporter/textfile
if curl -sS -m 15 -o /dev/null --socks5-hostname 127.0.0.1:2080 https://www.gstatic.com/generate_204
then ok=1; else ok=0; fi
echo "vpn_check_success{server=\"vpn.yourdomain.com\"} $ok" > "$dir/vpn.prom.$$"
mv "$dir/vpn.prom.$$" "$dir/vpn.prom"
The write-then-move pattern comes from the node_exporter README: it stops Prometheus from reading a half-written file. This works well for a few servers. It gets harder with many protocols, many locations and servers that change every week, because every new server means another client config on every check host.
Full disclosure: the outside-in part is what TunnelHQ does. It completes the real handshake for 11 protocols from a pasted config or link (or a subscription URL it keeps in sync), every 1 to 5 minutes depending on plan, opens and closes incidents from the results, and alerts to 20 channels. On higher plans you choose the countries each server is tested from. It doesn't replace node_exporter on the host, and I'd run both. Every plan starts with a 14-day free trial.


