في مقال كيف تعمل الإشعارات الفورية Push Notifications شرحنا الطريق الذي تسلكه الرسالة من سيرفر إلى هاتف، وأن أغلب هذا الطريق يمر عبر Apple وGoogle، أما هذا المقال فعن الحالة التي يكون فيها المستقبل أنت وفريقك: النسخة الاحتياطية الليلية فشلت، والقرص وصل إلى 90%، والنشر إلى الإنتاج انتهى، وخدمة متوقفة منذ عشر دقائق. والطرق المعتادة لإيصال هذه التنبيهات لها مشكلات معروفة، فالبريد يضيع بين الرسائل أو يتأخر ويحتاج إلى سيرفر بريد يعمل، وروبوتات المحادثة في الخدمات التجارية تنقل أسماء سيرفراتك وأخطاءها إلى طرف ثالث، وكتابة تطبيق جوال خاص بالتنبيهات مشروع كامل.
والأداة التي نستخدمها هنا هي ntfy (تنطق notify)، وهي سيرفر صغير مكتوب ب Go يستقبل الرسالة بطلب HTTP عادي ويوصلها إلى تطبيق على الهاتف أو إلى المتصفح، ومعها تطبيق Android وتطبيق iOS وواجهة ويب، والمشروع بترخيص مزدوج Apache-2.0 وGPLv2. وسوف نتناول في هذا المقال ما يلي:
- كيف يعمل ntfy: المواضيع Topics والنشر والاشتراك.
- كيف يصل التنبيه إلى هاتفك على Android وiOS، وأين يمر عبر Google وApple.
- تشغيل سيرفر خاص لا يقبل أحداً إلا بحساب، مع المستخدمين والصلاحيات ACL والتوكنات Tokens.
- إرسال التنبيهات من السكربتات ومن Cron ومن خط CI ومن أدوات المراقبة مثل Alertmanager، كلها بتجربة حقيقية.
- من يراقب سيرفر التنبيهات نفسه، وأخطاء شائعة.
كيف يعمل ntfy؟ المواضيع Topics والنشر والاشتراك
فكرة ntfy هي النشر والاشتراك Publish/Subscribe عبر HTTP: الموضوع Topic اسم في الرابط مثل /backups، ومن يريد أن يرسل يكتب طلب POST إلى هذا الرابط وجسمه نص الرسالة، ومن يريد أن يستقبل يشترك في الموضوع نفسه، فيصله كل ما ينشر فيه. ولا يحتاج الموضوع إلى إنشاء مسبق، فأول رسالة تنشئه، والصورة التالية تبين الأطراف:

وهذا التصميم هو سبب انتشار ntfy، فالمرسل لا يحتاج إلى مكتبة ولا SDK، ويكفيه curl، والسطر التالي من توثيق النشر رسالة كاملة إلى السيرفر العام:
curl -d "Backup successful 😀" ntfy.sh/mytopicوتستطيع أن تضيف للرسالة عنواناً بالترويسة Title، وأولوية من 1 إلى 5 بالترويسة Priority تحدد في تطبيق Android الصوت والاهتزاز وهل يظهر الإشعار فوق الشاشة، ووسوماً Tags يتحول بعضها إلى رموز تعبيرية، ورابطاً يفتح عند الضغط Click، ونصاً بصيغة Markdown، وكل ذلك موثق في صفحة النشر. وللحدود التي تهمك من قائمة الحدود: الرسالة حتى 4096 بايت وما زاد يتحول إلى مرفق، والعنوان حتى 1 KB، والسيرفر يحفظ الرسائل افتراضياً 12 ساعة حتى يحصل عليها من كان غير متصل.
كيف يصل التنبيه إلى هاتفك؟
هنا يعود ما شرحناه في المقال السابق عن الطريق إلى الهاتف، وntfy يتعامل مع كل نظام بطريقة مختلفة، والصورة التالية تبين الطريقين:

- Android: بحسب توثيق تطبيق الهاتف يستخدم التطبيق Firebase فقط مع السيرفر العام ntfy.sh وفي نسخة Google Play وحدها، أما مع سيرفرك فيعمل بالتوصيل الفوري Instant Delivery، أي أن التطبيق يحفظ اتصالاً مع سيرفرك عبر خدمة تعمل في المقدمة Foreground Service تظهر كإشعار دائم صغير، ونسخة F-Droid لا تحتوي Firebase أصلاً. وبهذا يصل نص التنبيه من سيرفرك إلى هاتفك دون أن يمر على Google.
- iOS: لا يسمح النظام لتطبيق بحفظ اتصال في الخلفية، لذلك يشرح توثيق الإعداد أن السيرفر المستضاف يرسل إلى ntfy.sh طلباً اسمه
poll_requestيحمل معرف الرسالة فقط والهاش SHA-256 لرابط الموضوع، وntfy.sh يمرره عبر Firebase إلى APNs، فيستيقظ التطبيق ويجلب الرسالة الحقيقية من سيرفرك، والنص الذي يمر هو «New message» دائماً. وهذا يتطلب الإعدادupstream-base-url: "https://ntfy.sh"، وبدونه تصل الرسائل متأخرة قد تصل إلى ساعات بحسب حالة الهاتف. - المتصفح: واجهة الويب تستقبل الرسائل والصفحة مفتوحة، ومع إعداد Web Push بمفاتيح VAPID تصل والمتصفح مغلق، بالطريقة التي جربناها في المقال السابق.
وفي التجربة التالية نستخدم الاشتراك عبر HTTP بتدفق JSON من واجهة الاشتراك، لأنه يرينا بالضبط ما يصل إلى أي تطبيق.
تجربة عملية: سيرفر ntfy خاص وتنبيهات من أربعة مصادر
الآن سوف نشغل ntfy 2.28.0 في Container بإعداد خاص لا يقبل أي طلب دون حساب، ثم ننشئ حسابات لكل مرسل ونعطي كل حساب أقل ما يحتاجه، ثم نرسل من Container ثان يمثل سيرفر الإنتاج web-01: سكربت يفحص القرص من Cron، وغلاف Wrapper ينبه عند فشل أي أمر، وخطوة في خط CI، وتنبيه بصيغة Alertmanager. والتجربة تحتاج Docker فقط، والمنفذ على 127.0.0.1 فلا يصل إليه أحد من خارج جهازك.
ملف الإعداد وتشغيل السيرفر
ملف الإعداد server.yml:
# /etc/ntfy/server.yml
base-url: "http://127.0.0.1:18480"
listen-http: ":80"
# messages are kept 12 hours so a phone that was offline gets them later
cache-file: "/var/lib/ntfy/cache.db"
cache-duration: "12h"
attachment-cache-dir: "/var/lib/ntfy/attachments"
# private server: nobody reads or writes unless an ACL entry allows it
auth-file: "/var/lib/ntfy/user.db"
auth-default-access: "deny-all"
enable-login: true
require-login: trueفي الإعداد أعلاه لاحظ التالي:
auth-default-access: "deny-all"هو أهم سطر، فهو يقرر ماذا يحدث لطلب لا توجد له قاعدة، والقيمة الافتراضيةread-writeأي أن أي شخص يقرأ ويكتب في كل المواضيع، كما في قسم التحكم في الوصول.base-urlهو الرابط الذي يراه الناس، وفي الإنتاج يكونhttps://ntfy.example.com، وهو مطلوب للمرفقات ولتطبيق iOS.cache-fileيجعل الرسائل المحفوظة 12 ساعة تبقى بعد إعادة التشغيل، وبدونه تحفظ في الذاكرة فقط.require-loginيجعل واجهة الويب تطلب الدخول قبل أي شيء.
نشغل السيرفر على شبكة خاصة بالتجربة، ثم نتأكد أنه يعمل وأنه يرفض الغرباء:
docker network create lab-notify-net
docker run -d --name lab-notify-ntfy --network lab-notify-net -p 127.0.0.1:18480:80 \
-v "$PWD/server.yml:/etc/ntfy/server.yml:ro" -v lab-notify-ntfy-data:/var/lib/ntfy \
binwiederhier/ntfy:v2.28.0 serve
docker logs lab-notify-ntfy
curl -s http://127.0.0.1:18480/v1/health
curl -s -d "test" http://127.0.0.1:18480/alerts-prod2026/10/07 15:29:09 INFO Listening on :80[http], ntfy 2.28.0, log level is INFO (tag=startup)
{"healthy":true}
{"code":40301,"http":403,"error":"forbidden","link":"https://ntfy.sh/docs/publish/#authentication"}السيرفر يعمل، والطلب دون حساب يرفض ب 403، وهذا ما نريده تماماً.
المستخدمون والصلاحيات ACL والتوكنات
القاعدة التي نتبعها هنا هي أقل صلاحية Least Privilege: كل مرسل حساب مستقل لا يستطيع إلا الكتابة في موضوعه، وفريق التشغيل حساب يقرأ ولا يكتب. والصورة التالية تبين ما سوف ننشئه:

ننشئ المستخدمين بالأمر ntfy user داخل ال Container، ونمرر كلمة المرور في المتغير NTFY_PASSWORD حتى لا يسألنا عنها، والبوتات تأخذ كلمات مرور عشوائية لأنها سوف تستخدم التوكنات فقط:
docker exec -e NTFY_PASSWORD='Adm1n-Lab-2026' lab-notify-ntfy ntfy user add --role=admin admin
docker exec -e NTFY_PASSWORD='Ops-Lab-2026' lab-notify-ntfy ntfy user add ops
for u in backup-bot ci-bot monitor-bot; do
docker exec -e NTFY_PASSWORD="$(head -c 18 /dev/urandom | base64)" lab-notify-ntfy ntfy user add $u
doneuser admin added with role admin
user ops added with role user
user backup-bot added with role user
user ci-bot added with role user
user monitor-bot added with role userثم القواعد بالأمر ntfy access، والنجمة في alerts-* تعني كل موضوع يبدأ ب alerts-:
docker exec lab-notify-ntfy ntfy access ops 'alerts-*' read-only
docker exec lab-notify-ntfy ntfy access ops backups read-only
docker exec lab-notify-ntfy ntfy access ops deploys read-only
docker exec lab-notify-ntfy ntfy access backup-bot backups write-only
docker exec lab-notify-ntfy ntfy access ci-bot deploys write-only
docker exec lab-notify-ntfy ntfy access monitor-bot 'alerts-*' write-only
docker exec lab-notify-ntfy ntfy accessuser admin (role: admin, tier: none)
- read-write access to all topics (admin role)
user backup-bot (role: user, tier: none)
- write-only access to topic backups
user ci-bot (role: user, tier: none)
- write-only access to topic deploys
user monitor-bot (role: user, tier: none)
- write-only access to topic alerts-*
user ops (role: user, tier: none)
- read-only access to topic alerts-*
- read-only access to topic backups
- read-only access to topic deploys
user * (role: anonymous, tier: none)
- no topic-specific permissions
- no access to any (other) topics (server config)ثم توكن لكل بوت، والتوكن الخاص ب CI ينتهي بعد 90 يوماً حتى لا يبقى صالحاً إلى الأبد إذا تسرب من سجلات الخط:
docker exec lab-notify-ntfy ntfy token add --label="backup on web-01" backup-bot
docker exec lab-notify-ntfy ntfy token add --expires=90d --label="gitea actions" ci-bot
docker exec lab-notify-ntfy ntfy token add --label="alertmanager" monitor-bot
docker exec lab-notify-ntfy ntfy token listuser backup-bot
- tk_7ufuemtyvajrn8uqhh0pfozhhizbk (backup on web-01), never expires, accessed from 192.168.176.3 at 07 Oct 26 17:47 UTC
user ci-bot
- tk_dotg9thtddr07sbvgveinsg5rzlsn (gitea actions), expires 05 Jan 27 17:47 UTC, accessed from 192.168.176.3 at 07 Oct 26 17:47 UTC
user monitor-bot
- tk_te8r1278ehop4irfy64f22bvihlcp (alertmanager), never expires, accessed from 192.168.176.3 at 07 Oct 26 17:47 UTCوقد يتساءل البعض: لماذا ثلاثة بوتات وليس حساباً واحداً لكل السكربتات؟ والإجابة في توثيق التوكنات نفسه، فالتوكن اليوم يعطي صلاحيات المستخدم كاملة، ولا توجد بعد توكنات بصلاحيات أضيق من صاحبها، فإذا تسرب توكن من سكربت النسخ الاحتياطي فأقصى ما يفعله المهاجم أن يكتب في موضوع backups، ولا يقرأ تنبيهات الإنتاج ولا يرسل تنبيهاً مزوراً فيها. وإذا كنت تفضل أن يكون كل ذلك في ملف الإعداد بدل الأوامر، فالخيارات auth-users وauth-access وauth-tokens في مثال السيرفر الخاص تفعل الشيء نفسه، وكلمات المرور فيها هاش bcrypt يولده الأمر ntfy user hash.
النشر والرفض
نشغل Container صغيراً باسم السيرفر web-01 على الشبكة نفسها، ونفتح فيه اشتراكاً دائماً بحساب ops يكتب كل ما يصل إلى ملف، وهو يمثل تطبيق الهاتف:
docker run -d --name lab-notify-client --hostname web-01 --network lab-notify-net alpine:3.24 sleep infinity
docker exec lab-notify-client apk add --no-cache curl bash coreutils jq
docker exec -d lab-notify-client sh -c \
'curl -s -N -u ops:Ops-Lab-2026 "http://lab-notify-ntfy/alerts-prod,backups,deploys/json" > /tmp/stream.jsonl'
docker exec -it lab-notify-client bashومن داخل ال Container نرسل رسالة بتوكن البوت، ثم نجرب أربعة طلبات يجب أن ترفض:
BK=tk_7ufuemtyvajrn8uqhh0pfozhhizbk
curl -s -H "Authorization: Bearer $BK" -H "Title: نسخة قاعدة البيانات" -H "Priority: low" \
-H "Tags: white_check_mark,backup" -d "اكتملت النسخة الليلية: 1.4 GB في 6 دقائق" http://lab-notify-ntfy/backups
curl -s -H "Authorization: Bearer $BK" -d test http://lab-notify-ntfy/alerts-prod # موضوع ليس له
curl -s -H "Authorization: Bearer $BK" "http://lab-notify-ntfy/backups/json?poll=1" # يقرأ موضوعه
curl -s -u ops:Ops-Lab-2026 -d test http://lab-notify-ntfy/backups # ops يكتب
curl -s -u ops:wrong -d test http://lab-notify-ntfy/backups # كلمة مرور خاطئة{"id":"ImdpMNw1ZVkX","time":1791395265,"expires":1791438465,"event":"message","topic":"backups","title":"نسخة قاعدة البيانات","message":"اكتملت النسخة الليلية: 1.4 GB في 6 دقائق","priority":2,"tags":["white_check_mark","backup"]}
{"code":40301,"http":403,"error":"forbidden","link":"https://ntfy.sh/docs/publish/#authentication"}
{"code":40301,"http":403,"error":"forbidden","link":"https://ntfy.sh/docs/publish/#authentication"}
{"code":40301,"http":403,"error":"forbidden","link":"https://ntfy.sh/docs/publish/#authentication"}
{"code":40101,"http":401,"error":"unauthorized","link":"https://ntfy.sh/docs/publish/#authentication"}في المخرج أعلاه لاحظ التالي:
- الرسالة المقبولة تعود بمعرفها ووقتها ووقت انتهائها من ال Cache بعد 12 ساعة بالضبط (43200 ثانية)، والأولوية
lowأصبحت الرقم 2. - البوت لا يستطيع أن يقرأ حتى موضوعه، لأن صلاحيته
write-only، فلو تسرب توكنه لا يقرأ به أحد نتائج النسخ الاحتياطي. - الفرق بين 401 و403 مفيد عند التشخيص: 401 يعني أن السيرفر لم يعرفك، أي كلمة مرور أو توكن خاطئ، و403 يعني أنه عرفك ولكن القواعد لا تسمح.
من Cron: تنبيه عندما يمتلئ القرص
السكربت التالي يفحص نسبة استخدام الأقراص ويرسل تنبيهاً بأولوية عالية إذا تجاوزت حداً، ويوضع في /usr/local/bin/check-disk.sh:
#!/usr/bin/env bash
# /usr/local/bin/check-disk.sh: alert when a disk passes the limit (run from cron)
set -euo pipefail
NTFY_URL="${NTFY_URL:-https://ntfy.example.com}"
NTFY_TOKEN="${NTFY_TOKEN:?set NTFY_TOKEN}"
LIMIT="${LIMIT:-85}"
MOUNTS="${MOUNTS:-/}"
df -P $MOUNTS | awk 'NR>1 {sub("%","",$5); print $5, $6}' |
while read -r used mount; do
if [ "$used" -ge "$LIMIT" ]; then
curl -fsS -o /dev/null \
-H "Authorization: Bearer $NTFY_TOKEN" \
-H "Title: $(hostname): القرص $mount ممتلئ" \
-H "Priority: high" \
-H "Tags: warning,disk" \
-d "المستخدم ${used}% والحد ${LIMIT}%" \
"$NTFY_URL/alerts-prod"
echo "alert sent for $mount (${used}%)"
fi
doneنشغله مرة بالحد الافتراضي 85% فلا يرسل شيئاً، ثم بحد 10% حتى نرى التنبيه في التجربة:
export NTFY_URL=http://lab-notify-ntfy NTFY_TOKEN=tk_te8r1278ehop4irfy64f22bvihlcp
check-disk.sh; echo exit=$?
LIMIT=10 check-disk.shexit=0
alert sent for / (13%)وعلى السيرفر الحقيقي تضعه في Cron كل عشر دقائق، والمتغيرات في أول ملف crontab يقرؤها كل سطر بعدها، والملف لا يقرؤه إلا صاحبه:
crontab -eNTFY_URL=https://ntfy.example.com
NTFY_TOKEN=tk_te8r1278ehop4irfy64f22bvihlcp
*/10 * * * * /usr/local/bin/check-disk.shولاحظ أن هذا السكربت سوف يرسل التنبيه نفسه كل عشر دقائق حتى تحل المشكلة، وهذا مقبول لتنبيه بهذه الأهمية، ولكنه مزعج لتنبيهات أقل أهمية، وهنا تبدأ الحاجة إلى أداة مراقبة حقيقية تتذكر الحالة وترسل عند التغير فقط، كما سوف نرى في قسم المراقبة. وماذا تفعل عندما يصل التنبيه؟ ذلك في حالة القرص الممتلئ من دليل استكشاف الأخطاء.
غلاف لأي مهمة: تنبيه عند الفشل فقط
أكثر التنبيهات فائدة هي التي تخبرك أن مهمة مجدولة فشلت، لأن الفشل الصامت للنسخ الاحتياطي يكتشف عادة يوم تحتاج إلى الاستعادة. والغلاف التالي يشغل أي أمر، ويحفظ مخرجه، ولا يرسل شيئاً إذا نجح، وإذا فشل يرسل رمز الخروج وآخر خمسة أسطر بأولوية عاجلة:
#!/usr/bin/env bash
# /usr/local/bin/notify-on-fail.sh: run any command, send its last lines only when it fails
# notify-on-fail.sh backups "نسخة قاعدة البيانات" pg_dump ...
TOPIC="$1"; TITLE="$2"; shift 2
LOG=$(mktemp)
"$@" >"$LOG" 2>&1
CODE=$?
if [ "$CODE" -eq 0 ]; then
rm -f "$LOG"; exit 0
fi
curl -fsS -o /dev/null \
-H "Authorization: Bearer $NTFY_TOKEN" \
-H "Title: $(hostname): فشل $TITLE" \
-H "Priority: urgent" \
-H "Tags: rotating_light" \
--data-binary "رمز الخروج $CODE
$(tail -n 5 "$LOG")" \
"$NTFY_URL/$TOPIC"
rm -f "$LOG"; exit "$CODE"export NTFY_TOKEN=$BK
notify-on-fail.sh backups 'نسخة الملفات' tar czf /tmp/etc.tgz /etc; echo exit=$?
notify-on-fail.sh backups 'نسخة الملفات' tar czf /tmp/srv.tgz /srv/app/uploads; echo exit=$?exit=0
exit=1الأولى نجحت ولم ترسل شيئاً، والثانية فشلت لأن المجلد غير موجود فأرسلت التنبيه وأعادت رمز الخروج كما هو، حتى يبقى Cron أو أي أداة فوقها تعرف أن المهمة فشلت. وفي سطر Cron يصبح الاستخدام هكذا:
30 2 * * * /usr/local/bin/notify-on-fail.sh backups "نسخة قاعدة البيانات" /usr/local/bin/backup-db.shوقد شرحنا ما يجب أن يفعله سكربت النسخ نفسه واختبار الاستعادة في النسخ الاحتياطي بقاعدة 3-2-1.
if "$@"; then … fi، فوصل التنبيه وفيه «رمز الخروج 0» والأمر قد فشل، والسبب أن $? بعد جملة if كاملة هو رمز الجملة نفسها وليس رمز الأمر. لذلك احفظ CODE=$? في السطر الذي يلي الأمر مباشرة كما في النسخة أعلاه، وجرب الغلاف بأمر يفشل عن قصد قبل أن تعتمد عليه.من خط CI: تنبيه بعد النشر
في خط CI على Gitea Actions تضع التوكن في أسرار المستودع Secrets باسم NTFY_TOKEN، ثم تضيف في نهاية مهمة النشر خطوتين، واحدة عند النجاح وأخرى بأولوية عالية عند الفشل، وقد شرحنا تشغيل الخطوط على سيرفرك في Gitea Actions:
- name: إشعار النشر
if: success()
env:
NTFY_TOKEN: ${{ secrets.NTFY_TOKEN }}
run: |
curl -fsS -H "Authorization: Bearer $NTFY_TOKEN" \
-H "Title: نشر $GITHUB_REF_NAME إلى الإنتاج" -H "Tags: rocket" -H "Markdown: yes" \
-H "Click: $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/commit/$GITHUB_SHA" \
-d "**$GITHUB_REPOSITORY** نشره $GITHUB_ACTOR بنجاح" https://ntfy.example.com/deploys
- name: إشعار فشل النشر
if: failure()
env:
NTFY_TOKEN: ${{ secrets.NTFY_TOKEN }}
run: |
curl -fsS -H "Authorization: Bearer $NTFY_TOKEN" -H "Priority: high" -H "Tags: x" \
-d "فشل نشر $GITHUB_REPOSITORY ($GITHUB_REF_NAME)" https://ntfy.example.com/deploysوفي التجربة شغلنا أمر curl نفسه من ال Container بتوكن ci-bot بعد أن وضعنا متغيرات الخط بأيدينا، ورابط Click فيها رابط تجريبي:
export NTFY_TOKEN=tk_dotg9thtddr07sbvgveinsg5rzlsn GITHUB_SERVER_URL=https://git.example.com \
GITHUB_REPOSITORY=team/app GITHUB_RUN_ID=42 GITHUB_REF_NAME=v1.8.2 GITHUB_ACTOR=wajdy
curl -fsS -H "Authorization: Bearer $NTFY_TOKEN" -H "Title: نشر $GITHUB_REF_NAME إلى الإنتاج" \
-H "Tags: rocket" -H "Markdown: yes" \
-H "Click: $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" \
-d "**$GITHUB_REPOSITORY** نشره $GITHUB_ACTOR بنجاح" "$NTFY_URL/deploys"{"id":"xnXlgTseDHjJ","time":1791395272,"expires":1791438472,"event":"message","topic":"deploys","title":"نشر v1.8.2 إلى الإنتاج","message":"**team/app** نشره wajdy بنجاح","tags":["rocket"],"click":"https://git.example.com/team/app/actions/runs/42","content_type":"text/markdown"}والخيار -f مهم في الخطوط، لأنه يجعل curl يفشل برمز خروج غير صفري إذا رد السيرفر بخطأ، فتعرف من سجل الخط أن التنبيه نفسه لم يرسل. وإذا كان التوكن سراً في الخط فاتبع ما شرحناه في خط CI/CD آمن حتى لا يظهر في السجلات.
من أدوات المراقبة: Alertmanager وGrafana وUptime Kuma
أدوات المراقبة ترسل تنبيهاتها كطلب Webhook بصيغة JSON خاصة بها، وntfy 2.x يحولها إلى رسالة مقروءة بقوالب مدمجة Pre-defined Templates اسمها github وgrafana وalertmanager، تختار أحدها بإضافة ?template= إلى الرابط. وفي Alertmanager يكون المستقبل Receiver هكذا، والخيار authorization في إعداد Webhook يرسل التوكن في ترويسة Bearer:
receivers:
- name: ntfy
webhook_configs:
- url: https://ntfy.example.com/alerts-prod?template=alertmanager
http_config:
authorization:
credentials: tk_te8r1278ehop4irfy64f22bvihlcpولم نشغل Alertmanager نفسه في هذه التجربة، وإنما أرسلنا من ال Container طلباً بصيغة Webhook الخاصة به من الملف التالي:
{
"version": "4",
"status": "firing",
"receiver": "ntfy",
"groupLabels": { "alertname": "HighErrorRate" },
"commonLabels": { "alertname": "HighErrorRate", "severity": "critical", "service": "api" },
"commonAnnotations": { "summary": "API 5xx above 5% for 10 minutes" },
"externalURL": "http://alertmanager:9093",
"alerts": [
{
"status": "firing",
"labels": { "alertname": "HighErrorRate", "severity": "critical", "service": "api", "instance": "web-01:8080" },
"annotations": { "summary": "API 5xx above 5% for 10 minutes", "description": "5xx ratio is 7.3% on web-01" },
"startsAt": "2026-10-07T15:20:00Z",
"endsAt": "0001-01-01T00:00:00Z",
"generatorURL": "http://prometheus:9090/graph?g0.expr=..."
}
]
}curl -s -H "Authorization: Bearer tk_te8r1278ehop4irfy64f22bvihlcp" --data-binary @/tmp/alert.json \
"http://lab-notify-ntfy/alerts-prod?template=alertmanager"{"id":"DABmtI8qVmQt","time":1791395273,"expires":1791438473,"event":"message","topic":"alerts-prod","title":"🚨 Alert: HighErrorRate","message":"Status: Firing\nReceiver: ntfy\nAlert: HighErrorRate\nInstance: web-01:8080\nSeverity: critical\nStarts at: 2026-10-07T15:20:00ZEnds at: 0001-01-01T00:00:00Z\nSummary: API 5xx above 5% for 10 minutes\nDescription: 5xx ratio is 7.3% on web-01\nSource: http://prometheus:9090/graph?g0.expr=..."}تحول JSON الطويل إلى عنوان ونص من سطور، ولاحظ أن القالب المدمج في هذا الإصدار يلصق سطر «Starts at» بسطر «Ends at»، وإذا أزعجك ذلك تستطيع أن تضع ملفاً بالاسم نفسه alertmanager.yml في مجلد القوالب /etc/ntfy/templates فيحل محل المدمج كما في التوثيق. وبالطريقة نفسها يستقبل ntfy تنبيهات Grafana بالقالب grafana، أما Uptime Kuma وHealthchecks وNetdata وغيرها فتدعم ntfy مباشرة كنوع إشعار كما في صفحة التكاملات، وكلها مخططة في مرحلة المراقبة والسجلات في خارطة الطريق.
ماذا وصل إلى المشترك؟
الملف الذي كتبه اشتراك ops المفتوح منذ البداية فيه كل ما أرسلناه بالترتيب، وأول سطر حدث open عند فتح الاتصال:
cat /tmp/stream.jsonl{"id":"dkCVR5gjtE8l","time":1791395263,"event":"open","topic":"alerts-prod,backups,deploys"}
{"id":"ImdpMNw1ZVkX","time":1791395265,"expires":1791438465,"event":"message","topic":"backups","title":"نسخة قاعدة البيانات","message":"اكتملت النسخة الليلية: 1.4 GB في 6 دقائق","priority":2,"tags":["white_check_mark","backup"]}
{"id":"7Z5pPtzeMA7f","time":1791395269,"expires":1791438469,"event":"message","topic":"alerts-prod","title":"web-01: القرص / ممتلئ","message":"المستخدم 13% والحد 10%","priority":4,"tags":["warning","disk"]}
{"id":"I89EQgC0DSnJ","time":1791395270,"expires":1791438470,"event":"message","topic":"backups","title":"web-01: فشل نسخة الملفات","message":"رمز الخروج 1\ntar: /srv/app/uploads: No such file or directory\ntar: error exit delayed from previous errors","priority":5,"tags":["rotating_light"]}
{"id":"xnXlgTseDHjJ","time":1791395272,"expires":1791438472,"event":"message","topic":"deploys","title":"نشر v1.8.2 إلى الإنتاج","message":"**team/app** نشره wajdy بنجاح","tags":["rocket"],"click":"https://git.example.com/team/app/actions/runs/42","content_type":"text/markdown"}
{"id":"DABmtI8qVmQt","time":1791395273,"expires":1791438473,"event":"message","topic":"alerts-prod","title":"🚨 Alert: HighErrorRate","message":"Status: Firing\nReceiver: ntfy\nAlert: HighErrorRate\nInstance: web-01:8080\nSeverity: critical\nStarts at: 2026-10-07T15:20:00ZEnds at: 0001-01-01T00:00:00Z\nSummary: API 5xx above 5% for 10 minutes\nDescription: 5xx ratio is 7.3% on web-01\nSource: http://prometheus:9090/graph?g0.expr=..."}والهاتف الذي كان مطفأ لا يفقد شيئاً، لأن السيرفر يحفظ الرسائل 12 ساعة، والتطبيق يطلب عند اتصاله ما فاته بالمعامل since، والطلب التالي يجلب رسائل النسخ الاحتياطي في آخر ساعة:
curl -s -u ops:Ops-Lab-2026 "http://lab-notify-ntfy/backups/json?poll=1&since=1h" | jq -c "{time,title,priority}"{"time":1791395265,"title":"نسخة قاعدة البيانات","priority":2}
{"time":1791395270,"title":"web-01: فشل نسخة الملفات","priority":5}وهذا الحفظ له جانب يجب أن تعرفه، فالرسائل مكتوبة على قرص السيرفر في cache.db، والتوثيق نفسه في قسم ال Cache يذكر أن هذا قد يثير مخاوف الخصوصية، فالرسالة التي لا تريد حفظها ترسلها بالترويسة Cache: no فتصل إلى المتصلين الآن فقط. وأخيراً سجلنا الدخول إلى واجهة الويب بحساب ops واشتركنا في المواضيع الثلاثة، والصورة التالية من التجربة نفسها:

إزالة التجربة
docker rm -f lab-notify-ntfy lab-notify-client
docker volume rm lab-notify-ntfy-data
docker network rm lab-notify-netقبل الإنتاج: HTTPS وال Reverse Proxy
في الإنتاج يعمل ntfy خلف Reverse Proxy يتولى الشهادة، وتغير في server.yml ثلاثة أشياء: base-url يصبح رابط HTTPS، وlisten-http يستمع على 127.0.0.1 فقط، وbehind-proxy: true حتى يأخذ عنوان الزائر الحقيقي من X-Forwarded-For فيطبق حدود الطلبات على كل زائر وليس على ال Proxy كله، وقد شرحنا هذه المشكلة في تطبيقك خلف Reverse Proxy. ومثال Nginx في التوثيق يوقف التخزين المؤقت proxy_buffering off ويمرر ترويستي Upgrade وConnection حتى تعمل اتصالات WebSocket التي يعتمد عليها تطبيق Android، ويرفع مهلة القراءة إلى 3 دقائق، أما Caddy فيكفيه سطر واحد لأنه يمرر WebSocket وحده:
ntfy.example.com {
reverse_proxy 127.0.0.1:2586
}وإذا كان في فريقك من يستخدم iPhone فأضف upstream-base-url: "https://ntfy.sh" كما شرحنا في قسم الهاتف، مع العلم أن معرف الرسالة والهاش يمران حينها على ntfy.sh وGoogle وApple.
من يراقب سيرفر التنبيهات نفسه؟
هناك مشكلة رئيسية في كل نظام تنبيهات: السكون. فإذا توقف السيرفر الذي يشغل Cron وntfy معاً فلن يصلك أي تنبيه، وهذا يشبه تماماً حالة «كل شيء بخير»، والصورة التالية تبين المشكلة والحل:

- لا تشغل ntfy على السيرفر الذي يراقبه: ضعه على سيرفر صغير منفصل، أو على الأقل لا تجعله يعتمد على قاعدة البيانات أو القرص نفسه.
- نبضة منتظمة Heartbeat: بدل أن تنتظر رسالة الفشل، اجعل المهمة المهمة ترسل «أنا حي» بعد كل نجاح إلى أداة على جهاز آخر تتوقعها، فإذا تأخرت النبضة صرخت الأداة، وهذا ما تفعله Uptime Kuma بنوع Push Monitor وHealthchecks.
- فحص ntfy نفسه: المسار
/v1/healthيرد{"healthy":true}كما رأينا، وتستطيع أن تفعل مسار/metricsلPrometheus بالخيارenable-metricsكما في قسم المراقبة. - قناة ثانية للأهم: التنبيه الذي لا تحتمل ضياعه يذهب أيضاً إلى البريد، فإذا تعطلت قناة بقيت الأخرى.
وكيف تحقق في المشكلة بعد أن يصل التنبيه؟ ذلك في استكشاف الأخطاء وتحليل الأداء، والسجلات التي تحتاجها حينها في السجلات Logging للمطورين.
أخطاء شائعة
- ترك
auth-default-accessعلى قيمته الافتراضية: القيمة الافتراضيةread-write، فالسيرفر الذي على الإنترنت دون هذا السطر يقبل الكتابة والقراءة من أي شخص. - توكن واحد لكل السكربتات: التوكن يحمل صلاحيات صاحبه كاملة، فاجعل لكل مرسل حساباً بصلاحية كتابة في موضوعه فقط.
- تنبيه كل عشر دقائق للمشكلة نفسها: الفريق يتعلم تجاهل التنبيهات المتكررة، فاحفظ التنبيهات العاجلة للأمور العاجلة، واستخدم الأولويات
lowوminللمعلومات. - أسرار في نص التنبيه: كلمة مرور في سطر خطأ تصل إلى كل هاتف مشترك وتحفظ 12 ساعة على القرص، فانظر ما يكتبه سكربتك قبل أن ترسل آخر سطوره.
- Reverse Proxy دون WebSocket: يظهر في تطبيق Android خطأ اتصال أو تتأخر الرسائل، والسبب ترويستا
UpgradeوConnectionغير ممررتين أو مهلة قراءة قصيرة. - عنوان عربي يظهر علامات استفهام: بعض المكتبات ولغات البرمجة لا ترسل UTF-8 في الترويسات، والتوثيق يقترح ترميز العنوان بصيغة RFC 2047 مثل
=?UTF-8?B?…?=، أما curl على Linux فيرسله كما هو كما رأينا.
الخلاصة
وصلنا لنهاية الموضوع، وأهم ما فيه:
- ntfy سيرفر نشر واشتراك عبر HTTP، فأي سكربت يرسل تنبيهاً بسطر curl واحد، والمشتركون هم تطبيق الهاتف وواجهة الويب وأي برنامج يقرأ تدفق JSON.
- على Android يصل التنبيه من سيرفرك مباشرة دون Google، وعلى iOS يمر معرف الرسالة فقط عبر ntfy.sh وFCM وAPNs ثم يجلب التطبيق النص من سيرفرك.
- السيرفر الخاص يبدأ ب
auth-default-access: "deny-all"، ثم حساب لكل مرسل بصلاحية كتابة في موضوعه وتوكن خاص به، وحساب للقراءة لفريق التشغيل. - أنفع التنبيهات هي فشل المهام المجدولة، وغلاف صغير يرسل عند الفشل فقط ويعيد رمز الخروج كما هو يكفي لكل مهام Cron.
- أدوات المراقبة ترسل إلى ntfy بقوالب جاهزة لAlertmanager وGrafana أو بدعم مباشر، ولكن سيرفر التنبيهات نفسه يحتاج إلى من يراقبه من مكان آخر.
دليلكيف تعمل الإشعارات الفورية Push Notifications؟كيف يصل الإشعار إلى تطبيق مغلق عبر خدمات Apple وGoogle، وما الذي تراه هذه الخدمات من بيانات مستخدميك.
دليلالنسخ الاحتياطي بقاعدة 3-2-1 واختبار الاستعادةكيف تحدد كم بيانات تقبل أن تخسر، وتبني نسخاً لا يستطيع المخترق حذفها، وتتأكد أنك تستطيع استعادتها فعلاً.
دليلاستكشاف الأخطاء وتحليل الأداء للمبتدئين: من العرض إلى السببعندما يبطئ الموقع أو يعطي 502، هذه طريقة منظمة توصلك من العرض إلى السبب قبل أن تخمن أو تشتري سيرفراً أكبر.
دليلالسجلات Logging للمطورين: ماذا تسجل وماذا لا تسجلكيف تكتب سجلات Logs تساعدك على فهم ما حدث في الإنتاج، وما الذي لا يدخل السجل أبداً، وكيف تجمعها في مكان واحد.
دليلGitea Actions: تشغيل CI/CD وبناء Docker Images على سيرفركاجعل سيرفرك يختبر الكود ويبني Docker Images تلقائياً مع كل push، دون أن يخرج كودك أو أسرارك إلى خدمة CI خارجية.سجل التحديثات
- أكتوبر 2026: كتابة المقال، وتنفيذ تجربة ntfy 2.28.0 في Docker بسيرفر خاص وخمسة حسابات وثلاثة توكنات، والإرسال من Container على Alpine 3.24 مع curl 8.22.0 من سكربت Cron وغلاف للمهام وخطوة CI وقالب Alertmanager.