Alertmanager Config - Routing, Grouping, Silences, and Escalation
Status: Active
Last Updated: 2026-08-26
Category: Observability - Alerting
Prerequisites: simple-alerts, prometheus-basics
Time: 2-3 hours
Tags: alertmanager, routing, silences, inhibition, telegram, webhook, escalation
Summary
Alertmanager takes the firing alerts Prometheus rules produce and decides who hears about them, how fast, and how often. This article covers the routing tree, grouping and repeat intervals, silences vs inhibition, receiver setup for email/webhook/Telegram, and escalation patterns that keep 2 a.m. pages meaningful — building directly on the rules written in simple-alerts.md.
What You'll Learn
By the end of this article, you'll be able to:
- Design a routing tree by severity and team/area labels
- Tune group_by/group_wait/repeat_interval to stop notification storms
- Use silences for maintenance and inhibition for dependent failures
- Configure email, webhook, and Telegram receivers
- Implement time-based routing and simple escalation paths
Table of Contents
- Context / Why This Matters
- Implementation / Core Content
- Practical Examples
- Common Pitfalls & Troubleshooting
- Next Steps / Ops Actions
Context / Why This Matters
simple-alerts.md got alert rules firing in Prometheus. Out of the box those go nowhere useful: without Alertmanager you get no deduplication, no grouping, no "stop yelling during planned maintenance," and no way to route a disk warning somewhere different from an API outage. Alertmanager is the state machine between "condition is true" and "a human knows."
The core mental model: rules decide what matters; Alertmanager decides who cares. Keep severity/routing metadata on alerts as labels at rule-definition time — routing then becomes configuration, not rewrites.
Implementation / Core Content
Wiring Prometheus to Alertmanager
In prometheus.yml:
alerting:
alertmanagers:
- static_configs:
- targets: ["alertmanager:9093"]
rule_files:
- /etc/prometheus/rules/*.yml
And every rule from simple-alerts.md should carry routing labels:
- alert: HostDiskWillFillIn14Days
expr: predict_linear(node_filesystem_avail_bytes{mountpoint="/"}[7d], 14*24*3600) < 0
for: 30m
labels:
severity: warning
area: infra # ← used by the router
annotations:
summary: "Disk on {{ $labels.instance }} fills within ~14 days"
The routing tree
Alerts enter at the root receiver, then traverse child routes depth-first; first matching child wins (children are checked before continuing siblings only via continue: true). Unmatched alerts fall back to the parent's receiver.
Full worked config (alertmanager.yml):
global:
resolve_timeout: 5m
smtp_smarthost: "smtp.example.com:587"
smtp_from: "alerts@fogserv.cloud"
smtp_auth_username: "alerts@fogserv.cloud"
smtp_auth_password_file: /etc/alertmanager/secrets/smtp-password
templates:
- "/etc/alertmanager/templates/*.tmpl"
route:
receiver: email-default # fallback: nothing matched anywhere
group_by: ["alertname", "instance"]
group_wait: 30s # collect siblings before first notify
group_interval: 5m # min gap between notifies of same group
repeat_interval: 4h # re-notify while still firing
routes:
# 1. Page-worthy problems → immediate, everywhere
- matchers: [severity="critical"]
receiver: telegram-oncall
group_wait: 10s
repeat_interval: 1h
continue: true # ALSO send to default for the record
# 2. Infra warnings → email, relaxed cadence
- matchers: [severity="warning", area="infra"]
receiver: email-infra
repeat_interval: 12h
# 3. App-level warnings → webhook into your ticket bot
- matchers: [severity="warning", area="apps"]
receiver: webhook-tickets
repeat_interval: 8h
# 4. Nightly noisy-but-harmless batch jobs: only page during work hours
- matchers: [job="batch-jobs"]
receiver: email-default
active_time_intervals: [work-hours]
repeat_interval: 24h
time_intervals:
- name: work-hours
time_intervals:
- weekdays: ["monday:friday"]
times:
- start: "08:00"
end: "18:00"
- location: "Europe/Berlin"
receivers:
- name: email-default
email_configs:
- to: "homelab-alerts@fogserv.cloud"
- name: email-infra
email_configs:
- to: "infra@fogserv.cloud"
- name: telegram-oncall
telegram_configs:
- bot_token_file: /etc/alertmanager/secrets/tg-bot-token
chat_id: -1001234567890
send_resolved: true
- name: webhook-tickets
webhook_configs:
- url: "http://task-bot.internal:8080/alertmanager"
send_resolved: true
Interval semantics worth internalizing (they cause most confusion):
| Setting | Meaning | Typical value |
|---|---|---|
group_wait |
Delay before the FIRST notification of a new group, to batch friends arriving | 30s |
group_interval |
Minimum delay between notifications about the SAME group | 5m |
repeat_interval |
How often to re-send while the group stays firing | 4h (critical: 1h) |
Grouping strategy
group_by controls which alerts collapse into one notification. Sensible choices:
["alertname", "instance"]— one message per problem per host (default above)["alertname"]— collapse a host-count blast ("6 hosts low on memory" as one message)- Avoid grouping by high-cardinality labels or you're back to one-notification-per-alert
Silences vs inhibition
They solve different problems:
- Silence: mute specific alerts for a period — planned maintenance, known-bad host awaiting parts. Created in the UI (
Alerts → Silence) with matchers likeinstance=~"backup-nas.*"plus duration. - Inhibition: automatic suppression when a root-cause alert fires. When a host is down, its dozen downstream warnings are noise:
inhibit_rules:
# If a host is down, suppress all other alerts from that host
- source_matchers: [alertname="HostDown"]
target_matchers: [severity=~"warning|info"]
equal: ["instance"]
# Critical suppresses warning of the same alertname+instance
- source_matchers: [severity="critical"]
target_matchers: [severity="warning"]
equal: ["alertname", "instance"]
Rule of thumb: if you'd create the same silence every week, encode it as an inhibition rule instead.
Receivers
Email — shown above under global.smtp_*. Test with a deliberately-firing rule.
Webhook — POSTs this JSON shape (handle status: firing/resolved):
{
"status": "firing",
"alerts": [
{
"status": "firing",
"labels": { "alertname": "HostDiskWillFillIn14Days", "severity": "warning", "instance": "docker-01:9100" },
"annotations": { "summary": "Disk on docker-01 fills within ~14 days" },
"startsAt": "2026-08-26T09:30:00Z",
"endsAt": "0001-01-01T00:00:00Z"
}
],
"groupLabels": { "alertname": "HostDiskWillFillIn14Days" }
}
Minimal handler (Node/Express):
app.post("/alertmanager", (req, res) => {
for (const a of req.body.alerts ?? []) {
const { alertname, instance } = a.labels;
if (a.status === "firing") createTicket(`${alertname} on ${instance}`, a.annotations.summary);
else closeTicket(`${alertname} on ${instance}`);
}
res.sendStatus(200);
});
Telegram — create a bot with @BotFather, add it to a group, get the chat id via getUpdates, then use telegram_configs as shown. send_resolved: true so recovery messages arrive too.
Escalation patterns without extra software
True paging tools (PagerDuty/Grafana OnCall) are overkill here; these patterns cover most homelab needs:
# Pattern 1: re-notify faster the longer something burns (two routes, same target,
# second gated on nothing but repeat_interval difference won't escalate by age —
# so pair Alertmanager with a webhook consumer that counts notifications):
routes:
- matchers: [severity="critical"]
receiver: telegram-oncall
repeat_interval: 30m
continue: true
- matchers: [severity="critical"]
receiver: webhook-escalator # escalator re-sends to SMS after 3 unacked
# Pattern 2: business-hours vs night split
route:
routes:
- matchers: [severity="warning"]
receiver: email-warnings
active_time_intervals: [work-hours]
- matchers: [severity="warning"]
receiver: telegram-oncall # same class of alert pages at night
inactive_time_intervals: [work-hours]
Validate every change before reload — Alertmanager refuses bad configs at startup otherwise:
amtool check-config /etc/alertmanager/alertmanager.yml
curl -X POST http://alertmanager:9093/-/reload
amtool alert query alertmanager=http://alertmanager:9093 # see live state from CLI
Practical Examples
Example 1: Stop the restart-storm spam
A flapping container fires ContainerCrashLooping every few minutes. Fix at three levels:
- Alert side (simple-alerts.md): require persistence —
for: 15m - Route side: give that alertname its own route with
repeat_interval: 2h - Incident side: silence the instance while you actually fix it
amtool silence add instance="docker-01:9100" alertname="ContainerCrashLooping" \
--duration 2h --comment "fixing image pull, JIRA-123" \
--alertmanager.url http://localhost:9093
Example 2: Maintenance window without lost history
Before upgrading the Proxmox host:
amtool silence add cluster="proxmox" --duration 90m \
--comment "kernel upgrade 2026-08-26" --author "ops@fogserv.cloud"
Alerts still fire and appear in the UI (marked suppressed); nobody gets pinged; resolved-after-maintenance states are clean.
Example 3: Verify the whole chain end-to-end
Fire a synthetic alert straight into Alertmanager, bypassing Prometheus:
curl -XPOST http://localhost:9093/api/v2/alerts -H 'Content-Type: application/json' -d '[
{"labels":{"alertname":"TestCritical","severity":"critical","area":"infra","instance":"test:1"},
"annotations":{"summary":"synthetic test alert"}}
]'
Expected: Telegram message within ~10s (group_wait), email too (because of continue: true), and both again resolved once you clear it. If any leg fails, check curl localhost:9093/api/v2/status and Alertmanager logs.
Common Pitfalls & Troubleshooting
| Problem | Cause | Fix |
|---|---|---|
| Alerts fire in Prometheus UI, nobody notified | No alerting section in prometheus.yml, or wrong AM address | Add alerting: block; verify /api/v2/status shows the Prometheus peer |
| Notification storm during an incident | group_by missing key labels, or group_wait too long letting groups fork |
Group by ["alertname","instance"]; keep group_wait modest |
| Same alert re-pings every few minutes | repeat_interval inherited too short from a parent route |
Set explicit repeat per route; remember child routes don't inherit values they override |
| Nothing arrives during off hours unexpectedly | An active_time_intervals gate left on a broad route |
Audit routes with time gates; amtool config show the effective tree |
| Silences pile up forever | Forgotten ad-hoc silences | Prefer --duration over open-ended; review weekly via UI filter |
| Critical alert also needed in the archive | Default routing stops at first match | continue: true on the critical route (as configured above) |
| Telegram messages never arrive | Bot not in the target chat, or wrong chat_id sign | Add bot to group, fetch real id via getUpdates (group ids are negative) |
Next Steps / Ops Actions
- Revisit alert expressions themselves: simple-alerts
- Add log-based alert queries as Grafana-managed rules: loki-logging
- Keep the runbook next to the alert — link articles per alert in annotations: grafana-dashboards
- Document who's on call and the response steps in your ops notes: documentation-as-code
Sources & Related Articles
External references consulted:
- https://prometheus.io/docs/alerting/latest/configuration/
- https://prometheus.io/docs/alerting/latest/notifications/
- https://prometheus.io/blog/2022/02/28/time-interval-support-in-alertmanager/
Related knowledge-base articles:
Change Log
2026-08-26
- Initial creation by kb-writing session (ox-alpha).