A small VPS is the most cost-effective computer you can rent and one of the least supervised. It runs your side project, a client's WordPress, a Postgres for a hobby app, a game server, a few Docker containers, and a cron job or two, and nobody looks at it until something stops working. The provider's dashboard shows a green dot that means the hypervisor is running your VM, which is true right up until the moment you actually need to know something.
This guide is the monitoring setup for one server, or a handful, on Hetzner, DigitalOcean, OVH, Linode, Vultr, or anywhere similar. It covers the five ways these boxes actually fail, three layers of checks that catch all of them, the one gotcha specific to monitoring from Cloudflare's network (you can't HTTP-check a bare IP), and how to read the alerts when they fire.
The five ways a VPS dies
- The disk fills. Journal logs, Nginx access logs, Docker images that were never pruned, database backups that were never rotated, a runaway core dump. On a 20 or 40 GB disk this takes months and then happens all at once: Postgres refuses writes, the web server can't log and starts erroring, cron jobs fail, and SSH still works, which is how you'll eventually find out. This is the single most common VPS failure and no external check sees it coming.
- The OOM killer takes the database. A 1 or 2 GB box with no swap, a traffic spike or a memory leak, and the kernel kills the largest process. That's MySQL or Postgres. The site now returns 500s or "Error establishing a database connection." Sometimes systemd restarts it; sometimes it's configured not to.
- An automatic update rebooted it and something didn't come back. Unattended upgrades with automatic reboot are a good default for security. The failure is a service that was started by hand once and never
systemctl enabled, or a Docker container without a restart policy. The box is up, SSH is up, and the thing you care about isn't running. - A firewall or network change locked a port. A provider firewall got a new default,
ufwwas enabled without the right rule, fail2ban banned the wrong range, a floating IP was reassigned. The service is running perfectly and unreachable. - The provider had an outage. Host node failure, network maintenance, a data center incident. Rarer than the other four and the only one you can't fix, which is exactly why you want to identify it fast instead of spending an hour inside a box that's fine.
Notice the split. Failures 4 and 5 are visible from outside and invisible from inside. Failures 1, 2, and 3 are visible from inside and only become visible from outside once they've taken a service down. That's why one layer of monitoring isn't enough.
Layer 1: is the box reachable? (TCP check on SSH)
The simplest "is my server up" check is a TCP connection to port 22. If SSH accepts a connection, the machine is booted, networked, and reachable from the internet; if it's refused or times out, one of those isn't true. CronAlert's TCP port monitor does this every minute and works against a bare IP, so it's the check to use for a server that doesn't have a domain. It's on the Pro plan.
If you've moved SSH to a non-standard port, monitor that port. If you've locked SSH down to specific source IPs, this check won't work (CronAlert's connections come from Cloudflare's network), so use port 443 or 80 instead if the box serves anything, or rely on the heartbeat in layer 3, which needs no inbound access at all.
The bare-IP gotcha
You might reasonably try an HTTP monitor on http://203.0.113.10. It won't work from CronAlert, and it's worth knowing why: CronAlert's checks run on Cloudflare Workers, and Cloudflare refuses to make an HTTP request to a bare IP address, returning a synthetic 403 (error 1003) that never reaches your server. CronAlert rejects those URLs at creation so you don't end up with a monitor that's permanently, falsely down. Two fixes: create a DNS A record such as vps1.example.com pointing at the IP and monitor the hostname (which also gets you HTTPS and certificate expiry tracking), or use the TCP monitor, which reaches IPs directly.
Layer 2: does it serve what it's supposed to? (HTTP checks)
One HTTP monitor per thing the box serves, by hostname: each website, each API, the admin panel, the web map on the game server. These are on the free plan (25 monitors at 3-minute intervals) and they're the checks that correspond to what users experience. Follow the usual rules: monitor the exact URL people use, add a keyword check (Pro) on anything where a wrong page can return 200, and if the app has a health endpoint that touches the database, monitor that too. The how many monitors guide covers deciding what deserves its own check.
For a box that only runs non-HTTP services, layer 2 is more TCP monitors: the database port if it's intentionally exposed, IMAP and submission for a mail server, the game port for Java Minecraft. The port monitoring guide has the table.
Layer 3: is it healthy inside? (a cron heartbeat)
This is the layer most people skip and the one that catches failures 1 through 3 before they become outages. A heartbeat monitor gives you a URL that expects a ping on a schedule and alerts when the pings stop. Put a one-minute cron job on the box that checks the things an external monitor can't see and pings the URL only if they all pass. The absence of a ping then means "something inside is wrong," and it arrives before the disk is actually full or the site is actually down.
#!/usr/bin/env bash
# /usr/local/bin/vps-health.sh — run from cron every minute
set -uo pipefail
HEARTBEAT="https://cronalert.com/api/heartbeat/<token>"
fail=0
# Disk: fail at 90% on / (add other mounts as needed)
use=$(df --output=pcent / | tail -1 | tr -dc '0-9')
[ "$use" -ge 90 ] && { echo "disk ${use}%"; fail=1; }
# Memory: fail if less than 5% available
avail=$(awk '/MemAvailable/ {a=$2} /MemTotal/ {t=$2} END {printf "%d", a*100/t}' /proc/meminfo)
[ "$avail" -lt 5 ] && { echo "mem avail ${avail}%"; fail=1; }
# Services that must be running
for svc in nginx postgresql docker; do
systemctl is-active --quiet "$svc" || { echo "$svc not active"; fail=1; }
done
# Reboot pending (Debian/Ubuntu) — warn, don't fail
[ -f /var/run/reboot-required ] && echo "reboot required"
# Ping only when everything passed; silence is the alert
[ "$fail" -eq 0 ] && curl -fsS -m 10 -X POST "$HEARTBEAT" >/dev/null
exit 0 # /etc/cron.d/vps-health
* * * * * root /usr/local/bin/vps-health.sh 2>&1 | logger -t vps-health Set the heartbeat's expected interval to one minute; CronAlert alerts when no ping has arrived within twice the interval, so a failing condition at 2:00 is an alert around 2:02. The echoes go to syslog via logger, so when the alert fires, journalctl -t vps-health on the box tells you which condition tripped. Adjust the thresholds and the service list to the machine; the point is that the script encodes what "healthy" means for this specific server.
Three things this layer catches that nothing else does: the disk at 90% (you get a week of warning instead of a corrupted database), the OOM-killed Postgres that systemd didn't restart (alert within two minutes, before anyone loads the site), and the post-reboot missing service. It also catches the box being down entirely, since a dead machine sends no pings, which makes it a reasonable substitute for layer 1 on a server that doesn't allow inbound connections from the internet at all.
Heartbeats are on the Pro plan. A free alternative for the same signal is a tiny HTTP health endpoint on the box (a few lines of Python or a shell CGI behind Nginx) that runs the same checks and returns 200 or 503, monitored with a free HTTP check. It requires the box to have a hostname and a web server, but for a machine that already serves a site, that's usually true.
Reading the alerts
With all three layers on the same alert channels, the pattern of what fires is the diagnosis:
- Everything down at once: the box or the network. Check the provider's status page first, then the console in their dashboard.
- SSH up, HTTP down, heartbeat down: a service died inside. The heartbeat's syslog line names it. Usually OOM or a failed restart.
- SSH up, HTTP down, heartbeat up: the service is running and unreachable, or returning errors the local check doesn't test. Firewall, proxy config, certificate, or an application error. Read the HTTP monitor's status code.
- Heartbeat down, everything else up: the early warning. Disk or memory threshold, a non-critical service, or the cron daemon itself. Fix it this week, not tonight.
- SSH down, HTTP up: SSH specifically. A fail2ban ban, a firewall rule, or sshd failed to restart after an update. Not urgent for users, urgent for you.
Housekeeping that prevents most of the alerts
- Rotate and cap logs:
journalctl --vacuum-size=500Monce, thenSystemMaxUse=500Minjournald.conf. Check that logrotate is actually handling Nginx and application logs. - Prune Docker on a schedule:
docker system prune -af --filter "until=168h"weekly. Images are the disk filler people forget. - Add swap on small boxes. A 1 GB swapfile turns "OOM killer took Postgres" into "the site was slow for a minute."
- Enable every service you expect to survive a reboot:
systemctl enable, andrestart: unless-stoppedon containers. Then reboot on purpose, once, while watching the monitors. - Put the reboot window in a maintenance window. If unattended-upgrades reboots at 4 AM Sunday, a maintenance window keeps it from paging you.
- Monitor the backups. A VPS with a nightly backup script that stopped working in March is the standard horror story. Backup monitoring covers the heartbeat pattern for it.
Frequently asked questions
How do I monitor whether my VPS is up?
TCP check on SSH for reachability, HTTP checks for services, and a cron heartbeat that only pings when disk, memory, and services pass. Which layers fire tells you where to look.
Why does my monitor say my server IP is down when it works?
Cloudflare won't make HTTP requests to bare IPs. Monitor a hostname instead, or use a TCP port monitor.
Most common cause of VPS downtime?
Full disk, then OOM, then a service that didn't return after an automatic reboot. All three are caught early by the internal heartbeat.
Can I do this for free?
The HTTP layer, yes, with hostnames. TCP checks and heartbeats are Pro at $5 per month. A self-hosted health endpoint plus a free HTTP check is the free version of layer 3.
Does CronAlert need an agent on the server?
No. The heartbeat script is a few lines of bash and curl; nothing else is installed and no inbound access is required for it.
Three monitors and a cron line
For most single-server setups the whole thing is a TCP monitor on SSH, one HTTP monitor per site, and the heartbeat script above. Fifteen minutes, and the server that nobody watches becomes the server that tells you when its disk is at 90%, when its database got killed, and when it didn't come back from a reboot, all before a user notices. Create an account, add the HTTP monitors for free, and upgrade to Pro for the port check and the heartbeat. Related reading: TCP port monitoring, monitoring Docker and self-hosted apps, monitoring systemd timers and services, monitoring Nginx, Traefik, and Caddy, and game server monitoring if that's what the box is for.