MSP & multi-tenantgevorderd5 min lezen

On-call-rotaties met ntfy: push-alerts met escalatie, zonder PagerDuty

PagerDuty kost vanaf 21 dollar per gebruiker per maand en stuurt je alertdata naar de VS. Voor een team van twee tot tien mensen kan het met een self-hosted ntfy-server, een rotatieschema in een tekstbestand en een script van vijftig regels: alerts gaan naar wie deze week wacht heeft, escaleren na tien minuten zonder bevestiging naar de back-up, en breken door de stille modus op de telefoon. Inclusief acknowledge via één tik.

Inhoud
  1. Stap 1: een eigen ntfy-server
  2. Stap 2: het rotatieschema
  3. Stap 3: de router — één ingang voor alle scripts
  4. Stap 4: acknowledge en escalatie
  5. Stap 5: test het, elke week
  6. Valkuilen
  7. Wat je hiermee nog niet hebt
  8. Zo doet monsys het
  9. FAQ

Alle how-to's op deze site pushen alerts naar ntfy. Dat werkt perfect tot je met meer dan één persoon bent: dan krijgt iedereen alles, om 03:00, ook wie geen dienst heeft — en na twee weken heeft iedereen het topic gedempt. Wat je nodig hebt is klein: een schema (wie heeft wanneer wacht), routing (alert naar die persoon), escalatie (na X minuten zonder reactie naar de volgende) en een manier om te zeggen "ik ben ermee bezig". PagerDuty doet dat voor 21 dollar per persoon per maand. Dit artikel doet het met ntfy en cron.

Stap 1: een eigen ntfy-server

ntfy.sh is gratis te gebruiken, maar voor on-call wil je een eigen server: geen rate-limits, geen afhankelijkheid van een derde, en je alerts (met hostnamen en foutmeldingen) blijven bij jou.

sudo install -d /opt/ntfy && cd /opt/ntfy
sudo tee docker-compose.yml >/dev/null <<'EOF'
services:
  ntfy:
    image: binwiederhier/ntfy:latest
    command: serve
    restart: unless-stopped
    ports: ["127.0.0.1:8085:80"]
    volumes:
      - ./cache:/var/cache/ntfy
      - ./etc:/etc/ntfy
    environment:
      NTFY_BASE_URL: https://ntfy.example.be
      NTFY_CACHE_FILE: /var/cache/ntfy/cache.db
      NTFY_AUTH_FILE: /var/cache/ntfy/user.db
      NTFY_AUTH_DEFAULT_ACCESS: deny-all      # niemand mag lezen/schrijven zonder account
      NTFY_BEHIND_PROXY: "true"
      NTFY_ENABLE_LOGIN: "true"
      NTFY_ATTACHMENT_CACHE_DIR: /var/cache/ntfy/attachments
      NTFY_UPSTREAM_BASE_URL: https://ntfy.sh   # nodig voor iOS-push; Android werkt zonder
EOF
sudo docker compose up -d
# Reverse proxy (Caddy) — WebSocket-ondersteuning is standaard
printf 'ntfy.example.be {\n    reverse_proxy 127.0.0.1:8085\n}\n' | sudo tee -a /etc/caddy/Caddyfile && sudo systemctl reload caddy

deny-all is bewust: een open ntfy-server is een open spam-kanaal naar de telefoons van je team. Maak accounts en rechten aan:

N="sudo docker compose exec -T ntfy ntfy"
$N user add --role=admin ops-admin
$N user add jeroen; $N user add marie; $N user add tom
$N user add --role=user monitoring                     # de servers die alerts sturen
$N access monitoring 'alerts-*' write-only
$N access jeroen 'alerts-*' read-write; $N access marie 'alerts-*' read-write; $N access tom 'alerts-*' read-write
$N token add monitoring                                # token voor de scripts, i.p.v. wachtwoord

Op de telefoon: ntfy-app (F-Droid, Play Store, App Store), server https://ntfy.example.be, inloggen met het persoonlijke account.

Stap 2: het rotatieschema

Eén tekstbestand, op de host die de alerts routeert. ISO-weken, of datumbereiken — wat je team ook gebruikt om afspraken te maken.

sudo tee /etc/oncall/schedule.tsv >/dev/null <<'EOF'
# van	tot	primair	backup	telefoon_primair (voor de escalatie-tekst)
2026-09-14	2026-09-20	jeroen	marie	+32470000001
2026-09-21	2026-09-27	marie	tom	+32470000002
2026-09-28	2026-10-04	tom	jeroen	+32470000003
2026-10-05	2026-10-11	jeroen	marie	+32470000001
EOF

Wie heeft nu dienst:

oncall() {  # oncall primary|backup|phone
  awk -F'\t' -v d="$(date +%F)" -v f="$1" '!/^#/ && $1<=d && d<=$2 { print (f=="primary"?$3:(f=="backup"?$4:$5)); exit }' /etc/oncall/schedule.tsv
}
echo "primair: $(oncall primary), backup: $(oncall backup)"

Per persoon één topic: alerts-jeroen, alerts-marie, alerts-tom. Iedereen abonneert zich op zijn eigen topic en op alerts-all (informatief, gedempt). Het schema bepaalt naar welk persoonlijk topic de kritieke alerts gaan.

Stap 3: de router — één ingang voor alle scripts

In plaats van dat elk monitoringscript zelf naar een topic pusht, sturen ze naar één lokaal script dat routeert, dedupliceert en escalatie inplant.

sudo tee /usr/local/bin/alert >/dev/null <<'EOF'
#!/usr/bin/env bash
# alert <severity: info|warn|crit> <titel> [body via stdin]
# info → alerts-all (stil). warn → alerts-all + primair (normale prio). crit → primair (urgent, doorbreekt stille modus) + escalatie.
set -u
SEV=$1; TITLE=$2; BODY=$(cat); NTFY=https://ntfy.example.be; TOK=$(cat /etc/oncall/token)
S=/etc/oncall/schedule.tsv; D=$(date +%F)
PRIM=$(awk -F'\t' -v d="$D" '!/^#/ && $1<=d && d<=$2 {print $3; exit}' "$S")
BACK=$(awk -F'\t' -v d="$D" '!/^#/ && $1<=d && d<=$2 {print $4; exit}' "$S")
ID=$(printf '%s' "$TITLE" | sha1sum | cut -c1-12)          # dedup-sleutel per titel
ST=/var/lib/oncall; mkdir -p "$ST"

# Dedup: dezelfde titel binnen 30 min niet opnieuw pushen (behalve crit-escalatie)
if [ -f "$ST/$ID.sent" ] && [ "$(find "$ST/$ID.sent" -mmin -30)" ]; then exit 0; fi
touch "$ST/$ID.sent"

push() { # push <topic> <prio> <tags> [extra headers...]
  local topic=$1 prio=$2 tags=$3; shift 3
  curl -s -u ":$TOK" -H "Title: $TITLE" -H "Priority: $prio" -H "Tags: $tags" "$@" --data-binary "$BODY" "$NTFY/$topic" >/dev/null
}
case $SEV in
  info) push alerts-all min information_source ;;
  warn) push alerts-all default warning; push "alerts-$PRIM" default warning ;;
  crit)
    # Actions-header: één tik = acknowledge (POST naar ack-topic) — zie stap 4
    push "alerts-$PRIM" urgent rotating_light -H "Actions: http, ACK, $NTFY/ack, method=POST, body=$ID $PRIM, clear=true"
    push alerts-all default rotating_light
    # Escalatie inplannen: na 10 min zonder ack → backup; na 20 min → beiden + telefoon in de tekst
    echo "$ID	$(date +%s)	$PRIM	$BACK	$TITLE" >> "$ST/pending.tsv" ;;
esac
EOF
sudo chmod 0755 /usr/local/bin/alert
sudo install -d -m 0750 /etc/oncall && echo "tk_xxxxxxxxxxxxxxxx" | sudo tee /etc/oncall/token >/dev/null && sudo chmod 0640 /etc/oncall/token
# Gebruik in elk monitoringscript:
echo "disk /var 91% on web-01" | alert crit "disk almost full web-01"

De Priority: urgent is wat het verschil maakt om 03:00: de ntfy-app laat een urgent-bericht door de "niet storen"-modus heen (Android: als de app die permissie heeft; iOS: als "critical alerts" is toegestaan). min verschijnt stil in de lijst.

Stap 4: acknowledge en escalatie

De Actions-header zet een knop "ACK" onder de notificatie. Eén tik doet een POST naar het topic ack met de alert-id. Een tweede cron leest die acks en escaleert wat niet bevestigd is.

sudo tee /usr/local/sbin/oncall-escalate.sh >/dev/null <<'EOF'
#!/usr/bin/env bash
# Elke minuut: lees acks van het ack-topic, escaleer onbevestigde crit-alerts.
NTFY=https://ntfy.example.be; TOK=$(cat /etc/oncall/token); ST=/var/lib/oncall
[ -s "$ST/pending.tsv" ] || exit 0
# Acks van de laatste 24 u ophalen (JSON, poll)
curl -s -u ":$TOK" "$NTFY/ack/json?poll=1&since=24h" | jq -r '.message // empty' | awk '{print $1}' | sort -u > "$ST/acked.txt"
now=$(date +%s); : > "$ST/pending.new"
while IFS=$'\t' read -r id t0 prim back title; do
  if grep -qx "$id" "$ST/acked.txt"; then continue; fi          # bevestigd → klaar
  age=$(( (now - t0) / 60 ))
  if [ "$age" -ge 20 ] && [ ! -f "$ST/$id.esc2" ]; then
    phone=$(awk -F'\t' -v d="$(date +%F)" '!/^#/ && $1<=d && d<=$2 {print $5; exit}' /etc/oncall/schedule.tsv)
    for p in "$prim" "$back"; do
      curl -s -u ":$TOK" -H "Title: ESCALATION 20m: $title" -H "Priority: urgent" -H "Tags: rotating_light,sos" \
        -d "Niet bevestigd na 20 min. Primair $prim ($phone) en backup $back: iemand bellen." "$NTFY/alerts-$p" >/dev/null
    done; touch "$ST/$id.esc2"
  elif [ "$age" -ge 10 ] && [ ! -f "$ST/$id.esc1" ]; then
    curl -s -u ":$TOK" -H "Title: ESCALATION 10m: $title" -H "Priority: urgent" -H "Tags: rotating_light" \
      -H "Actions: http, ACK, $NTFY/ack, method=POST, body=$id $back, clear=true" \
      -d "Primair $prim heeft niet bevestigd. Jij bent backup." "$NTFY/alerts-$back" >/dev/null
    touch "$ST/$id.esc1"
  fi
  [ "$age" -lt 120 ] && printf '%s\t%s\t%s\t%s\t%s\n' "$id" "$t0" "$prim" "$back" "$title" >> "$ST/pending.new"   # na 2 u opgeven
done < "$ST/pending.tsv"
mv "$ST/pending.new" "$ST/pending.tsv"
EOF
sudo chmod 0755 /usr/local/sbin/oncall-escalate.sh
echo '* * * * * root /usr/local/sbin/oncall-escalate.sh' | sudo tee /etc/cron.d/oncall-escalate
# Het ack-topic: iedereen mag schrijven (de knop), alleen de router leest
sudo docker compose -f /opt/ntfy/docker-compose.yml exec -T ntfy ntfy access jeroen ack write-only
sudo docker compose -f /opt/ntfy/docker-compose.yml exec -T ntfy ntfy access marie ack write-only
sudo docker compose -f /opt/ntfy/docker-compose.yml exec -T ntfy ntfy access tom ack write-only
sudo docker compose -f /opt/ntfy/docker-compose.yml exec -T ntfy ntfy access monitoring ack read-write

De keten: crit-alert → primair (urgent, ACK-knop) → na 10 min zonder ack: backup (urgent, ACK-knop) → na 20 min: beiden, met telefoonnummer in de tekst zodat wie het leest de ander kan bellen. Wie op ACK tikt, stopt de escalatie; de ack staat met naam in het ack-topic — dat is je log van wie wanneer reageerde.

Stap 5: test het, elke week

# Maandag 09:00: een testalert naar de primaire, met ACK. Niet bevestigd na 10 min = het schema of de app klopt niet.
echo "0 9 * * 1 root echo 'Weekly on-call test — tik ACK' | /usr/local/bin/alert crit \"on-call test $(date +\%F)\"" | sudo tee /etc/cron.d/oncall-test

En één keer per kwartaal een echte oefening: iemand zet om 22:00 een schijf vol op een testhost, en je meet hoe lang het duurt tot de ack. Dat getal (MTTA, mean time to acknowledge) is wat een klant met een SLA wil zien.

Valkuilen

  • iOS-push zonder upstream. Op iPhones komen ntfy-pushes alleen aan via Apple's push-dienst, en daarvoor moet je server NTFY_UPSTREAM_BASE_URL naar ntfy.sh laten wijzen. Alleen het bericht-id gaat naar ntfy.sh, niet de inhoud — maar het is een afhankelijkheid, en je moet hem kennen. Android werkt volledig zelfstandig.
  • Batterij-optimalisatie. Android doodt achtergrondverbindingen; de ntfy-app moet in de uitzonderingslijst (de app vraagt erom). Zonder dat komen urgent-alerts minuten te laat. Dat is één van de dingen die de wekelijkse test vangt.
  • Schema dat afloopt. Als schedule.tsv geen regel voor vandaag heeft, is PRIM leeg en gaat de alert naar alerts- — naar niemand. Voeg aan het script een fallback toe (PRIM=${PRIM:-jeroen}) én een dagelijkse check die waarschuwt als het schema binnen 14 dagen afloopt.
  • Dedup die een tweede incident verbergt. "disk almost full web-01" om 03:00 en opnieuw om 03:20 (na een cleanup die niet hielp) is dezelfde titel binnen 30 minuten → onderdrukt. Zet de meetwaarde niet in de titel maar in de body, en maak het dedup-venster korter voor crit.
  • Eén persoon in het schema. Een rotatie met één naam is geen rotatie maar een burn-out. Minimaal twee, liefst drie; en de backup van deze week is de primaire van volgende week.

Wat je hiermee nog niet hebt

  • Overrides. "Marie is ziek, Tom neemt over tot donderdag" is een edit in schedule.tsv via ssh — om 06:30, vanaf een telefoon. Een UI ontbreekt.
  • Per-klant routing. Een MSP wil dat alerts van klant A naar het team van klant A gaan, en dat klant A zelf ook een topic heeft. Dat is een tweede dimensie in het schema en in de router.
  • Rapportage. Hoeveel alerts per week, hoeveel 's nachts, gemiddelde tijd tot ack, wie het vaakst wakker werd: staat verspreid in het ack-topic en pending.tsv.

Zo doet monsys het

monsys gebruikt dezelfde bouwsteen — een self-hosted ntfy-server, geen Twilio, geen PagerDuty — maar de on-call-rotatie staat per groep of tenant in het dashboard: shifts met start, einde en contact, overrides zonder ssh, en per alert wordt de dienstdoende persoon automatisch opgezocht en toegevoegd aan de push. Acknowledge en resolve gebeuren in het dashboard of via de push-actie, met naam en tijd in het audit-log; MTTA en MTTR per groep staan in de operations-metrics en in de Trust Score. Voor MSP's: alerts van klant A gaan naar de on-call van klant A's groep, en de klant kan meekijken op zijn eigen ntfy-topic.

FAQ

Is ntfy betrouwbaar genoeg voor on-call?

Voor een team dat zijn eigen server beheert en de wekelijkse test doet: ja. Het protocol is eenvoudig (HTTP + WebSocket), de app is stabiel en de urgent-prioriteit doorbreekt stille modi. De zwakke plek is niet ntfy maar de telefoon: batterij-optimalisatie en vergeten permissies.

Hoe zit het met SMS of bellen als fallback?

Bewust niet in deze opzet: SMS-gateways (Twilio en co) zijn US-diensten met een SLA die niet beter is dan die van ntfy, en bellen vereist een telefonieprovider. De escalatie na 20 minuten zet daarom het telefoonnummer in de tekst, zodat een mens belt. Voor een organisatie onder NIS2 is dat ook de defensiever te verdedigen keuze.

Kan ik dit combineren met Alertmanager of Uptime Kuma?

Ja. Beide kunnen naar een webhook of rechtstreeks naar ntfy sturen; laat ze naar een lokaal endpoint sturen dat /usr/local/bin/alert aanroept, of configureer ze direct op alerts-all en gebruik de router alleen voor crit. Het schema en de escalatie blijven dan op één plek.

Geschreven door het monsys-team — sysadmins die dit dagelijks doen.

Zelf gedaan? Laat monsys het bijhouden.

Alles uit deze how-to draait in monsys als doorlopende check, met historie, alerts en audit-bewijs. 5 servers gratis, EU-gehost in België, geïnstalleerd in 60 seconden.