Guide
The minimum viable monitoring stack
Most homelab monitoring setups cost more RAM and attention than the services they watch, and still fail to tell you the one thing that matters: that the machine stopped.
What is the minimum monitoring a self-hosted server needs?
Three things: an uptime check running somewhere other than the server itself, an alert when a filesystem passes about 85% full, and one notification channel you genuinely read. Add a dead man's switch so a backup job that stops running raises an alarm. That is tier one and it catches the failures that actually cost people data. Container metrics are tier two and worth about 40 MB. Prometheus and Grafana are tier three and most home servers should not be running them.
Monitoring should cost less than the thing it watches. That sounds obvious and it is routinely violated: Netdata documents roughly 150 MiB of RAM and about 4 GiB of disk by default, which on a 2 GB Raspberry Pi running three containers makes the monitoring the largest workload on the machine. A Prometheus and Grafana pair is another 300 MB before a single exporter, and neither of them will tell you that your backup job stopped running three weeks ago.
Here is a tiered answer. Most home servers should stop after tier one, and almost all should stop after tier two.
Tier one: the three things that actually matter#
1. An uptime check that runs somewhere else. This is the non-negotiable one and the one people get wrong.
2. Disk-full alerting. A filesystem at 100% is the most common self-inflicted outage in self-hosting, and it usually arrives via unbounded Docker JSON logs, a Prometheus TSDB nobody set retention on, or a database that grew. It is entirely predictable and entirely preventable.
3. One notification channel you will actually read. Not email you filter. Not a Discord server with 40 other channels. One topic on a push service, on your phone, that is quiet enough that a buzz means something.
Add a fourth if you have backups, and you should: a dead man's switch. A backup job that fails loudly is a good day. A backup job that silently stopped being scheduled is the one that costs you the archive, and no metric on the machine will show it. The pattern is a check that expects a ping on a schedule and alerts on its absence:
# The last line of your backup script, after restic exits 0.
curl -fsS -m 10 --retry 3 https://hc-ping.com/YOUR-UUID
# Signal failures too, so a broken run is distinguishable from a missed one:
# trap 'curl -fsS -m 10 https://hc-ping.com/YOUR-UUID/fail' ERRRun it against a hosted service or a self-hosted one; what matters is that the thing waiting for the ping is not the machine taking the backup. Backups that actually restore covers the rest of the job.
Why a monitor on the box that died tells you nothing#
If Uptime Kuma runs on the same host as Jellyfin, it can tell you Jellyfin returned a 500. It cannot tell you the host kernel-panicked, the PSU failed, the SD card went read-only, or the power went out, because in every one of those cases the monitor is also gone. Uptime Kuma's own documentation is honest about this: one instance is one vantage point, and it cannot report on its own death.
There are three cheap fixes, in ascending order of effort:
- A hosted uptime check hitting one public endpoint, or a dead man's switch that your server pings every five minutes. Free tiers cover a single-server homelab comfortably.
- A second small device. A Raspberry Pi Zero or an old thin client running Gatus at 25 MB, on the same LAN, checking the main server. This also catches "the network is fine but the server is not", which an internet-based check cannot distinguish.
- A 4 USD a month VPS running the checks, if you already have one for other reasons.
Pick one. The full-featured dashboard on the main box is the nice-to-have; the external check is the monitoring.
What a tier one stack looks like in practice#
Gatus is the smallest complete answer, because the entire application is a YAML file and it reloads on write:
# /srv/data/gatus/config/config.yaml
storage:
type: sqlite # the default is memory, silently, and it forgets on restart
path: /data/data.db
alerting:
ntfy:
url: "https://ntfy.example.com"
topic: "alerts"
default-alert:
failure-threshold: 3 # 3 consecutive failures, not 1
success-threshold: 2
send-on-resolved: true
endpoints:
- name: jellyfin
url: "https://jellyfin.example.com/health"
interval: 60s
conditions:
- "[STATUS] == 200"
- "[RESPONSE_TIME] < 2000"
alerts:
- type: ntfy
- name: tls-certificate
url: "https://example.com"
interval: 6h
conditions:
- "[CERTIFICATE_EXPIRATION] > 336h" # alert with 14 days to spare
alerts:
- type: ntfyTwo Gatus defaults to change on day one. storage defaults to memory with no warning banner, so an unconfigured instance shows history and forgets all of it on the next container update. And concurrency defaults to 3, so a handful of endpoints with long timeouts against something unreachable will queue behind each other and stretch your intervals well past what you configured.
If you would rather click than edit YAML, Uptime Kuma does the same job at 120 MB with a real UI; Uptime Kuma vs Gatus compares them properly. Keep its data volume on local disk, because the README lists NFS as unsupported and SQLite over a network share corrupts.
The disk check does not need a monitoring system at all:
#!/usr/bin/env bash
# /usr/local/sbin/disk-alert.sh (systemd timer, every 15 minutes)
set -euo pipefail
THRESHOLD=85
df -P -x tmpfs -x devtmpfs --output=pcent,target | tail -n +2 |
while read -r PCT MOUNT; do
USED="${PCT%\%}"
if [ "$USED" -ge "$THRESHOLD" ]; then
curl -fsS -H "Title: $MOUNT is $USED% full" \
-d "$(df -h "$MOUNT" | tail -1)" \
"https://ntfy.example.com/alerts"
fi
doneWhen it fires, docker system df is usually the answer: dangling images and build cache accumulate quietly, and a busy host adds tens of gigabytes a year.
Tier two: container-level metrics, for about 40 MB#
Tier two answers "which container is eating the memory" and "was the disk already filling last Tuesday". Beszel is the right size for this: the hub idles around 40 MB, the agent image is roughly 4 MB, and you get CPU, memory, disk, network, container stats and alerts across several machines.
Know its boundary before you build on it. Beszel's retention windows are hard-coded in the source, not configurable: one-minute records for an hour, ten-minute for twelve hours, two-hour for a week, eight-hour for thirty days, then deleted. You cannot keep a year of graphs. For a home server, thirty days is almost always enough to answer the question you actually have, which is "when did this start".
Add Dozzle here too. At 15 MB it is docker logs -f in a browser, retains nothing by design, and replaces the reflex to ssh in and tail. Note that recreating a container after an image update destroys that container's logs along with it, which is exactly the log you wanted.
If you want per-second resolution and hundreds of collectors you did not configure, that is Netdata, and Beszel vs Netdata is the honest comparison. Two Netdata facts to price in: port 19999 has no authentication at all by design, exposing process lists, containers and network connections to anyone who can reach it, and container mode needs SYS_PTRACE and SYS_ADMIN capabilities or half the metrics are silently missing.
Tier three: Prometheus and Grafana, and why you probably should not#
There is a real case for tier three: you have several machines, you have a specific question that needs a query language, or you want retention measured in quarters. If that is you, build it.
For everyone else, the costs are concrete. Prometheus retains 15 days by default when neither storage.tsdb.retention.time nor .size is set, which surprises people expecting a year of history. Its storage math is fine (1 to 2 bytes per sample, so 1,000 active series at a 15 second interval is roughly 10 MB of compacted blocks a day), but the write pattern is not: every scrape appends to a write-ahead log, the head block flushes every two hours, and compaction rewrites blocks after that. On an SD card or a cheap DRAM-less SSD, that constant churn is a wear problem. Cardinality, not volume, is what kills the process: one label with unbounded values turns one metric into tens of thousands of series and gigabytes of RAM.
Grafana is never an answer on its own. With no data source configured it is an empty shell, so budget for what sits underneath it and count that RAM too. It has been AGPLv3 since April 2021, its schema migrations run automatically on start with no downgrade, and its Docker image runs as uid 472, so a fresh bind mount fails on first boot until you chown -R 472:472.
The honest summary: Netdata vs Prometheus and Grafana is a real decision for a small fleet and an act of self-harm for one mini PC.
What to alert on, and what to only graph#
Alert. Host unreachable from an external vantage point. Filesystem past 85%, or projected full within seven days. Backup dead man's switch missed. TLS certificate expiring inside 14 days. A container restarting repeatedly. A SMART pre-fail attribute changing value. A service returning non-200 for three consecutive checks.
Graph only. CPU load. Memory usage. Network throughput. Temperatures. Per-container memory. Request rates. These are how you diagnose an alert, and they are terrible alerts themselves: a home server pinning its CPU for twenty minutes is a transcode, not an incident.
Two rules keep the channel useful. First, thresholds are consecutive-failure counts, not single samples. Second, and this is the one people skip: any alert that fires more than about once a month without requiring action must be retuned or deleted. An alert you have learned to swipe away is worse than no alert, because you believe you are covered.
Finally, watch the alert threshold semantics. Gatus counts consecutive results, not minutes, so changing an endpoint's interval silently changes how long an outage must last before you hear about it.
What to do next#
Do tier one this week: an external check or a dead man's switch, the disk timer above, and one notification topic. It is under an hour and it covers the failures that lose data. Then add Beszel if you want the graphs, and read An update strategy that does not lose data, because the second most common cause of a 3am alert is an unattended container update you did not schedule.
Questions#
Can I run Uptime Kuma on the server it is monitoring?
You can, and it will not tell you when that server dies, which is the outage you care most about. A monitor sharing a host with its targets can only report application-level failures. Run the on-box instance if you like the dashboard, then add one external check: a free hosted uptime service, a second cheap device on your LAN running Gatus, or a dead man's switch that alerts when a heartbeat from the box stops arriving.
Uptime Kuma or Gatus?
Uptime Kuma if you want to add and edit checks in a browser, including from your phone at 2am; it idles around 120 MB and stores everything in SQLite. Gatus if your infrastructure lives in git; the entire application is one YAML file it reloads on write, and it idles around 25 MB. Gatus has one trap that catches everyone: storage defaults to memory with no warning, so history disappears on restart until you set storage.type: sqlite.
Do I need Prometheus and Grafana at home?
Almost certainly not. Between them they want around 300 MB of RAM before you add exporters, Prometheus retains 15 days by default and writes to disk continuously enough to wear out SD cards and cheap SSDs, and Grafana is an empty shell without a data source underneath it. They are the right answer when you have a specific question that needs a query language and long retention. They are the wrong answer as a default dashboard.
What should trigger an alert versus just be graphed?
Alert on things that are actionable and that you would want to be woken for: host unreachable, a filesystem past 85%, a backup job that failed to check in, a certificate expiring inside two weeks, a container in a restart loop, a SMART pre-fail attribute changing. Graph everything else. CPU load, memory use, network throughput and temperatures are diagnostic context, not alerts, and turning them into alerts is the fastest route to ignoring the channel entirely.
How do I avoid alert fatigue?
Apply one rule ruthlessly: any alert that fires more than about once a month without requiring action gets its threshold changed or gets deleted. Use consecutive-failure thresholds rather than single failures, so a two-second network blip does not page you. Send everything to exactly one channel, and make that channel silent enough that a notification means something. A monitoring system you have muted is worse than none, because you believe you have one.
Is Netdata too heavy for a Raspberry Pi?
Often, yes. Netdata documents roughly 150 MiB of RAM, about 5% of a core, and around 4 GiB of disk across its default three storage tiers, and it writes continuously. On a 2 GB Pi running three containers, the monitoring is a significant fraction of the workload, and on an SD card the write pattern is a wear problem. Beszel's agent, by contrast, is a roughly 4 MB image idling in the low tens of megabytes.
What about disk health monitoring?
Worth having, and worth being honest about the cost. Scrutiny gives you a genuinely good dashboard, and it runs InfluxDB and about 100 MB of RAM to track a few kilobytes of SMART attributes per drive per day. On a 12 bay NAS that is clearly worth it. On a two-disk mini PC, smartd with a mail or script alert does most of the job for nothing.
Sources#
- Uptime Kuma releases and project repository
- Gatus repository and configuration reference
- Beszel record retention windows in source
- Netdata documentation, RAM utilization
- Netdata documentation, disk requirements and retention defaults
- Prometheus documentation, storage and retention
- Grafana documentation, installation requirements
- Healthchecks documentation, dead man's switch monitoring
- Scrutiny, InfluxDB troubleshooting and downsampling
Published . Last reviewed . Found something out of date? Tell us and we will fix it and log the change.