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.
| Interval | Check | Command | Act when |
|---|---|---|---|
| 5 min (automated) | Disk on / | df --output=pcent / | > 85% |
| 15 min (automated) | Load vs vCPU | cat /proc/loadavg | 1-min > 2× vCPU for 3 samples |
| Hourly | Service state | systemctl is-failed --quiet nginx | exit code 0 |
| Daily | Certificate expiry | openssl s_client -connect <host>:443 </dev/null 2>/dev/null | openssl x509 -noout -enddate | < 21 days |
| Daily | Backup freshness | ls -lt /srv/backups | head | newest object > 26 h old |
| Daily | SSH failures | journalctl -u ssh -S -24h | grep -c 'Failed password' | > 500/day from one /24 |
| Weekly | Pending reboots | test -f /var/run/reboot-required | file exists > 7 days |
| Weekly | Steal time | vmstat 5 12 | awk 'NR>2{s+=$16}END{print s/NR}' | > 5% sustained |
| Monthly | Log growth | journalctl --disk-usage | > 1.5 GB or > 10% of disk |
| Monthly | Restore drill | restore one backup to a scratch VM | any 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-serverandmysqldfirst; both default to consuming whatever is left. - A failed unit that has been failed for weeks —
systemctl reset-failedhides it. Readjournalctl -u name -n 50instead; 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
You must be logged in to post a comment.