Monitoring advice usually starts with Prometheus, Grafana and an alertmanager. That is the right answer at a certain scale, and it is a weekend of work plus a permanent maintenance commitment for one or two servers.

For a small setup, three things cover nearly everything that actually goes wrong:

  1. A service died — and nothing told you.
  2. The disk filled up — which breaks things in ways that look unrelated.
  3. The whole machine is unreachable — which nothing running on it can report.

This sets up all three in about an hour, using tools already on the server.

What this does not give you: history, graphs, or trend analysis. It tells you something is wrong now; it will not tell you memory has been creeping up for three weeks. If you need that, you have outgrown this page and should build the real thing.

1. Get alerts out of the machine

Everything below depends on being able to notify you, so do this first. Do not use email from the server itself — mail from a random VPS is quietly discarded by every major provider, and you will believe alerting works when it does not.

Use a webhook to something you actually look at. This example uses ntfy, which needs no account, but the shape is the same for Slack, Discord, Telegram or Gotify:

sudo nano /usr/local/bin/notify
#!/bin/bash
# notify "Title" "Message body"
set -euo pipefail

TOPIC="https://ntfy.sh/your-secret-topic-name"
HOST=$(hostname -s)

curl -fsS \
  -H "Title: [${HOST}] ${1}" \
  -H "Priority: high" \
  -d "${2}" \
  "${TOPIC}" > /dev/null
sudo chmod +x /usr/local/bin/notify

# Test it NOW - everything below is worthless if this fails
/usr/local/bin/notify "Test" "If you can read this, alerting works"

Verify: the notification arrives on your phone or in your chat. Do not continue until it does.

Two notes. Put the hostname in the title — with three servers, an alert that does not say which machine is only half an alert. And on a public ntfy topic, choose a long unguessable name, since anyone who knows it can read your alerts.

2. Tell me when a service dies

systemd already knows when a unit fails. It just does not tell anyone by default. OnFailure= fixes that, and it is the single highest-value thing on this page.

Create one reusable alerting unit that takes the failed service’s name as a parameter:

sudo nano /etc/systemd/system/alert@.service
[Unit]
Description=Alert that %i failed

[Service]
Type=oneshot
ExecStart=/bin/bash -c '/usr/local/bin/notify "%i FAILED" \
  "$(systemctl status %i --no-pager -n 20 | tail -20)"'

Then attach it to any service you care about, using a drop-in rather than editing the packaged unit:

sudo systemctl edit nginx.service
[Unit]
OnFailure=alert@%n.service
sudo systemctl daemon-reload

# Prove it works - kill the service uncleanly
sudo systemctl kill -s SIGKILL --kill-who=main nginx

Verify: an alert arrives containing the last twenty log lines — which is usually enough to know what happened without logging in.

systemctl edit writes to /etc/systemd/system/nginx.service.d/override.conf, so package updates will not overwrite it. Repeat for each service that matters. Worth doing for the same list you would restart in a panic: the web server, the database, the application itself, and any backup timer.

While you are here, make sure important services come back on their own:

# In the same drop-in
[Service]
Restart=on-failure
RestartSec=10s

A service that restarts itself and tells you it did is a far better position than one that does either alone. systemctl covers units in more depth.

3. Watch the disk

A full disk causes failures that look like everything except a full disk — databases refusing writes, sessions failing, logs stopping, updates breaking. It is worth its own check, and it must run before things break rather than after.

sudo nano /usr/local/bin/check-disk
#!/bin/bash
set -euo pipefail
THRESHOLD=85

df -h --output=pcent,ipcent,target -x tmpfs -x devtmpfs \
  | tail -n +2 | while read -r used inodes mount; do
    u=${used%\%}
    i=${inodes%\%}
    if [ "$u" -ge "$THRESHOLD" ]; then
        /usr/local/bin/notify "Disk ${used} on ${mount}" \
          "$(df -h "$mount"; echo; du -h -d1 "$mount" 2>/dev/null | sort -h | tail -5)"
    fi
    if [ "$i" -ge "$THRESHOLD" ]; then
        /usr/local/bin/notify "Inodes ${inodes} on ${mount}" \
          "Running out of inodes, not space. df -i for detail."
    fi
done
sudo chmod +x /usr/local/bin/check-disk
sudo /usr/local/bin/check-disk        # test it

# Run it hourly
sudo crontab -e
# 17 * * * * /usr/local/bin/check-disk

Checking inodes as well as space is the part most scripts miss. A filesystem can be 40% full and completely unable to create a file — filesystems explains why. The alert also includes the five largest directories, so you often know the cause before opening a terminal.

Note the odd minute in the cron line. Scheduling everything at 0 means every check on every server fires simultaneously; spreading them out is free.

Remember cron’s environment is small — five variables, no aliases, no LANG, and nothing your shell startup files set. Its PATH is not the problem people expect, though: on Debian and Ubuntu it is the full login PATH from /etc/environment, because cron runs as a PAM application. Use absolute paths in anything it runs anyway — which is why the script calls /usr/local/bin/notify in full — because the next machine may not be Debian-family. The Life of a Login covers where a PATH comes from.

4. Watch from outside

Everything so far runs on the server, which means none of it works when the problem is the server. A machine that has run out of memory, lost its network or been shut down cannot report that.

You need one check from somewhere else. Two reasonable options:

ApproachGood for
A hosted uptime serviceZero maintenance; free tiers are usually enough
A dead-man’s switchCron jobs and backups — alerts when something stops reporting

The second is the more interesting one and it is underused. Instead of alerting when a job fails, you alert when a job stops checking in — which catches the case where the job never ran at all, the server was off, or cron itself was broken.

# In your backup script, at the very end
/usr/local/bin/backup.sh && curl -fsS https://hc-ping.com/your-uuid-here

# Or report failure explicitly
if /usr/local/bin/backup.sh; then
    curl -fsS https://hc-ping.com/your-uuid
else
    curl -fsS https://hc-ping.com/your-uuid/fail
fi

If the ping does not arrive within the window you configured, you get an alert. This is the only way to detect a backup that silently stopped running, which is a genuinely common and genuinely serious failure — see automated backups.

If you would rather self-host the whole thing, Uptime Kuma runs happily in a container behind the reverse proxy — on a different machine from the one it is watching, which is the entire point.

5. A morning summary

Alerts tell you about emergencies. A daily digest catches the slow problems that never trip a threshold.

#!/bin/bash
# /usr/local/bin/daily-report
set -uo pipefail

# systemctl --failed exits 0 when nothing has failed, so `|| echo none`
# after it never runs. Capture the output and test that instead.
failed=$(systemctl --failed --no-legend)

{
  echo "Uptime: $(uptime -p)"
  echo "Load:$(cut -d' ' -f1-3 /proc/loadavg) on $(nproc) cores"
  echo
  echo "Disk:"; df -h -x tmpfs -x devtmpfs | tail -n +2
  echo
  echo "Memory:"; free -h | head -2
  echo
  echo "Failed units:"; echo "${failed:-  none}"
  echo
  echo "Errors in the last 24h: $(journalctl -p err --since '24 hours ago' --no-pager | wc -l)"
  echo
  echo "Reboot required: $([ -f /var/run/reboot-required ] && echo YES || echo no)"
} | /usr/local/bin/notify "Daily report" "$(cat)"

Run it once a day from cron. Thirty seconds of reading catches a disk at 70% and climbing, a unit that has been failing quietly, or a pending reboot from a kernel update — none of which are urgent, and all of which become urgent if ignored. Read the error count as a rough tripwire rather than a health metric, because it is wrong in three directions at once: it counts lines, so a single stack trace inflates it by its own depth; it misses every entry that arrived with no PRIORITY and every application that records its severity inside a JSON message; and on the day journald starts discarding messages the number falls, because the notice explaining why is filed at info under systemd-journald. A morning when it drops to zero deserves as much attention as one when it spikes: The Life of a Log Line.

What to actually alert on

The failure mode of monitoring is too many alerts, not too few. An alert you routinely ignore is worse than no alert, because it trains you to ignore the real one.

ConditionAlert?
Service failedYes, immediately
Site unreachable from outsideYes, immediately
Backup did not check inYes
Disk above 85%Yes
Certificate expiring in under 14 daysYes
The daily error count drops to zero, or falls sharplyYes — that is usually throttling or a broken logging path rather than a good day
Brief CPU spikeNo
Memory looking fullNo — that is cache
Failed SSH login attemptsNo — constant background noise
Load above some numberNo, unless sustained and compared to core count

Two of those “no” rows are things people alert on and then regret. Memory appearing full is normal — Linux uses spare RAM as cache, and processes and memory explains which number to read instead. And a public server sees thousands of failed SSH attempts a day; if that bothers you, the fix is key-only authentication, not an alert. If you redirect that attention to accepted logins instead — which is the usual advice, and better advice — check who wrote the line before you act on it, because a convincing accepted-login message can be produced by any unprivileged local account: The Life of a Log Line.

The useful test: would this wake you up, and would you do something about it? If not, it belongs in the daily report rather than an alert.

Things that catch people out

SymptomCause
Alerts never arriveSending email from the server — use a webhook
Script works by hand, not in cronMissing environment; use absolute paths
OnFailure never firesForgot daemon-reload; the service exits 0 having done nothing; or the timer never fired, which OnFailure= on the service cannot see — The Life of a Scheduled Job
Server died, nothing alertedAll monitoring was on that server
Disk alert fired too lateThreshold too high, or check too infrequent
Backup broken for weeksNo dead-man’s switch
Everyone ignores the alertsToo many, or too many are not actionable

Whatever you set up, break something on purpose once and confirm the alert arrives. Untested monitoring is not monitoring — it is a belief about monitoring, and it is usually wrong.

Quick reference

You wantCommand
Anything failed right now?systemctl --failed
Errors since bootjournalctl -p err -b
Alert when a service diessystemctl edit NAME, add OnFailure=
Restart it automatically tooRestart=on-failure
Space and inodesdf -h and df -i
Test an alert pathRun the notify script by hand
Test failure alertingsudo systemctl kill -s SIGKILL NAME
See the drop-in you createdsystemctl cat NAME

Related reading