SSD Nodes Learn 🎉 VPS от $5.50/мес
Руководства Matt ConnorАвтор: Matt Connor

Как развернуть свой сервер 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, указывающую 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-проверки. Сам контейнер ntfy не требует открытия публичного порта.

Создание файла конфигурации ntfy

В Docker-образе отсутствует файл конфигурации, поэтому его необходимо создать самостоятельно. Все последующие команды в этом руководстве используют этот файл. Сначала определите идентификатор пользователя (UID) и группы (GID), от имени которых будет запущен контейнер.

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.yml
base-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-stopped
cd /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 изменит версию на сервере, и вы узнаете об этом только из списка изменений. Порт опубликован как 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 с помощью 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 уточняет, в чем именно проблема.

Если вы уже используете 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 mount).

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/raw

curl запросит пароль. Каждое сообщение приходит отдельной строкой, а пустые строки, которые появляются время от времени, являются сигналами поддержания соединения (keepalives). Если открыть https://ntfy.example.com в браузере и войти под той же учетной записью, вы получите веб-версию того же потока.

Настройка лимитов запросов для предотвращения перегрузки сервера одним скриптом

По умолчанию каждый посетитель получает корзину на 60 запросов, которая пополняется со скоростью один запрос каждые 5 секунд. Это достаточно щедрый лимит для частного сервера, однако скрипт, попавший в бесконечный цикл повторных попыток, быстро исчерпает его. Добавьте ограничения в server.yml.

visitor-request-limit-burst: 30
visitor-request-limit-replenish: "10s"
visitor-message-daily-limit: 500
sudo 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, чем ваша интерактивная оболочка, поэтому скрипт, работающий при ручном запуске, может завершиться до того, как дойдет до строки с 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/false
sudo 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, откройте Settings, укажите адрес вашего сервера в https://ntfy.example.com, добавьте учетную запись в разделе управления пользователями и подпишитесь на alerts. Функция instant delivery поддерживает работу фонового сервиса, поэтому сообщения приходят даже в режиме энергосбережения. Постоянное уведомление, которое при этом отображается, является требованием Android для фоновых служб, а не ошибкой. Сборка из F-Droid полностью лишена кода Firebase, поэтому все подписки используют instant delivery. 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 мигрируют при запуске, поэтому откат к старой версии после изменения схемы небезопасен. Сохраняйте резервную копию, которую вы только что сделали, пока новая версия не проработает сутки.

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, оправдан, если все подписчики — это подконтрольные вам машины. Для телефонов это плохой вариант, так как приложение получает сообщения только при активном туннеле, поэтому оповещения будут скапливаться в очереди до тех пор, пока телефон не восстановит соединение.