Как развернуть свой сервер ntfy через Docker Compose
Узнайте, как поднять собственный сервер ntfy на VPS с поддержкой TLS. Настройте аутентификацию, ACL и интеграцию с cron или systemd для получения уведомлений об ошибках системы.
Зачем нужен self-hosted сервер ntfy
Self-hosted сервер ntfy преобразует HTTP POST-запрос в push-уведомление на вашем телефоне. Вы публикуете сообщение с помощью curl, и оно доставляется в приложение для Android, iOS, вкладку браузера или любое другое устройство, способное поддерживать открытое HTTP-соединение. Вам не нужно устанавливать клиентские библиотеки или запускать брокер сообщений.
В ntfy адресация сообщений происходит по topic (теме). Тема — это имя в пути URL, например https://ntfy.example.com/alerts, которое создается в момент первой публикации сообщения. В стандартной конфигурации любой, кто знает это имя, может читать и записывать сообщения в тему, поэтому в документации проекта имя темы сравнивается с паролем. Такая модель подходит для публичного сервиса ntfy.sh. Она не подходит для сервера, через который передаются уведомления об ошибках резервного копирования, поэтому в данном руководстве мы включим аутентификацию до отправки первого сообщения.
Подготовка к работе
Вам потребуется VPS с установленной Ubuntu 24.04 или Debian 13, Docker Engine с плагином Compose, доменное имя и небольшой объем оперативной памяти. Создайте DNS-запись (A record) типа ntfy.example.com, указывающую на публичный IP-адрес сервера, и убедитесь, что она корректно разрешается, прежде чем приступать к дальнейшим действиям.
dig +short ntfy.example.com
sudo ufw allow 80,443/tcp
sudo ufw statusКоманда dig должна выводить IP-адрес вашего сервера. Выпуск сертификата завершится ошибкой, если команда ничего не возвращает, так как центр сертификации проверяет доменное имя извне. Порт 80 должен оставаться открытым, поскольку протокол ACME (automatic certificate management environment), используемый Let's Encrypt, применяет его для HTTP-проверки (HTTP challenge). Сам контейнер ntfy не требует открытия публичного порта.
Создание конфигурационного файла ntfy
В Docker-образе отсутствует конфигурационный файл, поэтому его необходимо создать самостоятельно. Все последующие команды в этом руководстве используют его. Сначала определите ID пользователя и ID группы, от имени которых будет запущен контейнер.
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, чтобы лимиты запросов (rate limits) учитывали реальных посетителей, а не считали обратный прокси-сервер одним крайне активным клиентом.
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 — это текущий релиз на август 2026 года, — а не на latest. При использовании latest следующее docker compose pull изменит версию сервера, а вы узнаете об этом только из changelog. Порт опубликован как 127.0.0.1:2586:2586, поэтому контейнер доступен только через loopback-адрес хоста. Если вместо этого указать 2586:2586, Docker добавит собственные правила firewall перед вашими правилами. В результате порт будет доступен из Интернета, даже если ufw status сообщает, что порт закрыт. Оба подхода применимы и к следующему контейнеру: самостоятельно размещаемый relay RustDesk закрепляет tag образа таким же образом, но его нельзя ограничить loopback-адресом, поскольку его signal- и relay-порты должны быть доступны из Интернета.
Если команда curl выводит Connection refused, изучите лог контейнера. Ошибка прав доступа к /var/lib/ntfy/user.db означает, что строка user: не соответствует владельцу этих директорий, поэтому процесс не может создать собственную базу данных и завершается. В руководстве по основам Docker Compose для VPS подробнее описаны владение томами и политики перезапуска.
Настройка TLS с помощью Caddy
Caddy самостоятельно запрашивает и обновляет сертификаты, что является самым быстрым способом обеспечить работу TLS (transport layer security).
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 подскажет, в чем именно проблема. Добавление еще одного сервиса в будущем потребует лишь еще одного блока с именем хоста в том же Caddyfile; именно так, например, Halcyon, фронтенд в стиле видеопрокатов 90-х для вашей библиотеки Jellyfin размещается на втором поддомене того же сервера.
Если вы уже используете 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 изнутри контейнера, поэтому файл конфигурации подключен как том.
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 превращаются в эмодзи в уведомлении, если имя соответствует известному короткому коду эмодзи, и остаются обычным текстом, если нет.
Чтобы следить за темой из терминала, используйте потоковую передачу:
curl -s -u admin https://ntfy.example.com/alerts/rawcurl запрашивает пароль. Каждое сообщение приходит в виде одной строки, а пустые строки, которые появляются время от времени, являются сигналами поддержания соединения (keepalives). Открытие 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 вместо сообщения. Лимит рассчитывается для каждого IP-адреса посетителя, поэтому параметр 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, чем ваша интерактивная оболочка, поэтому скрипт, работающий при ручном запуске, может завершиться до того, как дойдет до строки с curl. Руководство о том, почему не запускается задание cron описывает эти ловушки окружения. Используйте везде абсолютные пути и после первого запланированного запуска проверьте лог-файл, вместо того чтобы полагаться на предположения.
Оповещение при сбое systemd-юнита
Cron подходит для запланированных задач. Для постоянно работающих сервисов требуется OnFailure=, который systemd запускает при переходе юнита в состояние failed. Создайте один шаблон юнита и используйте его для каждого сервиса на сервере. Сохраните его как /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 с правами 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 разворачивается в полное имя юнита, поэтому экземпляр становится ntfy-unit-failed@myapp.service, а %i внутри шаблона передает myapp.service скрипту в качестве первого аргумента. Это позволяет одному шаблону обслуживать любой юнит. Проверьте работу на примере юнита, который намеренно завершается с ошибкой, сохранив его как /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, а телефон должен завибрировать примерно через секунду. После проверки удалите тестовый юнит.
Следует учитывать один нюанс. OnFailure= срабатывает только при достижении юнитом состояния failed, а сервис с Restart=always может никогда его не достичь, так как systemd будет постоянно его перезапускать. Юнит переходит в состояние сбоя только после превышения StartLimitBurst перезапусков в течение StartLimitIntervalSec. Установите эти два параметра для любого сервиса, о сбоях которого вы хотите получать уведомления, иначе цикл аварийных завершений может оставаться незамеченным в течение нескольких дней. Таймеры являются более чистой альтернативой упомянутому выше cron, так как сервис таймера получает OnFailure= автоматически, а в руководстве по systemd-сервисам и таймерам на VPS подробно описан процесс их настройки.
Интеграция мониторинга доступности в тот же топик
Uptime Kuma, self-hosted монитор состояния, поставляется с поддержкой уведомлений через ntfy. Откройте Settings, затем Notifications, выберите Setup Notification, укажите Ntfy, задайте URL сервера https://ntfy.example.com и топик alerts, выберите приоритет и вставьте токен доступа robot. Отправьте тестовое уведомление перед сохранением, так как при неверном имени топика запрос завершится без ошибки, если права write не распространяются на этот топик.
Реальное ограничение такой схемы: монитор, запущенный на том же VPS, не сообщит о недоступности самого VPS, а ntfy не сможет доставить сообщение о том, что ntfy не работает. Запускайте монитор на другой машине и настройте для него второй канал уведомлений, например email, для мониторинга самого ntfy. Тип монитора Push в Uptime Kuma закрывает другую «слепую зону»: ваш cron-скрипт обращается к push URL после успешного выполнения, а Kuma отправляет оповещение, если эти обращения перестают поступать. Ветка сбоя срабатывает только при запуске задачи, поэтому она не сообщает о задаче, которая не запустилась вовсе.
Работает ли self-hosted ntfy на Android и iPhone?
На Android — да, без ограничений. Установите приложение из Google Play или F-Droid, откройте настройки, укажите адрес вашего сервера в https://ntfy.example.com, добавьте учетную запись в разделе управления пользователями и подпишитесь на alerts. Для мгновенной доставки приложение поддерживает работу фонового сервиса, поэтому сообщения приходят даже в режиме энергосбережения (doze mode). Постоянное уведомление, которое при этом отображается, является требованием Android для фоновых процессов, а не ошибкой. Сборка из F-Droid не содержит кода Firebase, поэтому все подписки используют мгновенную доставку. ntfy также может выступать в роли дистрибьютора UnifiedPush — открытой альтернативы push-сервису Google, что позволяет другим приложениям с поддержкой UnifiedPush доставлять уведомления через ваш сервер.
На iOS работа сервиса зависит от одного условия, которое невозможно исключить. Apple активирует приложение в фоновом режиме только через APNs (Apple push notification service). Отправлять уведомления через этот сервис может только владелец сертификатов подписи приложения, поэтому ваш сервер не имеет возможности связаться с приложением напрямую. ntfy решает эту задачу с помощью реле: ваш сервер отправляет poll_request с идентификатором сообщения на ntfy.sh, который пересылает его через Firebase и APNs для активации приложения, после чего приложение запрашивает тело сообщения с вашего сервера.
upstream-base-url: "https://ntfy.sh"Четко осознавайте последствия этого решения. Содержимое сообщения остается на вашем сервере, но факт доставки и идентификатор сообщения проходят через инфраструктуру, которую вы не контролируете. Без этой настройки уведомления на iPhone от self-hosted сервера будут приходить с задержкой или не будут приходить вовсе, так как приложение не будет получать сигнал для активации. Единственный способ отказаться от использования реле — самостоятельно собрать и опубликовать приложение для iOS, используя собственную учетную запись разработчика Apple и свои ключи APNs. Это потребует ежегодной оплаты и пересборки приложения при каждом обновлении. Если использование реле для вас неприемлемо, используйте уведомления на Android или через веб-приложение на компьютере.
Резервное копирование, обновление и фиксация образа
Два пути невозможно восстановить автоматически: /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 мигрируют при запуске, поэтому откат к более старой версии после изменения схемы небезопасен. Сохраняйте резервную копию, которую вы только что сделали, пока новая версия не проработает сутки. У каждого сервиса на сервере есть свой короткий список путей, которые нельзя восстановить, и сравнение PhotoPrism и Immich определяет этот список, а также команды резервного копирования для фотобиблиотеки на аналогичном VPS.
Gotify и Apprise
Gotify — более компактный вариант: один бинарный файл с веб-интерфейсом и приложением для Android. В нем нет поддержки групповых символов (wildcards) для топиков и официального клиента для 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 для данного топика. Выполните ntfy access, чтобы вывести полный список. Помните, что разрешение write не дает права на подписку, поэтому учетная запись, которая успешно публикует сообщения, получит отказ при попытке чтения того же топика.
Работают ли уведомления на iPhone с self-hosted сервером ntfy?
Они работают через ретранслятор, которого нельзя избежать. Apple активирует приложения только через APNs (Apple push notification service), и отправлять запросы туда может только издатель приложения. Поэтому ntfy пересылает poll_request с идентификатором сообщения на ntfy.sh, который передает его на устройство. Установите upstream-base-url: "https://ntfy.sh" в server.yml и перезапустите контейнер. Тело сообщения по-прежнему загружается с вашего сервера. Без этой настройки уведомления на iOS будут приходить с задержкой или не будут приходить вовсе.
Почему оповещение ntfy от моего cron-задания не пришло?
Сначала выполните команду curl отдельно, чтобы убедиться в правильности токена и топика. Если вручную команда работает, а из cron — нет, проблема находится до отправки оповещения: cron запускает задания с минимальным окружением и коротким PATH, поэтому скрипт, вызывающий команду по короткому имени, может завершиться до выполнения строки с curl. Используйте абсолютные пути, перенаправляйте вывод задания в лог-файл и проверяйте этот файл после следующего запуска. Ответ 429 вместо успешной доставки означает, что сработал лимит частоты запросов (rate limit) и ваш скрипт пытается отправлять их слишком часто.
Стоит ли открывать ntfy для доступа из публичного Интернета?
Мобильным приложениям нужен доступ к серверу из сотовых сетей, поэтому стандартная конфигурация — это публичный HTTPS-эндпоинт с auth-default-access: "deny-all" и ACL для каждого топика. Это безопасно, если ни один топик не доступен для чтения пользователю everyone. Экземпляр, доступный только через VPN, оправдан, если все подписчики — это подконтрольные вам машины. Для телефонов это плохой вариант, так как приложение получает сообщения только при активном туннеле, из-за чего оповещения скапливаются в очереди до момента переподключения телефона.