Rotations d'astreinte avec ntfy : alertes push avec escalade, sans PagerDuty
PagerDuty commence à 21 dollars par utilisateur et par mois et envoie vos données d'alerte aux États-Unis. Pour une équipe de deux à dix personnes, cela se fait avec un serveur ntfy auto-hébergé, un planning de rotation dans un fichier texte et un script de cinquante lignes : les alertes vont à qui est de garde cette semaine, escaladent après dix minutes sans acquittement vers le remplaçant, et passent à travers le mode silencieux du téléphone. Acquittement en un tap inclus.
Sommaire
Tous les guides de ce site poussent les alertes vers ntfy. Cela fonctionne parfaitement jusqu'à ce que vous soyez plus d'un : alors tout le monde reçoit tout, à 03:00, y compris ceux qui ne sont pas de garde — et après deux semaines, tout le monde a coupé le topic. Ce dont vous avez besoin est petit : un planning (qui est de garde quand), un routage (l'alerte vers cette personne), une escalade (après X minutes sans réponse, vers la suivante) et une façon de dire « je m'en occupe ». PagerDuty fait cela pour 21 dollars par personne et par mois. Cet article le fait avec ntfy et cron.
Étape 1 : votre propre serveur ntfy
ntfy.sh est gratuit, mais pour l'astreinte vous voulez votre propre serveur : pas de limites de débit, pas de dépendance à un tiers, et vos alertes (avec noms d'hôtes et messages d'erreur) restent chez vous.
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 # personne ne peut lire/écrire sans compte
NTFY_BEHIND_PROXY: "true"
NTFY_ENABLE_LOGIN: "true"
NTFY_ATTACHMENT_CACHE_DIR: /var/cache/ntfy/attachments
NTFY_UPSTREAM_BASE_URL: https://ntfy.sh # nécessaire pour le push iOS ; Android fonctionne sans
EOF
sudo docker compose up -d
# Reverse proxy (Caddy) — le support WebSocket est par défaut
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 est délibéré : un serveur ntfy ouvert est un canal de spam ouvert vers les téléphones de votre équipe. Créez les comptes et les droits :
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 # les serveurs qui envoient les alertes
$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 pour les scripts, au lieu d'un mot de passe
Sur le téléphone : application ntfy (F-Droid, Play Store, App Store), serveur https://ntfy.example.be, connexion avec le compte personnel.
Étape 2 : le planning de rotation
Un fichier texte, sur l'hôte qui route les alertes. Semaines ISO, ou plages de dates — ce que votre équipe utilise pour s'organiser.
sudo tee /etc/oncall/schedule.tsv >/dev/null <<'EOF'
# du au primaire remplaçant tel_primaire (pour le texte d'escalade)
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
Qui est de garde maintenant :
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 "primaire : $(oncall primary), remplaçant : $(oncall backup)"
Un topic par personne : alerts-jeroen, alerts-marie, alerts-tom. Chacun s'abonne à son propre topic et à alerts-all (informatif, en sourdine). Le planning détermine vers quel topic personnel vont les alertes critiques.
Étape 3 : le routeur — une seule entrée pour tous les scripts
Au lieu que chaque script de supervision pousse lui-même vers un topic, ils envoient à un script local qui route, déduplique et planifie l'escalade.
sudo tee /usr/local/bin/alert >/dev/null <<'EOF'
#!/usr/bin/env bash
# alert <sévérité : info|warn|crit> <titre> [corps via stdin]
# info → alerts-all (silencieux). warn → alerts-all + primaire (prio normale). crit → primaire (urgent, passe le mode silencieux) + escalade.
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) # clé de dédup par titre
ST=/var/lib/oncall; mkdir -p "$ST"
# Dédup : le même titre n'est pas repoussé dans les 30 min (sauf escalade crit)
if [ -f "$ST/$ID.sent" ] && [ "$(find "$ST/$ID.sent" -mmin -30)" ]; then exit 0; fi
touch "$ST/$ID.sent"
push() { # push <topic> <prio> <tags> [en-têtes supplémentaires...]
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)
# En-tête Actions : un tap = acquittement (POST vers le topic ack) — voir étape 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
# Planifier l'escalade : après 10 min sans ack → remplaçant ; après 20 min → les deux + téléphone dans le texte
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
# Usage dans chaque script de supervision :
echo "disk /var 91% on web-01" | alert crit "disk almost full web-01"
Priority: urgent fait la différence à 03:00 : l'application ntfy laisse passer un message urgent à travers le mode « ne pas déranger » (Android : si l'application a cette permission ; iOS : si les « alertes critiques » sont autorisées). min apparaît silencieusement dans la liste.
Étape 4 : acquittement et escalade
L'en-tête Actions place un bouton « ACK » sous la notification. Un tap fait un POST vers le topic ack avec l'identifiant de l'alerte. Un second cron lit ces acks et escalade ce qui n'est pas acquitté.
sudo tee /usr/local/sbin/oncall-escalate.sh >/dev/null <<'EOF'
#!/usr/bin/env bash
# Chaque minute : lire les acks du topic ack, escalader les alertes crit non acquittées.
NTFY=https://ntfy.example.be; TOK=$(cat /etc/oncall/token); ST=/var/lib/oncall
[ -s "$ST/pending.tsv" ] || exit 0
# Récupérer les acks des dernières 24 h (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 # acquitté → terminé
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 "Non acquitté après 20 min. Primaire $prim ($phone) et remplaçant $back : que quelqu'un appelle." "$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 "Le primaire $prim n'a pas acquitté. Vous êtes le remplaçant." "$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" # abandonner après 2 h
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
# Le topic ack : tout le monde peut écrire (le bouton), seul le routeur lit
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
La chaîne : alerte crit → primaire (urgent, bouton ACK) → après 10 min sans ack : remplaçant (urgent, bouton ACK) → après 20 min : les deux, avec le numéro de téléphone dans le texte pour que celui qui lit puisse appeler l'autre. Qui tape ACK arrête l'escalade ; l'ack est dans le topic ack avec un nom — c'est votre journal de qui a réagi quand.
Étape 5 : testez, chaque semaine
# Lundi 09:00 : une alerte de test vers le primaire, avec ACK. Non acquittée après 10 min = le planning ou l'application ne va pas.
echo "0 9 * * 1 root echo 'Weekly on-call test — tap ACK' | /usr/local/bin/alert crit \"on-call test $(date +\%F)\"" | sudo tee /etc/cron.d/oncall-test
Et une fois par trimestre un vrai exercice : quelqu'un remplit un disque sur un hôte de test à 22:00, et vous mesurez le temps jusqu'à l'ack. Ce chiffre (MTTA, mean time to acknowledge) est ce qu'un client avec un SLA veut voir.
Pièges
- Push iOS sans upstream. Sur iPhone, les pushes ntfy n'arrivent que via le service push d'Apple, et pour cela votre serveur doit pointer
NTFY_UPSTREAM_BASE_URLvers ntfy.sh. Seul l'identifiant du message va vers ntfy.sh, pas le contenu — mais c'est une dépendance, et vous devez la connaître. Android fonctionne de façon totalement autonome. - Optimisation de batterie. Android tue les connexions en arrière-plan ; l'application ntfy doit être dans la liste d'exceptions (l'application le demande). Sans cela, les alertes urgentes arrivent avec des minutes de retard. C'est l'une des choses que le test hebdomadaire attrape.
- Planning qui expire. Si
schedule.tsvn'a pas de ligne pour aujourd'hui,PRIMest vide et l'alerte va versalerts-— vers personne. Ajoutez au script un repli (PRIM=${PRIM:-jeroen}) et un contrôle quotidien qui avertit quand le planning se termine sous 14 jours. - Dédup qui cache un second incident. « disk almost full web-01 » à 03:00 et à nouveau à 03:20 (après un nettoyage qui n'a pas aidé) est le même titre dans les 30 minutes → supprimé. Mettez la valeur mesurée dans le corps, pas dans le titre, et raccourcissez la fenêtre de dédup pour crit.
- Une seule personne dans le planning. Une rotation avec un seul nom n'est pas une rotation mais un burn-out. Au moins deux, de préférence trois ; et le remplaçant de cette semaine est le primaire de la suivante.
Ce qu'il vous manque encore
- Les remplacements. « Marie est malade, Tom prend le relais jusqu'à jeudi » est une édition de
schedule.tsvvia ssh — à 06:30, depuis un téléphone. Il manque une interface. - Le routage par client. Un MSP veut que les alertes du client A aillent à l'équipe du client A, et que le client A ait aussi son propre topic. C'est une seconde dimension dans le planning et dans le routeur.
- Le reporting. Combien d'alertes par semaine, combien la nuit, temps moyen jusqu'à l'ack, qui a été réveillé le plus souvent : dispersé dans le topic
acketpending.tsv.
Comment monsys fait
monsys utilise la même brique — un serveur ntfy auto-hébergé, pas de Twilio, pas de PagerDuty — mais la rotation d'astreinte est par groupe ou tenant dans le tableau de bord : des créneaux avec début, fin et contact, des remplacements sans ssh, et par alerte la personne de garde est automatiquement recherchée et ajoutée au push. Acquittement et résolution se font dans le tableau de bord ou via l'action push, avec nom et heure dans le journal d'audit ; MTTA et MTTR par groupe sont dans les métriques d'exploitation et le Trust Score. Pour les MSP : les alertes du client A vont à l'astreinte du groupe du client A, et le client peut suivre sur son propre topic ntfy.
FAQ
ntfy est-il assez fiable pour l'astreinte ?
Pour une équipe qui gère son propre serveur et fait le test hebdomadaire : oui. Le protocole est simple (HTTP + WebSocket), l'application est stable et la priorité urgent passe à travers les modes silencieux. Le point faible n'est pas ntfy mais le téléphone : optimisation de batterie et permissions oubliées.
Et le SMS ou l'appel en repli ?
Délibérément absents de cette configuration : les passerelles SMS (Twilio et co) sont des services américains dont le SLA ne vaut pas mieux que celui de ntfy, et appeler nécessite un opérateur de téléphonie. L'escalade après 20 minutes met donc le numéro de téléphone dans le texte, pour qu'un humain appelle. Pour une organisation sous NIS2, c'est aussi le choix le plus défendable.
Puis-je combiner cela avec Alertmanager ou Uptime Kuma ?
Oui. Les deux peuvent envoyer vers un webhook ou directement vers ntfy ; faites-les envoyer vers un point local qui appelle /usr/local/bin/alert, ou configurez-les directement sur alerts-all et n'utilisez le routeur que pour crit. Le planning et l'escalade restent alors en un seul endroit.
Rédigé par l'équipe monsys — des sysadmins qui font cela tous les jours.
Fait à la main ? Laissez monsys s'en charger en continu.
Tout ce que contient ce guide tourne dans monsys comme contrôle permanent, avec historique, alertes et preuves d'audit. 5 serveurs gratuits, hébergés en Belgique, installés en 60 secondes.