Metrics, logs, alerting and dashboards for a small fleet of Linux hosts, as one Docker Compose project. Everything is provisioned from files in this repository: datasources, dashboards, alert rules, Slack routing, retention. docker compose up -d gives a working system with nothing to click through, and every change is a commit.
It is built for the case where one machine monitors a handful of servers: a monitoring host runs Prometheus, Alertmanager, Loki and Grafana, the fleet runs node_exporter, and the monitoring host also watches its own containers and probes a list of URLs.
flowchart LR
subgraph fleet [Fleet hosts]
NE[node_exporter :9100]
end
subgraph mon [Monitoring host, docker compose]
NEL[node-exporter]
CA[cadvisor]
BB[blackbox-exporter]
P[Prometheus]
AM[Alertmanager]
DL[(Docker logs, /var/log)]
PT[Promtail]
L[Loki]
G[Grafana]
end
NE --> P
NEL --> P
CA --> P
BB --> P
P -->|alerts| AM
AM -->|webhook| S[Slack]
DL --> PT --> L
P --> G
L --> G
U[Browser via 127.0.0.1:3000 or SSH tunnel] --> G
- Prometheus 3 with 30 day retention, file based service discovery for fleet hosts, and 26 alerting rules with runbook lines in docs/alerts.md.
- Alertmanager with Slack routing by severity, grouping, a critical over warning inhibition rule and a
Watchdogheartbeat. The webhook comes from.env; the config is rendered at start-up from a template. - Grafana with the Prometheus, Loki and Alertmanager datasources provisioned and three dashboards: Node Overview (per host CPU, memory, disk, network, load), Containers (cadvisor plus a Loki log panel) and Alerts Overview (what is firing, pending, silenced, and whether notifications get out).
- Loki (single binary, filesystem storage, 14 day retention) and Promtail shipping every container's stdout and stderr plus
/var/log/*.logfrom the monitoring host. - node-exporter, cadvisor and the blackbox exporter (HTTP 2xx, ICMP and TCP modules).
- Recording rules for the dashboards, unit tests for the alert rules, and a CI workflow that runs yamllint,
docker compose config, promtool, amtool, dashboard checks, shellcheck and actionlint.
Pinned images: prom/prometheus:v3.14.0, prom/alertmanager:v0.34.1 (rebuilt locally with envsubst), grafana/grafana:13.2.2, grafana/loki:3.7.8, grafana/promtail:3.6.11, prom/node-exporter:v1.12.1, ghcr.io/google/cadvisor:v0.60.6, prom/blackbox-exporter:v0.28.0.
Requirements: Docker Engine 24 or newer with the Compose plugin, on a Linux host. About 1 GB of RAM for the stack at rest.
git clone https://github.com/michealzs/observability-stack.git
cd observability-stack
cp .env.example .env
$EDITOR .env # GRAFANA_ADMIN_PASSWORD and SLACK_WEBHOOK_URL at least
make validate # promtool, amtool and compose checks, all inside containers
make up # builds the alertmanager image and starts everything
make ps # wait until every service is healthy or runningOpen http://127.0.0.1:3000 and log in with the admin user and password from .env. The dashboards are in the Observability folder; Node Overview is the home dashboard. Within a minute the monitoring host itself shows up under instance node-exporter:9100, its containers under Containers, and Watchdog under Alerts Overview.
Working on a remote monitoring host: ssh -L 3000:127.0.0.1:3000 user@monitoring-host and open the same URL locally.
make on its own lists the other targets (logs, reload, lint, ports, down).
Install node_exporter (the node_exporter role in ansible-linux-baseline does this), allow TCP 9100 and ICMP from the monitoring host, then:
scripts/add-node.sh 10.0.10.13 --env prod --role webThat appends the host to prometheus/targets/nodes.yml and reloads Prometheus. The same list feeds the ICMP probe job. URLs to probe over HTTP go in prometheus/targets/http.yml. Details, firewall examples and how to verify are in docs/adding-hosts.md.
Alerts flow Prometheus, Alertmanager, Slack. Routing lives in alertmanager/alertmanager.yml.tmpl:
| Severity | Receiver | Group wait | Repeat | Resolved messages |
|---|---|---|---|---|
| critical | slack-critical | 15s | 1h | yes |
| warning | slack-warning | 30s | 4h | yes |
| info | slack-info | 30s | 24h | no |
Alerts are grouped by alertname, job and severity, so ten hosts with the same problem produce one message listing ten instances. A critical alert inhibits the warning version of the same alert on the same instance (the 3 day certificate expiry over the 30 day one, for example), and NodeExporterDown inhibits the rest of that host's alerts. Watchdog fires permanently and is routed to the null receiver; point a dead man's switch at it if you want to know when the pipeline itself dies.
Every rule has a severity, a for window, a summary and a description with the current value. The full list with what to do for each is in docs/alerts.md.
Silence with amtool inside the container (Alertmanager is not published on the host):
docker compose exec alertmanager amtool --alertmanager.url=http://localhost:9093 \
silence add alertname=HostHighCpuLoad instance=10.0.10.11:9100 --duration=2h --comment="batch job"Change the Slack channel with SLACK_CHANNEL in .env; edit the template for anything else, then make validate and docker compose up -d alertmanager.
| Component | Retention | Where to change it | Disk, order of magnitude |
|---|---|---|---|
| Prometheus | 30 days | PROM_RETENTION in .env |
0.5 to 1 GB per host per 30 days at a 15s scrape interval, plus about the same for cadvisor on a host with ten containers |
| Loki | 14 days | limits_config.retention_period in loki/loki-config.yml |
Roughly a tenth of the raw log volume; a quiet fleet stays under 1 GB |
| Alertmanager | 120 hours of notification log and silences | Alertmanager defaults | A few MB |
| Grafana | Not applicable | grafana_data volume holds the SQLite database |
Tens of MB |
Data lives in named volumes (prometheus_data, loki_data, grafana_data, alertmanager_data, promtail_data) and survives make down. Memory limits per service are set in docker-compose.yml (Prometheus 2 GB, Loki 1 GB, Grafana 512 MB, the rest 128 to 512 MB); raise Prometheus first if the fleet grows past a couple of dozen hosts.
- Only Grafana is published, and only on
127.0.0.1:3000. Reach it from elsewhere with an SSH tunnel or put a TLS terminating reverse proxy in front of it and setGRAFANA_ROOT_URL. - Prometheus and Alertmanager are reachable only inside the
monitoringnetwork. For debugging, copydocker-compose.override.example.ymltodocker-compose.override.ymlto publish them on localhost as well;docker compose port prometheus 9090then prints the address, andssh -L 9090:127.0.0.1:9090 user@monitoring-hostgets you there from a laptop. The override file is git-ignored. - Grafana has anonymous access, sign-up and org creation disabled. The admin password comes from
.envand should be changed from the example value before the first start. .envis git-ignored. The Slack webhook only exists in the Alertmanager container's environment and in the rendered config inside that container (mode 0600). It is not baked into the image.- node_exporter on fleet hosts should accept connections only from the monitoring host. Metrics are plain HTTP; use a private network, WireGuard or Tailscale between hosts.
- cadvisor runs privileged and Promtail mounts the Docker socket read-only, both of which amount to root on the monitoring host. That is the price of container metrics and logs; keep the monitoring host as locked down as the rest of the fleet.
- Every container has a CPU and memory limit and, where the image allows it,
no-new-privileges. The blackbox exporter keeps onlyNET_RAWfor ICMP.
Before every change lands:
make lint # yamllint, shellcheck, rule and dashboard checks; needs python3, PyYAML and shellcheck
make validate # docker compose config, promtool check config / check rules / test rules, amtool check-configmake validate runs promtool and amtool from the pinned images, so nothing needs installing beyond Docker. The unit tests in prometheus/tests/rules_test.yml pin down the behaviour of the alerts that matter most (host down, memory pressure, certificate expiry, the Watchdog and a recording rule).
The CI workflow in .github/workflows/ci.yml runs the same checks as separate jobs: yamllint, compose config (with the example .env), promtool, amtool (template rendered with envsubst, then checked, then the built image's own self-check), dashboard JSON checks, shellcheck and actionlint.
- The Alertmanager image is built locally because upstream ships a busybox image without envsubst and Alertmanager does not expand environment variables itself. The Dockerfile copies the upstream binaries onto Alpine and adds gettext; nothing else changes.
- node-exporter runs on the compose network rather than the host network so no host port opens. Its CPU, memory, disk and load numbers are the host's; its network counters are the container's, because those are per network namespace. Fleet hosts run node_exporter as a systemd unit and report their real interfaces.
- Loki's image is distroless and Promtail's has no HTTP client, so those two have no container healthcheck. Prometheus scrapes both and
PrometheusTargetMissingcovers them. - Promtail is in maintenance mode upstream; Grafana Alloy is the successor. It is still the simplest way to tail Docker logs with one config file, and the Loki push API it uses is stable. Swapping in Alloy is a one service change.
- Paths in
prometheus/prometheus.ymlare relative, so the same file works in the container and underpromtool check configon a laptop.
MIT, see LICENSE.
Maintained by Micheal (@michealzs).