如何自托管 ntfy 服务器实现推送通知
通过 Docker Compose 在 VPS 上部署 ntfy,配置 TLS 加密与 ACL 访问控制。本指南详细说明如何通过 systemd OnFailure 单元与 cron 任务发送私有推送通知,并解决反向代理下的真实 IP 识别与身份验证问题。
自托管 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 显示端口已关闭,该端口仍会响应来自互联网的请求。这两种做法同样适用于您接下来添加的容器:自托管 RustDesk 中继 以相同方式固定镜像标签,但它不能隐藏在回环地址后,因为其信令端口和中继端口必须响应来自互联网的请求。
如果 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 会指出具体原因。后续若要添加其他服务,只需在同一个 Caddyfile 中增加一个主机名代码块即可;例如,像 Halcyon,一个用于 Jellyfin 库的 90 年代风格视频店前端 这样的应用,就是通过这种方式部署在同一台服务器的第二个子域名上的。
如果您已经在运行 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 秒恢复 1 次。对于私有服务器而言,该配额较为宽松,但陷入重试循环的脚本会耗尽所有配额。请在 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 数据库会在启动时自动迁移,因此在架构变更后回滚到旧标签是不安全的。请保留刚才创建的备份,直到新版本稳定运行一天。服务器上的每个服务都有其无法重新生成的路径清单,PhotoPrism 与 Immich 的对比 针对同类 VPS 上的照片库整理了该清单及相应的备份命令。
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 权限不允许订阅,因此一个可以正常发布消息的账户在尝试读取同一主题时仍会被拒绝。
在 iPhone 上使用自托管的 ntfy 服务器时,通知功能是否正常?
通知功能可以通过中继正常工作,这是无法避免的。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 的实例是合理的。但这不适合手机,因为应用只有在隧道连接时才能接收消息,导致提醒会在手机重新连接前一直处于排队状态。