الأدلة التقنية

إشعارات فورية من سيرفرك إلى هاتفك مع ntfy

كيف تشغل سيرفر ntfy خاصاً، وتصلك على هاتفك رسالة عندما يمتلئ القرص أو تفشل النسخة الاحتياطية أو ينتهي النشر.

إشعارات فورية من سيرفرك إلى هاتفك مع ntfy

في مقال كيف تعمل الإشعارات الفورية 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 إلى هذا الرابط وجسمه نص الرسالة، ومن يريد أن يستقبل يشترك في الموضوع نفسه، فيصله كل ما ينشر فيه. ولا يحتاج الموضوع إلى إنشاء مسبق، فأول رسالة تنشئه، والصورة التالية تبين الأطراف:

على اليسار أربعة مرسلين: سكربت Cron لفحص القرص، وسكربت النسخ الاحتياطي، وخط CI، وAlertmanager وGrafana، ترسل أسهماً برتقالية بطلب HTTP POST إلى سيرفر ntfy في الوسط وتحته ثلاثة مواضيع alerts-prod وbackups وdeploys، وعلى اليمين ثلاثة مشتركين: تطبيق الهاتف وواجهة الويب في المتصفح وcurl أو سكربت، تصلها أسهم خضراء عبر WebSocket وJSON stream وWeb Push
أي أداة تعرف إرسال طلب HTTP تستطيع أن ترسل تنبيهاً

وهذا التصميم هو سبب انتشار ntfy، فالمرسل لا يحتاج إلى مكتبة ولا SDK، ويكفيه curl، والسطر التالي من توثيق النشر رسالة كاملة إلى السيرفر العام:

curl -d "Backup successful 😀" ntfy.sh/mytopic

وتستطيع أن تضيف للرسالة عنواناً بالترويسة Title، وأولوية من 1 إلى 5 بالترويسة Priority تحدد في تطبيق Android الصوت والاهتزاز وهل يظهر الإشعار فوق الشاشة، ووسوماً Tags يتحول بعضها إلى رموز تعبيرية، ورابطاً يفتح عند الضغط Click، ونصاً بصيغة Markdown، وكل ذلك موثق في صفحة النشر. وللحدود التي تهمك من قائمة الحدود: الرسالة حتى 4096 بايت وما زاد يتحول إلى مرفق، والعنوان حتى 1 KB، والسيرفر يحفظ الرسائل افتراضياً 12 ساعة حتى يحصل عليها من كان غير متصل.

⚠️
على السيرفر العام ntfy.sh يعمل اسم الموضوع ككلمة مرور، فكل من يعرف الاسم أو يخمنه يقرأ رسائلك ويرسل فيه، لذلك لا ترسل إلى السيرفر العام أسماء سيرفراتك الداخلية أو عناوينها أو أخطاء فيها بيانات، والسبب أن الموضوع هناك مفتوح لكل الإنترنت. وفي هذا المقال نشغل سيرفراً خاصاً يرفض كل طلب ليس معه حساب.

كيف يصل التنبيه إلى هاتفك؟

هنا يعود ما شرحناه في المقال السابق عن الطريق إلى الهاتف، وntfy يتعامل مع كل نظام بطريقة مختلفة، والصورة التالية تبين الطريقين:

لوحتان: في الأعلى Android حيث يحفظ تطبيق ntfy اتصال WebSocket مفتوحاً مع سيرفرك وتصل الرسائل مباشرة دون سيرفر Google، وفي الأسفل iOS حيث يرسل سيرفرك معرف الرسالة فقط إلى ntfy.sh الذي يمرره إلى FCM ثم APNs فيصل الهاتف إشعار عام ثم يجلب التطبيق نص الرسالة من سيرفرك
على Android لا يمر النص على Google، وعلى iOS يمر معرف الرسالة فقط
  • 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-prod
2026/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: كل مرسل حساب مستقل لا يستطيع إلا الكتابة في موضوعه، وفريق التشغيل حساب يقرأ ولا يكتب. والصورة التالية تبين ما سوف ننشئه:

جدول صلاحيات: الصفوف backup-bot وci-bot وmonitor-bot بتوكن وops بكلمة مرور وanonymous دون شيء، والأعمدة alerts-* وbackups وdeploys، وكل بوت له write-only في موضوع واحد فقط، وops له read-only في الثلاثة، وanonymous مرفوض في الكل، وفوقها auth-default-access deny-all
كل حساب يكتب في موضوعه فقط، ومن لا قاعدة له يرفض

ننشئ المستخدمين بالأمر 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
done
user 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 access
user 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 list
user 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.sh
exit=0
alert sent for / (13%)

وعلى السيرفر الحقيقي تضعه في Cron كل عشر دقائق، والمتغيرات في أول ملف crontab يقرؤها كل سطر بعدها، والملف لا يقرؤه إلا صاحبه:

crontab -e
NTFY_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 واشتركنا في المواضيع الثلاثة، والصورة التالية من التجربة نفسها:

واجهة ntfy في المتصفح بعد الدخول بحساب ops، في القائمة الجانبية المواضيع alerts-prod وbackups وdeploys، وفي الوسط ثلاثة إشعارات: تنبيه Alertmanager باسم HighErrorRate بسطوره، وإشعار نشر v1.8.2 إلى الإنتاج مع زري نسخ الرابط وفتحه، وإشعار فشل نسخة الملفات بأولوية عاجلة ورمز الخروج 1
واجهة الويب في تجربتنا بحساب يقرأ فقط

إزالة التجربة

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 معاً فلن يصلك أي تنبيه، وهذا يشبه تماماً حالة «كل شيء بخير»، والصورة التالية تبين المشكلة والحل:

لوحتان: على اليسار سيرفر web-01 عليه التطبيق وCron وntfy معاً، والخط إلى الهاتف مقطوع بعلامة حمراء مكتوب عليها السيرفر متوقف يعني السكون، وعلى اليمين جهاز مراقبة على سيرفر آخر يتوقع نبضة من web-01 ويفحص /v1/health في سيرفر 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.
نشرة عرب رووت | ArabRoot

معرفة تستحق مكاناً في بريدك.

مقالات مختارة وأدوات مفيدة وأفكار لمشروعك القادم، في رسالة واحدة كل أسبوع.

يمكنك إلغاء الاشتراك متى شئت. الخصوصية

تم استلام طلبك. افتح بريدك واضغط رابط التأكيد لإتمام الاشتراك.