自建 ntfy 推送通道:Docker 部署、Token 认证与 Nginx 长连接反代
项目信息
- GitHub:https://github.com/binwiederhier/ntfy
- 文档:https://docs.ntfy.sh
为什么个人站需要一条自己的推送通道
半夜磁盘写满、PHP-FPM 进程池打满、网站返回 502,你要等到第二天用户投诉才知道。Prometheus、Zabbix、Uptime Kuma 都装了,问题在于最后一段:告警怎么落到手机上。
常见的土办法有三种:告警发邮件,手机邮箱 App 推送经常延迟,或者被系统省电策略杀掉;发到微信、钉钉、飞书群,公司场景可以,个人站挂个机器人有点小题大做,而且第三方接口说变就变;买 Server 酱一类托管服务,免费额度有限,敏感信息还要过别人的服务器。
ntfy(读作 notify)是自托管圈子里常见的一个答案。Go 写的单文件二进制,服务端客户端全开源,跑在自己的服务器上,通过长连接把消息推到手机。
ntfy 的定位
推送架构是发布 / 订阅:脚本向某个 topic 发一条 HTTP POST,订阅了该 topic 的手机 App 立刻收到通知。消息内容经过你自己的服务器。
- 对比邮件:邮件是拉取式,客户端受系统省电策略影响大,延迟从几秒到几十分钟不等。ntfy 走常驻长连接加系统级推送,正常几秒内到达。
- 对比微信 / 钉钉机器人:那是别人的平台,接口会变、会限流、会封机器人,告警内容也全在对方服务器上。
- 对比托管推送服务:自建没有月额度限制,也不担心服务商跑路。
ntfy 同时提供网页端订阅,任何浏览器打开一个 URL 就能收消息,临时调试或者手边没有手机时能用。
服务端部署:Docker Compose
mkdir -p /opt/ntfy/{cache,etc}
cd /opt/ntfy
docker-compose.yml:
services:
ntfy:
image: binwiederhier/ntfy
container_name: ntfy
command:
- serve
environment:
- TZ=Asia/Shanghai
volumes:
- ./cache:/var/cache/ntfy
- ./etc:/etc/ntfy
ports:
- "127.0.0.1:2586:80"
restart: unless-stopped
端口只绑 127.0.0.1,不要写成 2586:80(那等于绑到 0.0.0.0,全网可访问)。ntfy 默认没有认证,任何人只要知道你的域名和 topic 名就能发消息,也能订阅你的 topic 读消息——topic 名本身不是密钥,是可以猜的。先绑回环,后面用 Nginx 反代再加认证。
docker compose up -d
docker compose logs -f ntfy
看到 listening on [::]:80 之类的日志就起来了。本机验证:
curl -d "ntfy 服务已启动" http://127.0.0.1:2586/mytopic
返回一串 JSON 即发布成功。
配置文件
命令行参数适合试跑,长期运行要写配置文件。ntfy 读取容器内的 /etc/ntfy/server.yml,对应宿主机挂载的 ./etc/server.yml。
base-url: "https://ntfy.example.com"
listen-http: ":80"
cache-file: "/var/cache/ntfy/cache.db"
cache-duration: "48h"
behind-proxy: true
auth-file: "/var/cache/ntfy/user.db"
auth-default-access: "deny-all"
base-url:必须填最终对外访问的 HTTPS 地址,否则 App 收到的点击跳转链接会指向内网 IP,点开打不开。新手最常见的坑。cache-file/cache-duration:消息落地到磁盘,手机离线一会儿再上线也能收到积压消息,这里保留 48 小时。behind-proxy: true:告诉 ntfy 它在 Nginx 后面,需要信任X-Forwarded-For来识别真实客户端 IP。auth-default-access: deny-all:默认拒绝匿名访问,只有授权用户才能收发。
开启认证
改完配置先重启一次让文件生效:
docker compose restart ntfy
# 创建管理员
docker exec -it ntfy ntfy user add --role=admin admin
# 创建普通用户(只给读写权限)
docker exec -it ntfy ntfy user add sender
docker exec -it ntfy ntfy access sender "*" read-write
user add 会提示输入密码。发告警用 sender 这个账号,权限只给 read-write,不给 admin。
配好认证后,发布消息必须带凭据。Basic Auth:
curl -u sender:你的密码 -d "测试告警" https://ntfy.example.com/mytopic
更推荐访问令牌(access token),适合脚本和 systemd 服务,避免密码到处写:
docker exec -it ntfy ntfy token add sender
# 输出形如 tk_xxxxxxxxxxxx,记录下来
curl -H "Authorization: Bearer tk_xxxxxxxxxxxx" -d "测试告警" https://ntfy.example.com/mytopic
token 本身不等于全权限——它绑定到某个用户,能读能写哪些 topic 由该用户在 auth-file 里的 ACL 决定,所以给 sender 的 read-write 范围要收窄。
Nginx 反向代理
ntfy 依赖长连接,反代配置写错会出现「网页能打开但手机收不到消息」。
server {
listen 443 ssl http2;
server_name ntfy.example.com;
ssl_certificate /etc/letsencrypt/live/ntfy.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/ntfy.example.com/privkey.pem;
location / {
proxy_pass http://127.0.0.1:2586;
proxy_http_version 1.1;
# 长连接/WebSocket 必须的头部
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
# 关闭缓冲,否则消息会被"攒着"延迟下发
proxy_buffering off;
# 长连接不能按默认 60s 超时断开
proxy_read_timeout 1d;
proxy_send_timeout 1d;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
三处关键点:proxy_buffering off 必须关,否则 Nginx 会把流式响应攒在缓冲区,消息到 App 有明显延迟;proxy_read_timeout 要拉长(1d),App 的订阅连接是长期挂着的,默认 60 秒会被掐断导致反复重连;Upgrade 和 Connection 头是必须的。
证书:
certbot --nginx -d ntfy.example.com
手机端接入
应用商店装官方 App「ntfy」(Android 在 Play 商店或 F-Droid,iOS 在 App Store)。首次打开:
- 设置里把 Default server 改成
https://ntfy.example.com,否则默认连官方 ntfy.sh。 - 订阅你的 topic,比如
mytopic。 - 服务端开了认证时,App 里填用户名密码,或粘贴 token。
iOS 有个情况要提:ntfy 在 iOS 上依赖 Apple 推送服务(APNs)兜底长连接,但自建实例要启用 APNs 需要自行托管转发服务,配置比 Android 麻烦。个人用户的折中方案是 iOS 上同时开着 App 后台长连接(系统设置里允许 ntfy 后台刷新)。Android 相对简单,前台服务常驻即可。
ntfy 也支持用 URL 直接订阅并带参数,适合做成「一键订阅」链接:
ntfy://ntfy.example.com/mytopic
接进监控和脚本
场景一:磁盘写满告警。
#!/bin/bash
THRESHOLD=85
TOKEN="tk_xxxxxxxxxxxx"
USAGE=$(df / | awk 'NR==2 {print $5}' | tr -d '%')
if [ "$USAGE" -ge "$THRESHOLD" ]; then
curl -s \
-H "Authorization: Bearer $TOKEN" \
-H "Title: 磁盘告警" \
-H "Priority: high" \
-H "Tags: warning" \
-d "根分区已用 ${USAGE}%,请及时清理" \
https://ntfy.example.com/mytopic
fi
ntfy 允许用 HTTP 头指定标题、优先级和标签。Priority: high 让手机以更醒目的方式提醒,Tags 显示图标。
场景二:systemd 服务失败自动通知。
[Unit]
Description=Notify on failure
[Service]
Type=oneshot
ExecStart=/usr/local/bin/notify-failure.sh "%i"
[Install]
WantedBy=multi-user.target
在被监控的服务里加 OnFailure=notify-failure@%n.service,服务崩溃时 systemd 触发这个 oneshot 单元推消息。
场景三:接进 Uptime Kuma。 Uptime Kuma 内置 ntfy 通知方式,在「设置 → 通知」里选 ntfy,填服务器地址、topic 和 token,网站一宕机就推送。
场景四:部署完成通知。 在 CI/CD 或部署脚本最后加一行 curl,成功失败都推一条。
进阶:用心跳防「安静地坏掉」
告警系统最怕的不是误报,而是推送通道本身挂了、或者脚本被注释掉了,你却以为一切正常。
做法是准备两个 topic:alerts 收正常告警,heartbeat 由正常运行的机器定时发「我活着」。如果 heartbeat 超过预期时间没消息,说明某台机器或某个服务挂了。Healthchecks、Uptime Kuma 的 Push 类型监控都内置了这种反向确认逻辑。
ntfy 支持消息的 Delay 头做延时投递,可以配合实现「N 分钟内没有新事件就提醒」这类逻辑。
六个最容易踩的坑
- 端口绑到 0.0.0.0 且没开认证:任何人扫到 2586 就能发消息、订阅偷听。永远先绑
127.0.0.1,对外只走 Nginx,配好deny-all和用户认证。 base-url填了内网地址:手机端点击通知跳转到内网 IP,打不开。必须填最终 HTTPS 域名。- 反代漏了
proxy_buffering off:消息延迟严重,甚至攒一批一起发。这是长连接类服务反代的通病。 - 没配
proxy_read_timeout:默认 60 秒断开,手机端反复重连,耗电且丢消息。 - token 和密码写死在脚本里并提交到 Git:仓库一公开等于把推送通道送人。用环境变量或
chmod 600的凭据文件。 - 只监控不验证:装完做一次断网演练——手动停掉一个服务,确认告警真的到了手机上。没验证过的告警通道等于没有。
小结
ntfy 用一个极简的 HTTP 模型把「服务器事件」和「人」之间的最后一段打通了:单二进制、无外部依赖、支持 Docker,也支持无 App 的网页订阅。
部署顺序:Docker 起来 → 配置文件定住 base-url 和缓存 → 开认证建 token → Nginx 反代关 buffering、拉长超时 → 手机 App 改默认服务器 → 用 curl 接进磁盘告警和服务失败通知 → 最后做一次断网演练。