VPS Maintenance Checklist: Health Checks That Run Without You

Maintenance on a VPS fails in a predictable order, and almost all of it fails silently: the disk fills because journald grew, a cron job stopped authenticating six weeks ago, a package is held back and the kernel has not been rebooted since. A checklist of “check disk, check memory” does not catch any of these. What catches them is turning each check into a command with a threshold, and then putting the commands where they run whether or not you log in.

This page is the command-level version of a maintenance routine. Every item has a command, a numeric threshold, and a remediation. If you are still choosing an instance size to run this on, our VPS plan sizing guide by workload maps these tools’ overhead to RAM and cores.

The Cadence Table

Cadence is driven by how fast a problem becomes unrecoverable, not by tradition. Disk fills in days at a steady rate; expiring TLS certificates become an outage in hours; kernel reboots are only needed when a security advisory demands it.

IntervalCheckCommandAct when
5 min (automated)Disk on /df --output=pcent /> 85%
15 min (automated)Load vs vCPUcat /proc/loadavg1-min > 2× vCPU for 3 samples
HourlyService statesystemctl is-failed --quiet nginxexit code 0
DailyCertificate expiryopenssl s_client -connect <host>:443 </dev/null 2>/dev/null | openssl x509 -noout -enddate< 21 days
DailyBackup freshnessls -lt /srv/backups | headnewest object > 26 h old
DailySSH failuresjournalctl -u ssh -S -24h | grep -c 'Failed password'> 500/day from one /24
WeeklyPending rebootstest -f /var/run/reboot-requiredfile exists > 7 days
WeeklySteal timevmstat 5 12 | awk 'NR>2{s+=$16}END{print s/NR}'> 5% sustained
MonthlyLog growthjournalctl --disk-usage> 1.5 GB or > 10% of disk
MonthlyRestore drillrestore one backup to a scratch VMany failure

Step 1: Read the Health Snapshot in Four Commands

Before automating anything, run this by hand so you know what “normal” looks like on this machine. Numbers without a baseline are noise.

uptime -p; cat /proc/loadavg
free -m | awk 'NR==2{printf "used %d MB of %d MB, avail %d MB\n",$3,$2,$7}'
df -h / /var /var/log 2>/dev/null | column -t
vmstat 1 5 | tail -4

Three readings matter more than the rest. Load average divided by vCPU count tells you if the box is queueing; above 1.0 sustained for ten minutes you are deferring work. Available memory (column 7 of free, not “free”) tells you whether the page cache can still absorb writes. Steal time in vmstat column st tells you the hypervisor is giving your time to someone else — the one metric you cannot fix from inside the guest.

Step 2: Bound the Two Things That Grow Without Asking

Logs and backups are the two directories that grow monotonically. Set hard ceilings at install time rather than reacting at 100% full.

# journald: cap total size and keep 14 days
sudo sed -i 's/^#\?SystemMaxUse=.*/SystemMaxUse=500M/' /etc/systemd/journald.conf
sudo sed -i 's/^#\?MaxRetentionSec=.*/MaxRetentionSec=14day/' /etc/systemd/journald.conf
sudo systemctl restart systemd-journald

# logrotate: compress after one rotation, keep 7 compressed copies
sudo tee /etc/logrotate.d/vps-baseline >/dev/null <<'EOF'
/var/log/nginx/*.log {
    daily
    rotate 7
    compress
    delaycompress
    missingok
    notifempty
    sharedscripts
    postrotate
        [ -f /run/nginx.pid ] && kill -USR1 $(cat /run/nginx.pid)
    endscript
}
EOF

Then verify the tail is actually being trimmed, rather than trusting the config:

journalctl --disk-usage
sudo logrotate --debug /etc/logrotate.d/vps-baseline 2>&1 | grep -E 'compress|rotating'

Step 3: Confirm Every Unit You Depend On Is Enabled, Not Just Running

The most common maintenance surprise is a service that survived a restart but not a reboot, because it was started manually once and never enabled. List every unit in a failed state, and separately list running units that are not enabled.

systemctl list-units --state=failed --no-legend

for u in $(systemctl list-units --type=service --state=running --no-legend | awk '{print $1}'); do
  systemctl is-enabled --quiet "$u" || echo "running but NOT enabled: $u"
done

The second loop is the one that pays for itself. Anything it prints — php8.3-fpm, a queue worker, a custom application unit — will be dead after the next maintenance reboot. Enable it, then confirm with systemctl is-enabled before you close the ticket.

Step 4: Turn the List Into One Cron That Reports Deltas

A checklist you have to remember is not maintenance. The script below exits non-zero only when something crossed a threshold, which makes it usable both interactively and from cron with mail or a webhook.

#!/usr/bin/env bash
# /usr/local/sbin/vps-check.sh
set -uo pipefail
fail=0
note(){ printf '%-12s %s\n' "$1" "$2"; fail=1; }

disk=$(df --output=pcent / | tail -1 | tr -dc '0-9')
[ "$disk" -gt 85 ] && note DISK "${disk}% used on /"

avail=$(awk '/MemAvailable/{print int($2/1024)}' /proc/meminfo)
[ "$avail" -lt 128 ] && note MEMORY "${avail} MB available"

steal=$(vmstat 5 3 | awk 'NR>2{s+=$16;n++}END{print (n?s/n:0)}')
awk -v s="$steal" 'BEGIN{exit !(s>5)}' && note STEAL "${steal}%"

for u in nginx mysql redis-server; do
  systemctl is-active --quiet "$u" || note SERVICE "$u not active"
done

newest=$(find /srv/backups -type f -mmin -1560 | head -1)
[ -z "$newest" ] && note BACKUP "no object newer than 26h"

journalctl --disk-usage | grep -qE '([2-9]|[1-9][0-9])[0-9]{2}\.[0-9]M' && note JOURNAL ">500M"
exit "$fail"

Install it as a systemd timer rather than a cron entry so the output lands in the journal and you get OnFailure= handling for free. A timer with Persistent=true also runs the check after a reboot if the machine was down at the scheduled time — the exact scenario an uncontrolled reboot creates.

sudo tee /etc/systemd/system/vps-check.service >/dev/null <<'EOF'
[Unit]
Description=Baseline VPS health check
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/vps-check.sh
EOF

sudo tee /etc/systemd/system/vps-check.timer >/dev/null <<'EOF'
[Unit]
Description=Run VPS health check every 15 minutes
[Timer]
OnCalendar=*:0/15
Persistent=true
[Install]
WantedBy=timers.target
EOF

sudo systemctl daemon-reload
sudo systemctl enable --now vps-check.timer
systemctl list-timers vps-check.timer

The Monthly Item Nobody Does: A Restore Drill

Backup freshness proves bytes were written. It does not prove they can be read. Once a month, restore the newest database dump into a scratch instance and run a single integrity query against it:

zstd -dc /srv/backups/db/latest.sql.zst \
  | mysql --defaults-file=/etc/mysql/restore.cnf scratch_verify
mysql -e 'CHECK TABLE wp_options, wp_posts' scratch_verify
mysql -e 'SELECT COUNT(*) FROM wp_posts' scratch_verify

Record the wall-clock time the restore took. That number is your real RTO, and it is usually two to five times the estimate people carry around — which matters when you are deciding between a snapshot strategy and a logical dump strategy on a small instance.

What to Do With the Numbers

  • Disk above 85% with logs as the top consumer — cap journald, rotate and compress, then find the writer.
  • Steal above 5% sustained — that is host contention. Document it with timestamps and raise it with the provider; you cannot tune your way out of a neighbour’s workload.
  • Available memory under 128 MB with low load — something is caching without a ceiling. Check redis-server and mysqld first; both default to consuming whatever is left.
  • A failed unit that has been failed for weeks — systemctl reset-failed hides it. Read journalctl -u name -n 50 instead; a service that has been down for weeks is a business risk, not a monitoring gap.

The whole routine is roughly 120 lines of shell across a check script and a timer. Its value is not the individual checks — it is that the script is the only thing that has to remember. For the instance-level context behind these thresholds, see our VPS benchmark reference and compare measured steal and disk latency across plan tiers before you size the next migration.

Leave a Reply