Uptime Kuma wakes you at 3 a.m. to say the server is down. It cannot tell you that the disk has been filling a little faster every week. A graph can, and it does so on a Tuesday afternoon, not at 3 a.m.
The short answer
How do you do homelab monitoring with Prometheus and Grafana? You run four containers with Docker Compose. Prometheus fetches numbers from your machines at a fixed interval and stores them. node_exporter makes CPU, memory, disks and network of a Linux host readable for it. Grafana draws the history, and Alertmanager sends a message when a rule fires [1][4][11][12][16]. Prometheus pulls: it asks every target for its current numbers, by default once a minute [4]. This guide pins image tags that exist (checked 25 Oct 2026), keeps every service on the loopback address, caps how much disk the history may use, and ships two alert rules: a service stopped answering, and a disk will be full within 24 hours. Our assessment: Prometheus answers "how is it trending?", Uptime Kuma answers "is it reachable?". Run both, because they fail differently.
How it works
Four roles. An exporter is a small program that shows the numbers of one system at a web address, normally /metrics. node_exporter does this for Linux hosts and listens on port 9100 by default [12]. Prometheus scrapes (fetches) each configured target, keeps the samples in a time series database on local disk, and records a series up with 1 or 0 for every scrape [4][9]. Grafana stores no metrics itself. It queries Prometheus through a data source and draws the result [28][29]. Alertmanager sends the notifications. The Prometheus docs say that alerting rules only work out what is broken right now and that another layer is needed for summarising, rate limiting and silencing. In the Prometheus world, that layer is Alertmanager [6].
Pinned versions. Prometheus 3.15.1 (released 21 Oct 2026) [18], node_exporter 1.12.1 (14 Jul 2026) [19], Alertmanager 0.34.1 (17 Sep 2026) [20] and Grafana 13.2.4 (13 Oct 2026) [21]. Each tag exists on Docker Hub as of 25 Oct 2026 [22][23][24][25]. We do not use latest, so an update is a decision you make and not something that happens at the next pull.
Where the stack runs. The Docker images are listed for 64-bit ARM and 32-bit ARM (v7) as well as x86-64 [22][23][24][25]. Beyond that, the choice is about your situation:
| Option | Fits when | Watch out for |
|---|---|---|
| Mini PC or old PC with Linux | It runs around the clock anyway | Measure its power draw first; see the power guides below |
| Raspberry Pi | You want the quietest, smallest box | Prometheus writes to disk all the time: a write-ahead log plus a new block every two hours [1]. Our assessment: use an SSD, not an SD card. We have no wear figures, so that part is unproven |
| On the monitored host itself | You want the quickest start | If that host dies, the monitoring dies with it and stays silent |
| VPS | You want a view from outside | Prometheus pulls, so your home exporters would have to be reachable from the internet. The project says its endpoints should not be exposed to public networks [10] |
Our assessment: put the stack on a small always-on machine at home, and put the outside view (Uptime Kuma) on a different device or a VPS. If you really want Prometheus on a VPS, connect it to your home network through a VPN rather than opening exporter ports; see the WireGuard and Tailscale guide below.
Why this does not repeat Uptime Kuma. Uptime Kuma asks from outside whether something answers. Prometheus reads numbers from inside and keeps them:
| Uptime Kuma | Prometheus with node_exporter | |
|---|---|---|
| Typical question | Is the page, port or host reachable? | How full is the disk, and how fast is it filling? |
| Source of data | Checks it runs itself | Exporters on the systems |
| What you keep | Up and down history, response times | Any number the exporters offer, for as long as you allow |
| Typical alarm | "Down for 2 minutes" | "Will be full in 24 hours" |
The two connect: Uptime Kuma serves its monitors as Prometheus metrics at /metrics, with basic authentication by default, so Prometheus can scrape them and Grafana can show availability next to load [36].
Limit the history. Without a limit, the default retention is 15 days [1][3]. Set time and size. Both apply, and whichever triggers first wins [1]. Since the 2025 change in Prometheus, the two command-line flags are marked deprecated and the same settings live in prometheus.yml under storage.tsdb.retention, which takes precedence over the flags [3][5]. Many tutorials still use the flags [37]. The size limit should be at most 80 to 85 percent of the disk you allocate, because compaction needs room for old and new blocks at the same time [1].
Close the doors. Prometheus listens on 0.0.0.0:9090 by default [3]. Its security model assumes untrusted users may read everything it serves, and says the endpoints should not be exposed to public networks [10]. Docker adds a trap: ports you publish with -p are diverted before ufw sees them [34], and a plain -p 9090:9090 is open to everything that can reach the host [33]. We avoid both by running the containers in the host network and binding each service to 127.0.0.1 (Linux only; we did not test Docker Desktop). The node_exporter README itself asks for host network and host PID for host monitoring [12], and Docker creates no firewall rules for host networking, so your own firewall still applies [34].
Setting it up
Everything lives in one folder. Create it and the files below:
monitoring/
├── compose.yaml
├── .env # GRAFANA_ADMIN_PASSWORD=choose-a-long-one (never commit this)
├── prometheus/
│ ├── prometheus.yml
│ └── rules.yml
├── alertmanager/
│ ├── alertmanager.yml
│ └── smtp_password # your mail password, nothing else in the file
└── grafana/provisioning/datasources/prometheus.ymlcompose.yaml. If you pass command: to the Prometheus or Alertmanager image, you replace the image defaults, so the config and storage flags are listed again [2][17]:
name: monitoring
services:
prometheus:
image: prom/prometheus:v3.15.1
restart: unless-stopped
network_mode: host
command:
- --config.file=/etc/prometheus/prometheus.yml
- --storage.tsdb.path=/prometheus
- --web.listen-address=127.0.0.1:9090
volumes:
- ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
- ./prometheus/rules.yml:/etc/prometheus/rules.yml:ro
- prometheus-data:/prometheus
node-exporter:
image: prom/node-exporter:v1.12.1
restart: unless-stopped
network_mode: host
pid: host
command:
- --path.rootfs=/host
- --web.listen-address=127.0.0.1:9100
volumes:
- /:/host:ro,rslave
alertmanager:
image: prom/alertmanager:v0.34.1
restart: unless-stopped
network_mode: host
command:
- --config.file=/etc/alertmanager/alertmanager.yml
- --storage.path=/alertmanager
- --web.listen-address=127.0.0.1:9093
- --cluster.listen-address=
volumes:
- ./alertmanager/alertmanager.yml:/etc/alertmanager/alertmanager.yml:ro
- ./alertmanager/smtp_password:/etc/alertmanager/smtp_password:ro
- alertmanager-data:/alertmanager
grafana:
image: grafana/grafana:13.2.4
restart: unless-stopped
network_mode: host
environment:
GF_SERVER_HTTP_ADDR: 127.0.0.1
GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:?set GRAFANA_ADMIN_PASSWORD in .env}
volumes:
- ./grafana/provisioning/datasources:/etc/grafana/provisioning/datasources:ro
- grafana-data:/var/lib/grafana
volumes:
prometheus-data:
alertmanager-data:
grafana-data:Why these lines matter:
--path.rootfs=/hosttogether with the bind mount/:/host:ro,rslaveis the node_exporter README recipe. Without them the exporter measures its own container [12]. The prefix is removed from themountpointlabel, so your root disk shows up as/[13].--cluster.listen-address=(empty) switches off Alertmanager's clustering. It is on by default and listens on0.0.0.0:9094[16], which in host networking would open a port to your whole LAN.--web.listen-addressis the flag for the bind address on Prometheus and Alertmanager [3][38]. node_exporter shares it through the Prometheus exporter toolkit, which also offers--web.config.filefor TLS and passwords if you ever need to scrape across the LAN [14]. Check yours withdocker run --rm prom/node-exporter:v1.12.1 --help.GF_SERVER_HTTP_ADDRis the environment form ofhttp_addrin the[server]section [27]. The admin password is only read on first start [27], and:?makes Compose stop if.envlacks it [35]. Docker secrets viaGF_SECURITY_ADMIN_PASSWORD__FILEare the alternative [26].- The files in
smtp_passwordand the configs are read by the container usernobody[17], so they must be readable for it.
prometheus.yml. The default scrape interval is one minute [4]. We use 15 seconds, which is fine for a handful of hosts:
global:
scrape_interval: 15s
evaluation_interval: 1m
storage:
tsdb:
retention:
time: 30d
size: 4GB
rule_files:
- /etc/prometheus/rules.yml
alerting:
alertmanagers:
- static_configs:
- targets: ["127.0.0.1:9093"]
scrape_configs:
- job_name: prometheus
static_configs:
- targets: ["127.0.0.1:9090"]
- job_name: node
static_configs:
- targets: ["127.0.0.1:9100"]Keep the job name node: the Node Exporter Full dashboard expects exactly that [32]. To watch more machines, install node_exporter on them and add their address:9100 to the node targets. Then bind the exporter to that machine's LAN address and let only the monitoring host reach it. Do not expose it more widely [10].
rules.yml. Two alarms are enough to start. The first comes straight from the Prometheus documentation, the second looks ahead [6][8]:
groups:
- name: homelab
rules:
- alert: InstanceDown
expr: up == 0
for: 5m
labels:
severity: critical
annotations:
summary: "{{ $labels.job }} on {{ $labels.instance }} has not answered for 5 minutes"
- alert: DiskFullWithin24h
expr: predict_linear(node_filesystem_avail_bytes{job="node", fstype!~"tmpfs|overlay|squashfs"}[6h], 24 * 3600) < 0
for: 30m
labels:
severity: warning
annotations:
summary: "{{ $labels.mountpoint }} on {{ $labels.instance }} will be full within 24 hours at the current rate"for makes Prometheus wait before an alert counts as firing, which keeps a single missed scrape from waking you [6]. predict_linear fits a straight line through the last six hours and reads it 24 hours ahead. It works on gauges only and needs at least two samples [8]. Our assessment: a big one-off write, such as a backup that lands on the disk, can bend that line, so the 30 minutes of for is a compromise you may tune.
alertmanager.yml. The values below are placeholders. Replace the host, the addresses and the user with those of your mail provider. Alertmanager can read the SMTP password from a file, and it requires TLS to the server unless you change that [15]:
global:
smtp_smarthost: "smtp.example.org:587"
smtp_from: "alerts@example.org"
smtp_auth_username: "alerts@example.org"
smtp_auth_password_file: /etc/alertmanager/smtp_password
route:
receiver: mail
group_by: ["alertname", "instance"]
receivers:
- name: mail
email_configs:
- to: "you@example.org"
send_resolved: truePick a channel that does not depend on the homelab itself. A mail account at an outside provider qualifies. A mail server running on the machine that just crashed does not.
Grafana data source. Grafana reads provisioning files at start [28]. Put the following in grafana/provisioning/datasources/prometheus.yml. A provisioned data source cannot be edited in the web interface afterwards [29]:
apiVersion: 1
datasources:
- name: Prometheus
type: prometheus
access: proxy
url: http://127.0.0.1:9090
isDefault: true
editable: falseStart and check. Run docker compose up -d in the folder. Then:
ss -tlnpshould show ports 9090, 9100, 9093 and 3000 on127.0.0.1only.curl -s http://127.0.0.1:9090/-/healthyreturns the health check of Prometheus [39]. Grafana's own check isGET /api/health[30].- Open Prometheus on
http://127.0.0.1:9090, runup, and expect two series with value 1. - For a rule or config change, validate it with
docker compose exec prometheus promtool check rules /etc/prometheus/rules.ymlandpromtool check config /etc/prometheus/prometheus.yml[7], then reload withdocker compose kill -s SIGHUP prometheus. A reload is also possible by HTTP, but only with--web.enable-lifecycle, which is off by default and which we leave off [3][10].
From another computer, use an SSH tunnel: ssh -L 3000:127.0.0.1:3000 user@monitoring-host, then open http://127.0.0.1:3000. If you want Grafana in the LAN directly, set GF_SERVER_HTTP_ADDR to the host's LAN address rather than 0.0.0.0, and do not forward any port from your router. For a proper address with HTTPS, put a reverse proxy in front; see the guide below.
Grafana dashboard. Log in as admin with the password from .env. The default is admin/admin if you skipped that step [27], so do not skip it. The quickest dashboard is the community one: Dashboards, New, Import, ID 1860 ("Node Exporter Full"), choose the data source [31][32]. Its page recommends the extra node_exporter options --collector.systemd and --collector.processes for some panels [32], which we did not enable, so a few panels may stay empty. For your own first dashboard, these queries are enough:
| Panel | Query | Source |
|---|---|---|
| CPU in system mode | rate(node_cpu_seconds_total{mode="system"}[1m]) | Prometheus node_exporter guide [11] |
| Free disk space | node_filesystem_avail_bytes | [11] |
| Network received | rate(node_network_receive_bytes_total[1m]) | [11] |
| Memory available | node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes | metric name from tutorial [37], check it in your own Prometheus |
In practice
How big will the history get? The Prometheus docs give a rough formula: disk space = retention in seconds × samples per second × bytes per sample, with 1 to 2 bytes per sample on average [1]. Your own numbers come from scrape_samples_scraped{job="node"}, which reports how many samples one scrape returned [9]. Here is a worked example with an invented value of 1,500 samples per scrape:
15 s interval → 1,500 / 15 = 100 samples per second
30 days → 30 × 86,400 s = 2,592,000 s
samples in 30 days → 100 × 2,592,000 = 259,200,000
at 1 byte per sample → about 0.26 GB
at 2 bytes per sample → about 0.52 GBThe 1,500 is made up for the method; measure your own. We allocate 5 GB for the Prometheus volume and set size: 4GB, which is 80 percent and follows the 80 to 85 percent advice [1]. The size limit counts the write-ahead log, but only persisted blocks are deleted to honour it [1]. For the test of the alarm, stop the exporter with docker compose stop node-exporter. After five minutes plus one evaluation interval, InstanceDown should fire, and a mail should arrive. Then start it again and wait for the "resolved" mail.
Checklist
- Check that every image tag exists on Docker Hub before you copy it, and write down the date.
- Set
GRAFANA_ADMIN_PASSWORDin.envbefore the first start, and keep.envout of git. - Bind every service to
127.0.0.1, and confirm it withss -tlnp. - Disable Alertmanager clustering when you run it in the host network.
- Set retention time and size in
prometheus.yml, and keep the size below 85 percent of the allocated disk. - Measure
scrape_samples_scrapedbefore you trust any size estimate. - Run
promtool check configandpromtool check rulesbefore every reload. - Provoke one alarm on purpose, and confirm that the mail arrives outside your homelab.
- Monitor Grafana and the host from a second device with Uptime Kuma, so the watcher is watched.
- Decide whether the metric history deserves a backup. The Prometheus docs recommend snapshots, which need the admin API [1][10]. We leave it off, and we do back up the compose folder and the Grafana volume.
Related pages: Uptime Kuma: know when your homelab is down; Prometheus exporter; Containers and OCI; Measuring homelab power correctly; Home server or VPS: the electricity maths; Reverse proxy for a homelab; WireGuard or Tailscale for reaching your homelab; Raspberry Pi 5 storage: SD, USB SSD or NVMe; Homelab backups with restic or Borg.