Як розгорнути self-hosted ntfy для push-сповіщень
Розгорніть ntfy на VPS через Docker Compose і TLS, захистіть topic користувачами та ACL, а сповіщення запускайте з cron і systemd OnFailure.
Що робить self-hosted ntfy-сервер
Self-hosted ntfy-сервер перетворює HTTP POST на push-сповіщення на телефоні. Ви публікуєте повідомлення за допомогою curl, і воно надходить у застосунок для Android, застосунок для iOS, вкладку браузера або будь-який інший клієнт, який може підтримувати відкрите HTTP-з’єднання. Не потрібно встановлювати клієнтську бібліотеку або запускати брокер повідомлень.
ntfy адресує повідомлення за topic. Topic — це ім’я в частині URL зі шляхом, наприклад https://ntfy.example.com/alerts. Він створюється в момент першої публікації повідомлення. У типовій інсталяції будь-хто, хто знає це ім’я, може читати topic і публікувати в нього повідомлення. Саме тому в документації проєкту назву topic порівнюють із паролем. Така модель підходить для публічного сервісу ntfy.sh. Вона не підходить для сервера, який передає повідомлення про помилки резервного копіювання. Тому в цьому посібнику автентифікацію буде ввімкнено до надсилання першого повідомлення.
Що потрібно до початку
Потрібен VPS з Ubuntu 24.04 або Debian 13, Docker Engine і плагіном Compose, доменне ім’я та дуже мало оперативної пам’яті. Створіть DNS-запис типу A, який спрямовує ntfy.example.com на публічну IP-адресу сервера, а потім переконайтеся, що це ім’я розпізнається, перш ніж виконувати будь-які інші дії.
dig +short ntfy.example.com
sudo ufw allow 80,443/tcp
sudo ufw statusdig має вивести IP-адресу вашого сервера. Видача сертифіката не відбудеться, якщо команда нічого не виведе, оскільки центр сертифікації перевіряє ім’я із зовнішньої мережі. Порт 80 має залишатися відкритим, оскільки ACME (automatic certificate management environment) — протокол, який використовує Let’s Encrypt, — застосовує його для HTTP-перевірки. Сам контейнер ntfy не отримує публічного порту.
Створіть файл конфігурації ntfy
Docker image не містить файлу конфігурації, тому його потрібно створити. Усі наступні команди в цьому посібнику читатимуть дані з нього. Спочатку визначте ідентифікатор користувача та ідентифікатор групи, від імені яких працюватиме контейнер.
id -u
id -g
sudo install -d -o "$(id -u)" -g "$(id -g)" /etc/ntfy /var/cache/ntfy /var/lib/ntfy
sudo nano /etc/ntfy/server.ymlbase-url: "https://ntfy.example.com"
listen-http: ":2586"
behind-proxy: true
cache-file: "/var/cache/ntfy/cache.db"
cache-duration: "12h"
auth-file: "/var/lib/ntfy/user.db"
auth-default-access: "deny-all"
enable-login: true
enable-signup: falseЧотири з цих параметрів мають ключове значення. base-url має містити точну публічну HTTPS-адресу, оскільки ntfy формує на її основі посилання на вкладення та власні запити вебзастосунку. Неправильне значення призведе до того, що вебзастосунок завантажиться, але всі дії завершуватимуться помилкою. listen-http: ":2586" прив’язує сервіс до всіх інтерфейсів усередині контейнера. Це правильна конфігурація, хоча на перший погляд вона може здаватися необережною: контейнер має власний мережевий простір імен, тому прив’язування до 127.0.0.1 усередині контейнера зробило б порт недоступним із хоста, а опублікований Docker порт не зміг би підключитися. auth-default-access: "deny-all" визначає всю модель безпеки, оскільки забороняє читання та запис усім, хто не має явного дозволу. behind-proxy: true вказує ntfy отримувати адресу клієнта із заголовка X-Forwarded-For, щоб обмеження частоти враховували реальних відвідувачів, а не reverse proxy як одного клієнта з надмірною активністю.
enable-login: true дає змогу вебзастосунку та мобільним застосункам входити за паролем. enable-signup залишається false, оскільки самостійне створення облікових записів на приватному сервері створює додатковий спосіб отримати несанкціонований доступ.
sudo chown "$(id -u):$(id -g)" /etc/ntfy/server.yml
sudo chmod 600 /etc/ntfy/server.ymlЗапуск ntfy за допомогою Docker Compose
Додайте це у /opt/ntfy/compose.yaml, замінивши 1000:1000 двома числами id -u і id -g, наведеними вище.
services:
ntfy:
image: binwiederhier/ntfy:v2.27.0
container_name: ntfy
command: serve
user: "1000:1000"
environment:
- TZ=UTC
volumes:
- /etc/ntfy:/etc/ntfy
- /var/cache/ntfy:/var/cache/ntfy
- /var/lib/ntfy:/var/lib/ntfy
ports:
- "127.0.0.1:2586:2586"
restart: unless-stoppedcd /opt/ntfy
sudo docker compose up -d
sudo docker compose logs ntfy
curl -s http://127.0.0.1:2586/v1/healthСправний сервер відповідає на {"healthy":true}. Два параметри в цьому compose-файлі задано навмисно. Образ зафіксовано на v2.27.0 — поточному релізі станом на August 2026 — замість latest, оскільки з latest наступний docker compose pull змінить версію сервера, а ви дізнаєтеся про це з changelog уже після оновлення. Порт опубліковано як 127.0.0.1:2586:2586, тому контейнер доступний лише через loopback-адресу хоста. Якщо вказати 2586:2586, Docker вставить власні правила firewall перед вашими. У результаті порт буде доступний з інтернету, навіть якщо ufw status показує, що порт закритий.
Якщо curl виводить Connection refused, перегляньте журнал контейнера. Помилка доступу до /var/lib/ntfy/user.db означає, що рядок user: не відповідає власнику цих каталогів. Тому процес не може створити власну базу даних і завершує роботу. Докладніше про власників томів і політики перезапуску розповідає посібник з основ Docker Compose для VPS.
Встановіть TLS перед ntfy за допомогою Caddy
Caddy самостійно запитує та поновлює сертифікат. Це найкоротший шлях до робочого TLS (безпеки транспортного рівня).
sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list
sudo chmod o+r /usr/share/keyrings/caddy-stable-archive-keyring.gpg
sudo chmod o+r /etc/apt/sources.list.d/caddy-stable.list
sudo apt update
sudo apt install -y caddyЗамініть вміст /etc/caddy/Caddyfile на три рядки.
ntfy.example.com {
reverse_proxy 127.0.0.1:2586
}sudo systemctl reload caddy
curl -s https://ntfy.example.com/v1/healthТой самий {"healthy":true} через HTTPS означає, що весь маршрут працює. 502 від Caddy означає, що ntfy не прослуховує порт: перевірте за допомогою sudo ss -lntp | grep 2586. Помилка сертифіката зазвичай означає, що запис DNS неправильний або порт 80 заблокований. sudo journalctl -u caddy -n 50 показує, яка саме проблема виникла.
Якщо ви вже використовуєте nginx, скопіюйте параметри проксі, наведені в документації ntfy: proxy_http_version 1.1, proxy_buffering off, proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for, а також встановіть тайм-аути читання та передавання щонайменше на три хвилини. Підписник утримує одне HTTP-з’єднання відкритим увесь час прослуховування. За замовчуванням nginx закриває неактивне upstream-з’єднання через 60 секунд, тому підписники зациклено перепідключаються, а повідомлення, надіслані під час розриву, втрачаються.
Створіть користувачів і обмежте доступ до топіків
Автентифікацію ввімкнено, і наразі ніхто не має доступу до жодного ресурсу. Саме цього ми й очікуємо. Створіть один обліковий запис адміністратора для себе та один машинний обліковий запис для скриптів. Ці команди читають /etc/ntfy/server.yml усередині контейнера, тому файл конфігурації змонтовано як volume.
sudo docker compose exec ntfy ntfy user add --role=admin admin
sudo docker compose exec ntfy ntfy user add robot
sudo docker compose exec ntfy ntfy user listКожна команда запитує пароль. Адміністратор ігнорує список доступу та може читати й записувати в усі топіки, тому використовуйте цей обліковий запис лише для себе та мобільного застосунку. robot — звичайний користувач, який не має жодного доступу, доки ви його не надасте.
sudo docker compose exec ntfy ntfy access robot alerts write
sudo docker compose exec ntfy ntfy access robot "alerts_*" write
sudo docker compose exec ntfy ntfy accessЗапис ACL (списку контролю доступу) містить користувача, топік і дозвіл. Топік — це або буквальна назва, або шаблон, у якому * відповідає будь-якому значенню. Тому alerts_* охоплює alerts_backup і alerts_db без окремої команди для кожного хоста. Дозвіл write означає лише публікацію. Якщо токен буде викрадено зі завдання cron, зловмисник не зможе підписатися на топік і прочитати опубліковані дані. Спеціальне ім’я користувача everyone визначає, що може робити неавтентифікований відвідувач. Використовуйте його лише для навмисного відкриття публічного ресурсу, наприклад ntfy access everyone status read.
Скрипти мають використовувати токен, а не ваш пароль.
sudo docker compose exec ntfy ntfy token add robotКоманда виводить токен, що починається з tk_. Токен успадковує всі дозволи користувача, якому він належить. Отже, цей токен може публікувати дані в топіках alerts і не може виконувати жодних інших дій. ntfy token list показує наявні токени, а ntfy token remove відкликає один із них, не змінюючи пароль користувача.
Надішліть перше повідомлення й перевірте роботу блокування
Спочатку перевірте, що двері зачинені.
curl -s -o /dev/null -w '%{http_code}\n' -d "hello" https://ntfy.example.com/alertsКоманда виводить 403, а 403 — правильна відповідь: auth-default-access: "deny-all" відхиляє анонімну публікацію. Тепер надішліть справжнє повідомлення.
curl -H "Authorization: Bearer tk_REPLACE_WITH_YOUR_TOKEN" \
-H "Title: Nightly backup finished" \
-H "Priority: default" \
-H "Tags: white_check_mark" \
-d "42 GB copied in 11 minutes" \
https://ntfy.example.com/alertsСервер повертає збережене повідомлення у форматі JSON. Це підтверджує, що його прийнято, а не проігноровано. Title — це виділений жирним шрифтом перший рядок. Priority має значення від 1 до 5 або, за назвою, від min до urgent і визначає, чи відтворюватиме телефон звук. Tags перетворюються на emoji у сповіщенні, якщо назва відповідає відомому скороченому коду emoji, і залишаються звичайним текстом, якщо відповідності немає.
Щоб переглядати topic із термінала, передавайте його потоком:
curl -s -u admin https://ntfy.example.com/alerts/rawcurl запитує пароль. Кожне повідомлення надходить в одному рядку, а порожні рядки, які час від часу з’являються, є keepalive-повідомленнями. Відкрийте https://ntfy.example.com у браузері та ввійдіть із тим самим обліковим записом, щоб отримати вебверсію того самого потоку.
Налаштуйте обмеження швидкості, щоб один скрипт не перевантажив сервер
За замовчуванням кожен відвідувач отримує кошик на 60 запитів, який поповнюється на один запит кожні 5 секунд. Для приватного сервера це щедре значення, але скрипт, що застряг у циклі повторних спроб, швидко використає весь ліміт. Додайте обмеження до server.yml.
visitor-request-limit-burst: 30
visitor-request-limit-replenish: "10s"
visitor-message-daily-limit: 500sudo docker compose restart ntfyВідвідувач, який перевищив ліміт, отримує HTTP 429 замість доставленого повідомлення. Ліміт обчислюється для адреси відвідувача. Саме тому behind-proxy: true має таке значення: без нього ntfy бачить лише адресу Caddy, усі клієнти вважаються одним відвідувачем, а один надокучливий скрипт вичерпує кошик, спільний для вашого телефона та інших серверів.
Сповіщення про помилку cron-завдання
Не передавайте токен у командному рядку. ps aux показує повний командний рядок кожного запущеного процесу всім користувачам системи, тому токен, переданий через -H, буде доступний для читання будь-якому локальному обліковому запису, доки працює curl. Файл конфігурації curl усуває цю проблему.
sudo install -d -m 700 /etc/ntfy-alert
printf 'header = "Authorization: Bearer tk_REPLACE_WITH_YOUR_TOKEN"\n' | sudo tee /etc/ntfy-alert/curlrc
sudo chmod 600 /etc/ntfy-alert/curlrcТепер додайте обробку завдання. Збережіть це як /usr/local/bin/backup-with-alert.sh і надайте йому права на виконання через chmod 750.
#!/bin/bash
out=$(/usr/local/bin/backup.sh 2>&1)
code=$?
if [ "$code" -ne 0 ]; then
printf '%s' "$out" | tail -c 1000 | curl -K /etc/ntfy-alert/curlrc \
-H "Title: backup.sh failed with exit $code" \
-H "Priority: high" \
-H "Tags: warning" \
--data-binary @- \
https://ntfy.example.com/alerts
fi
exit "$code"17 3 * * * /usr/local/bin/backup-with-alert.sh >> /var/log/backup-alert.log 2>&1$? зчитується в рядку одразу після команди, оскільки наступна виконана команда перезапише це значення. Вивід проходить через tail -c 1000, оскільки ntfy обмежує максимальний розмір повідомлення, а сповіщення не є засобом перегляду журналів. Закривальний exit "$code" зберігає початковий код завершення, тому всі інші засоби, що відстежують це завдання, також бачать помилку. Перевірте всю схему, один раз вказавши для скрипту /bin/false.
Гілка обробки помилки, яка ніколи не виконується, гірша за повну відсутність сповіщень, оскільки створює враження, що відсутність повідомлення означає успіх. cron запускає завдання в майже порожньому середовищі та з набагато коротшим PATH, ніж у вашій login shell, тому скрипт, який працює під час ручного запуску, може завершитися ще до виконання рядка з curl. У посібнику про те, чому cron-завдання не запускається описано ці проблеми із середовищем. Усюди використовуйте абсолютні шляхи та після першого запланованого запуску прочитайте файл журналу, а не покладайтеся на припущення.
Сповіщення про збій unit systemd
Cron відповідає за заплановані завдання. Для довготривалих сервісів потрібен OnFailure=, який systemd запускає, коли unit переходить у стан failed. Створіть один шаблонний unit і використовуйте його для кожного сервісу на сервері. Збережіть його як /etc/systemd/system/ntfy-unit-failed@.service.
[Unit]
Description=Send an ntfy alert because %i failed
[Service]
Type=oneshot
ExecStart=/usr/local/bin/ntfy-unit-failed %iПотім /usr/local/bin/ntfy-unit-failed, mode 750:
#!/bin/bash
unit="$1"
journalctl -u "$unit" -n 15 --no-pager -o cat | tail -c 1000 | curl -K /etc/ntfy-alert/curlrc \
-H "Title: $unit failed on $(hostname -s)" \
-H "Priority: urgent" \
-H "Tags: rotating_light" \
--data-binary @- \
https://ntfy.example.com/alertsПідключіть його до сервісу через drop-in, щоб оновлення пакета не перезаписало ваші зміни.
sudo systemctl edit myapp.service[Unit]
OnFailure=ntfy-unit-failed@%n.service%n розгортається в повне ім’я unit, тому екземпляр отримує ім’я ntfy-unit-failed@myapp.service, а %i у шаблоні передає myapp.service скрипту як його перший аргумент. Завдяки цьому один шаблон можна використовувати для будь-якого unit. Перевірте його роботу за допомогою unit, який навмисно завершується з помилкою. Збережіть його як /etc/systemd/system/ntfy-selftest.service.
[Unit]
Description=Deliberately failing unit
OnFailure=ntfy-unit-failed@%n.service
[Service]
Type=oneshot
ExecStart=/bin/falsesudo systemctl daemon-reload
sudo systemctl start ntfy-selftest.serviceКоманда запуску завершується з ненульовим кодом і виводить Job for ntfy-selftest.service failed because the control process exited with error code, а телефон має подати сигнал приблизно через секунду. Після цього видаліть тестовий unit.
Окремо зверніть увагу на одну особливість. OnFailure= запускається лише тоді, коли unit переходить у стан failed, а сервіс із Restart=always може ніколи не перейти в цей стан, оскільки systemd натомість постійно перезапускає його. Unit вважається таким, що зазнав збою, лише після перевищення StartLimitBurst перезапусків протягом StartLimitIntervalSec. Установіть ці два значення для кожного сервісу, про збої якого потрібно отримувати сповіщення. Інакше цикл падінь і перезапусків може непомітно тривати кілька днів. Таймери є кращою заміною наведеного вище шаблону з cron, оскільки service unit таймера автоматично отримує OnFailure=, а у посібнику із systemd services і timers на VPS описано перехід на таку схему.
Під’єднайте монітор доступності до тієї самої topic
Uptime Kuma, self-hosted монітор доступності, підтримує тип сповіщень ntfy. Відкрийте Settings, потім Notifications, потім Setup Notification, виберіть Ntfy, укажіть URL сервера https://ntfy.example.com і topic alerts, виберіть пріоритет і вставте токен доступу robot. Надішліть тестове сповіщення перед збереженням, оскільки неправильна назва topic спричиняє тихий збій, якщо grant write не поширюється на цей topic.
Реальне обмеження цієї схеми: монітор, запущений на тому самому VPS, не може повідомити, що VPS недоступний, а ntfy не може доставити повідомлення про недоступність самого ntfy. Запустіть монітор на іншій машині та додайте для нього другий канал сповіщень, наприклад email, щоб контролювати сам ntfy. Тип монітора Push в Uptime Kuma усуває іншу проблему з контролем: cron job викликає push URL після успішного виконання, а Kuma надсилає сповіщення, коли ці виклики припиняють надходити. Гілка обробки помилки спрацьовує лише тоді, коли job запущено, тому вона нічого не повідомляє про job, яка взагалі не стартувала.
Чи працює self-hosted ntfy на Android та iPhone?
На Android — так, без обмежень. Встановіть застосунок із Google Play або F-Droid, відкрийте Settings, установіть сервер за замовчуванням `https://ntfy.example.com, додайте свій обліковий запис на екрані керування користувачами, а потім підпишіться на alerts`. Миттєва доставка підтримує запущену у foreground службу, тому повідомлення надходять навіть коли телефон перебуває в режимі doze. Постійне сповіщення, яке з’являється разом із нею, є вимогою Android для foreground-служб, а не помилкою. Збірка F-Droid не містить коду Firebase, тому кожна підписка використовує миттєву доставку. ntfy також може працювати як дистриб’ютор UnifiedPush — відкритий замінник push-сервісу Google. Тому інші застосунки з підтримкою UnifiedPush теж можуть доставляти повідомлення через ваш сервер.
На iOS застосунок працює із залежністю, яку неможливо усунути. Apple пробуджує застосунок у background лише через APNs (Apple push notification service), а надсилати йому повідомлення може лише сторона, яка має облікові дані для підпису застосунку. Тому ваш сервер не може напряму зв’язатися із застосунком. ntfy вирішує це за допомогою relay: ваш сервер надсилає до ntfy.sh `poll_request`, що містить ID повідомлення. ntfy.sh передає його через Firebase і APNs, щоб пробудити застосунок, після чого застосунок отримує тіло повідомлення з вашого сервера.
upstream-base-url: "https://ntfy.sh"Важливо розуміти ціну цього рішення. Вміст повідомлення залишається на вашому сервері, але факт надходження повідомлення та його ID проходять через інфраструктуру, якою ви не керуєте. Без цього налаштування сповіщення на iPhone із self-hosted сервера надходять із затримкою або не надходять зовсім, оскільки ніщо не пробуджує застосунок. Єдиний спосіб усунути relay — самостійно зібрати та опублікувати iOS-застосунок, використовуючи власний обліковий запис Apple developer і власні ключі APNs. Це передбачає щорічну оплату та повторну збірку для кожного оновлення. Якщо relay неприйнятний для вашого сценарію, залиште сповіщення на Android або в desktop web app.
Резервне копіювання, оновлення та фіксація образу
Два шляхи неможливо відновити повторною генерацією: /etc/ntfy/server.yml і /var/lib/ntfy/user.db. У другому зберігаються всі користувачі, хеші паролів, записи ACL і токени, тому поводьтеся з ним як із приватним криптографічним ключем.
sudo tar czf ntfy-backup.tgz -C / etc/ntfy var/lib/ntfy
sudo chmod 600 ntfy-backup.tgzСкопіюйте цей файл за межі сервера. cache.db містить лише нещодавні повідомлення — дані за 12 годин разом із наведеним вище cache-duration, тому його втрата не призведе до втрати даних, які потрібно захищати. Щоб оновити систему, змініть тег у compose-файлі та виконайте pull.
sudo docker compose pull
sudo docker compose up -d
curl -s https://ntfy.example.com/v1/healthСпочатку прочитайте примітки до випуску. Бази даних SQLite мігрують під час запуску, тому після зміни схеми відкат до старішого тегу небезпечний. Зберігайте щойно створену резервну копію, доки нова версія не пропрацює один день.
Gotify та Apprise
Gotify — компактніший варіант: один бінарний файл із вебінтерфейсом і застосунком для Android, без шаблонів тем і офіційного клієнта для iOS. Це підходить для приватного сервера, де єдиною цільовою платформою є Android. Apprise — це бібліотека Python і інструмент командного рядка, а не сервер. Він надсилає одне повідомлення до понад ста сервісів, зокрема ntfy. Це підходить для скрипту, якому потрібно одночасно доставляти повідомлення в кілька місць. ntfy надає сервер, HTTP API і застосунки для обох мобільних платформ. Тому саме його зазвичай обирають для надсилання сповіщень із орендованого сервера.
FAQ
Чому публікація в моєму сервері ntfy повертає 403?
Якщо в auth-default-access: "deny-all" налаштовано server.yml, анонімну публікацію відхилено, і це очікувана поведінка. Передайте облікові дані за допомогою -u user:pass або -H "Authorization: Bearer tk_...". Якщо ви вже передаєте токен і все одно отримуєте 403, користувач, якому належить цей токен, не має відповідного запису ACL для topic. Виконайте ntfy access, щоб вивести повний список. Пам’ятайте, що дозвіл write не дає змоги підписуватися, тому обліковий запис, який може публікувати повідомлення, все одно отримає відмову, коли спробує прочитати той самий topic.
Чи працюють сповіщення на iPhone із self-hosted сервером ntfy?
Так, але через relay, якого неможливо уникнути. Apple пробуджує застосунки лише через APNs (Apple push notification service), і надсилати повідомлення до нього може лише видавець застосунку. Тому ntfy пересилає до ntfy.sh poll_request, що містить ID повідомлення, а ntfy.sh передає його на пристрій. Налаштуйте upstream-base-url: "https://ntfy.sh" у server.yml і перезапустіть container. Сам текст повідомлення й надалі отримується з вашого сервера. Без цього налаштування сповіщення iOS затримуються або взагалі не з’являються.
Чому сповіщення ntfy з мого cron job так і не надійшло?
Спочатку виконайте рядок curl окремо, щоб перевірити правильність токена й topic. Якщо вручну все працює, але з cron — ні, проблема виникає до надсилання сповіщення: cron запускає завдання з мінімальним оточенням і коротким PATH, тому script, який викликає команду за коротким ім’ям, може завершитися до виконання рядка curl. Використовуйте абсолютні шляхи, перенаправляйте вивід завдання до log file і після наступного запуску прочитайте цей файл. Відповідь 429 замість доставки означає, що обмеження частоти працює, а ваш script повторює спроби надто швидко.
Чи варто відкривати ntfy у публічному інтернеті?
Мобільні застосунки на телефонах мають підключатися до нього через мобільні мережі, тому публічний HTTPS endpoint із auth-default-access: "deny-all" і ACL для кожного topic є типовою схемою. Вона безпечна, якщо жоден topic не доступний для читання через everyone. Екземпляр, доступний лише через VPN, є прийнятним, коли кожен subscriber — це машина під вашим контролем. Для телефонів це невдалий варіант, оскільки застосунок отримує повідомлення лише під час активного тунелю, тому сповіщення накопичуються в черзі до повторного підключення телефона.