kube-prometheus-stack (Prometheus + Alertmanager + node-exporter +
kube-state-metrics + Grafana) plus Loki (logs), Alloy (log
shipper) and OpenCost (cost), running per cluster. Self-hosted, no
SaaS metrics tier. The stack is production-hardened along four axes:
- Alerts go to Slack via Alertmanager's native
slack_configs. - A dead-man's-switch (the always-firing
Watchdogalert) is pushed to an external heartbeat monitor — the one failure mode in-cluster alerting can never cover (the whole cluster being down). - State is persistent — Prometheus, Alertmanager and Loki use Hetzner
Cloud block volumes in prod, and Velero ships the
monitoringnamespace to R2 daily (24 h RPO). - Everything is reachable behind oauth2-proxy SSO: Grafana, Prometheus, Alertmanager and OpenCost.
| Component | Role | Persistence (prod) |
|---|---|---|
| Prometheus | Metrics + alert evaluation, 14 d / 5 GiB retention | hcloud PVC, 20 Gi |
| Alertmanager | Routing, grouping, silences (2 replicas, gossiped) | hcloud PVC, 2 Gi |
| node-exporter | Node metrics (DaemonSet) | n/a |
| kube-state-metrics | Kubernetes object metrics | n/a |
| Grafana | Dashboards + log exploration (Prometheus + Loki DS) | ephemeral (provisioned) |
| Loki | Log store, single-binary, 7 d retention | hcloud PVC, 10 Gi |
| Alloy | Per-node log shipper → Loki (DaemonSet) | n/a |
| OpenCost | Cost allocation against Prometheus | n/a |
Local/CI runs the same stack with Prometheus and Alertmanager on
emptyDir — losing those on a restart is fine there. Loki is the lone
exception: the chart's persistence.enabled: false crashes the
container (no /var/loki mount under a read-only root), so the base
attaches a small 5Gi PVC against the cluster's default storage class,
which on docker/CI is local-path (host directory, recycled with the
cluster). The hcloud PVC overrides for Prometheus, Alertmanager and
Loki live in the hetzner overlay (k8s/providers/hetzner/.../*/patches/),
the same way OpenBao gets block storage.
Alertmanager sends to a Slack incoming webhook (slack_configs with
api_url_file). The URL is per-cluster and SOPS-encrypted, so local
clusters get an invalid URL and stay quiet by design.
critical→ Slack immediately, repeat every 12 h.warning→ Slack, repeat every 24 h.criticalinhibits matchingwarning(same alertname/cluster/namespace).
In-cluster Alertmanager cannot tell you the cluster is down — it's down
too. To cover that, the chart's always-firing Watchdog alert is routed
to a dedicated heartbeat receiver that POSTs to an external monitor
on a tight cadence (repeat_interval: 50s). If the cluster — or the
Prometheus → Alertmanager pipeline — dies, the monitor stops receiving
pings and notifies Slack out-of-band.
Recommended monitor: healthchecks.io (free,
open-source, native Slack integration). Create a check with a ~5 min
period and ~10 min grace, connect it to Slack, and put its ping URL in
alertmanager_heartbeat_url (below). A self-hosted alternative is a
scheduled GitHub Actions workflow that probes the public Gateway and posts
to Slack — fully under your control, no third-party monitor.
The heartbeat URL is injected by Flux substitution
(${alertmanager_heartbeat_url}); unset, it defaults to an invalid URL,
so local/CI simply never heartbeat — harmless.
There is no remote-write or SaaS mirror. Instead, the persistent
Prometheus, Alertmanager and Loki volumes live in the monitoring
namespace, which Velero's daily-full schedule backs up to R2 every day
(includedNamespaces: ["*"], Kopia fs-backup). Restore is the standard
Velero flow in runbook.md. Backups are filesystem-level
and crash-consistent (Prometheus/Loki recover via their WAL on restore);
fine for a 24 h RPO.
Self-hosted, exposed at grafana.${domain} behind oauth2-proxy SSO. Since
the route is already gated to a single GitHub user, Grafana runs with
anonymous Admin and the login form disabled — whoever clears the SSO
gate is the operator. Datasources: Prometheus (auto-wired by the chart)
and Loki. Default Kubernetes dashboards are provisioned; the pod stays
ephemeral because dashboards are config, not state.
Two sources:
-
Curated chart default rules (
defaultRules.create: true). We keep the well-tested groups —general(incl.Watchdog),alertmanager,prometheus,prometheusOperator,kubernetesApps(CrashLooping/ReplicasMismatch/…),kubernetesStorage,node,kubeStateMetrics— and disable the groups for control-plane components we don't scrape (etcd, kube-apiserver/-scheduler/-controller- manager, kube-proxy, windows).KubeCPUOvercommit/KubeMemoryOvercommitare disabled — guaranteed noise on a cluster that runs hot on purpose. -
Platform-specific rules in
k8s/bases/infrastructure/alerts/platform-critical.yaml(not in the chart): Velero/CNPG backups, Flux reconciliation, cert-manager expiry, cluster-autoscaler and resource-pressure.
| Alert | Severity | Why |
|---|---|---|
Watchdog |
none | Always firing → external heartbeat |
NodeNotReady |
critical | Single node loss |
PersistentVolumeFillingUp |
critical | >90% PVC |
CertificateExpiringSoon |
warning | <14 d to expiry, cert-manager not renewing |
FluxKustomizationNotReady |
critical | Reconciliation broken >15 min |
VeleroNoRecentBackup |
critical | RPO breach — no successful backup in 30h |
CNPGClusterDegraded |
critical | Primary alone, no streaming replica |
(plus the chart's workload/storage/self-monitoring alerts.)
The Slack webhook and heartbeat URL are secrets, so they live in the
per-cluster variables-cluster-secret.enc.yaml (under bootstrap/) and
must be set by hand. Both are read from the Secret variables-cluster,
which is a Flux substituteFrom source.
# 1. Slack incoming webhook for alert notifications.
sops --set '["stringData"]["alertmanager_webhook_url"] "https://hooks.slack.com/services/XXX/YYY/ZZZ"' \
k8s/clusters/prod/bootstrap/variables-cluster-secret.enc.yaml
# 2. External heartbeat-monitor ping URL (e.g. healthchecks.io).
sops --set '["stringData"]["alertmanager_heartbeat_url"] "https://hc-ping.com/<uuid>"' \
k8s/clusters/prod/bootstrap/variables-cluster-secret.enc.yamlSlack side: create an incoming webhook for your alerts channel (e.g.
#platform-alerts) — the channel in the config is cosmetic, since an incoming
webhook posts to the channel it was created for. healthchecks.io side: create
the check, connect its Slack integration, copy the ping URL.
| Env | alertmanager_webhook_url |
alertmanager_heartbeat_url |
|---|---|---|
| local | invalid URL (alerts stay local) | unset → invalid (no heartbeat) |
| prod | Slack alerts-channel webhook | healthchecks.io ping URL |
- Silence an alert while you work: Alertmanager UI at
https://alertmanager.${domain}→ Silences → New. - Check why an alert fired / query metrics: Prometheus at
https://prometheus.${domain}(Graph / Alerts / Targets), or a Grafana dashboard athttps://grafana.${domain}. - Read logs: Grafana → Explore → Loki datasource, e.g.
{namespace="velero"} |= "error". - Cost: OpenCost at
https://opencost.${domain}.
All four are behind GitHub SSO (oauth2-proxy, restricted to the operator).
| Component | Requests | Limits |
|---|---|---|
| Prometheus | 50m / 256 Mi | — / 1.5 Gi |
| Alertmanager | 50m / 64 Mi (×2) | — / 128 Mi |
| Grafana | 50m / 128 Mi | — / 256 Mi |
| Loki | 50m / 128 Mi | — / 512 Mi |
| Alloy | 25m / 96 Mi (×node) | — / 256 Mi |
| Operator | 50m / 128 Mi | — / 256 Mi |
VPA right-sizes the requests at runtime (RequestsOnly), so the limits are
the real ceilings. The always-on tier (Grafana, Loki, persistent
Prometheus + Alertmanager) is what motivates the planned bump to a 4th
static worker (ksail.prod.yaml); it's held at 3 today while prod is
right-sized, and the hetzner overlay opts out of kubevirt/cdi to free
~1.5 GiB so the tier still fits.
- DR runbook — what to do when an alert fires, and restore
- Velero + CNPG — the systems whose health is checked
- restore-drill.md — CI validation of the stack
- HA primitives — cluster environments and topology