如何自托管 ntfy 服务器实现推送通知
本指南教你使用 Docker Compose 在 VPS 上自托管 ntfy,通过 TLS 加密、ACL 权限控制和用户认证保护敏感推送。涵盖从配置文件编写到 systemd OnFailure 集成的完整步骤,确保你的服务器告警安全可靠。
自托管 ntfy 服务器的作用
自托管 ntfy 服务器将 HTTP POST 请求转换为手机上的推送通知。您可以使用 curl 发布消息,消息会送达至 Android 应用、iOS 应用、浏览器标签页或任何其他能够保持 HTTP 连接的终端。无需安装客户端库,也无需运行消息代理。
ntfy 通过主题 (topic) 来寻址消息。主题是 URL 路径中的一个名称,例如 https://ntfy.example.com/alerts,只要有人向其发布消息,该主题即刻创建。在默认安装中,任何知道该名称的人都可以读取或写入该主题,因此项目文档将主题名称比作密码。这种模式对于公共的 ntfy.sh 服务是可行的。但对于承载备份失败通知等敏感信息的服务器,这种模式并不安全,因此本指南将在发送第一条消息前启用身份验证。
开始前的准备工作
你需要一台运行 Ubuntu 24.04 或 Debian 13 的 VPS,并安装 Docker Engine 和 Compose 插件。此外,你需要一个域名和极少的内存。请创建一个指向服务器公网 IP 地址的 DNS A 记录 ntfy.example.com,并在进行后续操作前确认该记录已生效。
dig +short ntfy.example.com
sudo ufw allow 80,443/tcp
sudo ufw statusdig 必须输出服务器的 IP 地址。如果该命令无输出,证书签发将会失败,因为证书颁发机构会从外部验证域名。请保持 80 端口开放,因为 Let's Encrypt 所使用的 ACME 协议需要通过该端口进行 HTTP 验证。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 会据此构建附件链接以及 Web 应用自身的请求地址;若该值错误,Web 应用虽然能加载,但所有操作都会失败。listen-http: ":2586" 绑定到容器内的所有接口,这看起来不够严谨,但却是正确的做法:容器拥有独立的网络命名空间,若在此处绑定到 127.0.0.1,则宿主机将无法访问该端口,Docker 映射的端口也将无法连接。auth-default-access: "deny-all" 是整个安全架构的核心,它拒绝任何未经明确授权的读写访问。behind-proxy: true 指示 ntfy 从 X-Forwarded-For 头部获取客户端地址,从而确保速率限制针对的是真实访问者,而不是将反向代理视为唯一的繁忙客户端。
enable-login: true 允许 Web 应用和手机客户端使用密码登录。enable-signup 保持为 false,因为在私有服务器上开启自助注册功能等同于敞开大门。
sudo chown "$(id -u):$(id -g)" /etc/ntfy/server.yml
sudo chmod 600 /etc/ntfy/server.yml使用 Docker Compose 运行 ntfy
将此内容放入 /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 年 8 月的当前版本),而不是 latest,因为使用 latest 时,下一次 docker compose pull 会更改服务器版本,而你只能在事后通过更新日志发现。端口发布为 127.0.0.1:2586:2586,因此容器仅能通过主机的回环地址访问。如果写入 2586:2586,Docker 会在你的防火墙规则之前插入其自身的规则,这意味着即使 ufw status 显示端口已关闭,该端口仍会响应来自互联网的请求。
如果 curl 输出 Connection refused,请查看容器日志。/var/lib/ntfy/user.db 上的权限错误意味着 user: 行与这些目录的所有者不匹配,导致进程无法创建自己的数据库并退出。Docker Compose VPS 基础指南 更详细地介绍了卷所有权和重启策略。
使用 Caddy 在前端部署 TLS
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通过 HTTPS 访问相同的 {"healthy":true},说明整个路径配置正确。如果收到来自 Caddy 的 502,说明 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,并将读取和发送超时时间至少设置为 3 分钟。订阅者在监听期间会保持一个 HTTP 连接处于打开状态,而 nginx 默认会在 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 accessACL(访问控制列表)条目由用户、主题和权限组成。主题可以是具体的名称,也可以是模式。其中 * 可以匹配任何内容,因此 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 的名称指定,它决定了手机是否发出提示音。当名称与已知的 emoji 短代码匹配时,Tags 会在通知中显示为 emoji,否则将显示为纯文本。
若要从终端监控某个主题,请使用流式传输:
curl -s -u admin https://ntfy.example.com/alerts/rawcurl 会提示输入密码。每条消息以单行形式到达,期间出现的空行是保活心跳包。在浏览器中打开 https://ntfy.example.com 并使用相同账户登录,即可使用该流的 Web 应用版本。
设置速率限制以防止单个脚本导致服务器过载
默认情况下,每位访问者拥有 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 任务的警报
请勿将令牌(token)直接放在命令行中。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 比登录 shell 短得多,因此手动运行正常的脚本在到达 curl 行之前就可能已经终止。关于 cron 任务为何无法运行的指南 涵盖了这些环境陷阱。请务必在所有地方使用绝对路径,并在首次计划运行后查看日志文件,而不要仅凭假设。
当 systemd 单元失败时发出警报
Cron 适用于定时任务。长期运行的服务需要 OnFailure=,每当单元进入 failed 状态时,systemd 就会运行该服务。创建一个模板单元并将其复用于服务器上的每个服务。将其保存为 /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 会不断重启它。只有当服务在 StartLimitIntervalSec 时间内重启次数超过 StartLimitBurst 时,单元才会判定为失败。请为您希望监控的服务设置这两个值,否则崩溃循环可能会在后台静默运行数天。定时器(Timers)是替代上述 cron 模式的更优方案,因为定时器的服务单元会自动获得 OnFailure=,VPS 上 systemd 服务与定时器指南 中详细介绍了如何进行转换。
将在线状态监控接入同一主题
Uptime Kuma,自托管状态监控工具 内置了 ntfy 通知类型。打开 Settings,进入 Notifications,点击 Setup Notification,选择 Ntfy,将服务器 URL 设置为 https://ntfy.example.com,主题设置为 alerts,选择优先级,并粘贴 robot 访问令牌。保存前请先发送测试通知,因为如果 write 授权未覆盖该主题,错误的名称会导致静默失败。
此方案的局限性在于:运行在同一台 VPS 上的监控器无法告知你该 VPS 已宕机,且 ntfy 无法发送关于自身故障的通知。请在另一台机器上运行监控器,并为监控 ntfy 本身的任务配置第二个通知渠道(例如电子邮件)。Uptime Kuma 的 Push 监控类型可以覆盖另一个盲点:你的 cron 任务在成功运行后调用一个推送 URL,当这些调用停止时,Kuma 会发出警报。故障分支仅在任务运行时触发,因此它无法检测到从未启动的任务。
自托管的 ntfy 能在 Android 和 iPhone 上运行吗?
在 Android 上,完全可以。从 Google Play 或 F-Droid 安装应用,打开设置,将默认服务器设置为 https://ntfy.example.com,在用户管理界面添加账户,然后订阅 alerts。即时推送功能会保持一个前台服务运行,因此即使手机处于打盹模式(doze mode),消息也能送达。该功能附带的永久通知是 Android 对前台服务的强制要求,并非程序错误。F-Droid 版本完全不包含 Firebase 代码,因此所有订阅均使用即时推送。ntfy 还可以作为 UnifiedPush 分发器(Google 推送服务的开源替代方案),其他支持 UnifiedPush 的应用也能通过你的服务器进行推送。
在 iOS 上,它需要一个无法移除的依赖项。Apple 仅允许通过 APNs(Apple 推送通知服务)唤醒后台应用,且只有持有应用签名凭证的一方才能向其发送通知,因此你的服务器无法直接连接该应用。ntfy 通过中继解决了这个问题:你的服务器向 ntfy.sh 发送一个包含消息 ID 的 poll_request,由其通过 Firebase 和 APNs 转发以唤醒应用,随后应用会从你的服务器获取消息正文。
upstream-base-url: "https://ntfy.sh"请明确其代价。消息内容保留在你的服务器上,但“有消息到达”这一事实及其 ID 会经过非你运行的基础设施。若不使用此设置,自托管服务器发往 iPhone 的通知会延迟或无法送达,因为没有任何机制可以唤醒应用。移除该中继的唯一方法是使用你自己的 Apple 开发者账户和 APNs 密钥自行构建并发布 iOS 应用,这意味着每年需要支付费用,且每次更新都需要重新构建。如果无法接受该中继机制,请在 Android 或桌面端 Web 应用上使用提醒功能。
备份、升级与镜像锁定
有两个路径无法重新生成:/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 仅存储最近的消息,且受限于上述 cache-duration 的 12 小时限制,因此丢失该文件不会造成任何损失。升级意味着修改 compose 文件中的标签并执行拉取操作。
sudo docker compose pull
sudo docker compose up -d
curl -s https://ntfy.example.com/v1/health请先阅读发行说明。SQLite 数据库会在启动时自动迁移,因此在架构变更后回滚到旧标签是不安全的。在新版本稳定运行一天之前,请保留刚才创建的备份。
Gotify 与 Apprise
Gotify 是更轻量的选择:它是一个包含 Web UI 和 Android 应用的单一二进制文件,不支持主题通配符,也没有官方 iOS 客户端,适合仅以 Android 为目标的私有服务器。Apprise 并非服务器,而是一个 Python 库和命令行工具,它能将单条消息分发到包括 ntfy 在内的 100 多种服务,适合需要同时通知多个目标的脚本。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 授权并不允许订阅,因此一个可以正常发布消息的账户在尝试读取同一主题时仍会被拒绝。
自托管的 ntfy 服务器上的通知能在 iPhone 上工作吗?
它们可以通过一个无法绕过的中继服务正常工作。Apple 仅通过 APNs(Apple 推送通知服务)唤醒应用,且只有应用的发布者才能向其发送消息,因此 ntfy 会将包含消息 ID 的 poll_request 转发给 ntfy.sh,由其转发至设备。在 server.yml 中设置 upstream-base-url: "https://ntfy.sh" 并重启容器。消息正文本身仍从你的服务器获取。若不进行此设置,iOS 通知将会延迟或无法显示。
为什么我的 cron 任务触发的 ntfy 提醒没有收到?
首先单独运行该 curl 命令,以验证令牌和主题是否正确。如果手动运行正常但 cron 任务失败,则问题出在提醒的上游:cron 执行任务时环境配置极简且 PATH 很短,因此直接调用命令名称的脚本可能在执行到 curl 行之前就已终止。请使用绝对路径,将任务输出重定向到日志文件,并在下次运行后查看该文件。如果收到 429 响应而非送达确认,说明速率限制已生效,且你的脚本重试频率过高。
我应该将 ntfy 暴露在公网上吗?
手机应用需要从移动网络访问它,因此配置带有 auth-default-access: "deny-all" 和主题级 ACL 的公共 HTTPS 端点是标准做法;只要没有主题允许 everyone 读取,这种方式就是安全的。如果所有订阅者都是你管理的机器,仅通过 VPN 访问是合理的。但这不适合手机,因为应用只有在隧道连接时才能接收消息,导致提醒会堆积直到手机重新连接。