为什么个人站长需要一个自己的推送通道
做站久了,你一定遇到过这种场面:半夜服务器磁盘写满、PHP-FPM 进程池打满、网站返回 502,而你正在睡觉,直到第二天早上有用户投诉才知道。监控工具(Prometheus、Zabbix、Uptime Kuma)你其实都装了,问题出在最后一百米——告警怎么送到你手机上。
常见的土办法有三种:一是告警发到邮箱,但手机邮箱 App 的推送经常延迟或干脆被系统省电策略杀掉;二是发到微信/钉钉/飞书群,公司场景可以,个人站挂个机器人有点小题大做,而且第三方平台的接口说变就变;三是买个 Server 酱之类的托管服务,免费额度有限,敏感信息还要过别人的服务器。
ntfy(读作 notify)是这几年在自托管圈子里很受欢迎的一个答案。它用 Go 写成,就是一个单文件二进制,服务端和客户端全开源,跑在你自己的服务器上,通过一个长连接把消息推到手机上。本文就从零把 ntfy 部署起来,接到你的监控和脚本里,并把它安全地放在公网后面。
ntfy 到底解决了什么问题:它和那三个土办法的区别
先讲清楚定位,避免你装完发现不合适。
推送架构:ntfy 采用「发布/订阅」模型。你的服务器脚本向某个 topic 发一条 HTTP POST,订阅了这个 topic 的手机 App(通过长连接或 FCM 兜底)立刻收到通知。整个过程你自己掌控,消息内容经过你的服务器。
对比邮件:邮件是拉取式(或半推半拉),客户端受系统省电策略影响大,延迟可以从几秒到几十分钟不等。ntfy 走的是常驻长连接 + 系统级推送,正常几秒内到达。
对比微信/钉钉机器人:那些是别人的平台,接口会变、会限流、会封机器人,而且你的告警内容全在对方服务器上。个人站自建没有这些顾虑。
对比托管推送服务:自建最大的好处是没有月额度限制,也不担心服务商跑路或者把你的数据拿去做什么。
ntfy 的另一个杀手级特点是「无 App 也能用」——它同时提供网页端订阅,任何浏览器打开一个 URL 就能收消息,临时调试或者在没有手机的场景下非常方便。
服务端部署:Docker Compose 三分钟起来
最省心的方式是 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 偷看消息。先绑回环,后面用 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控制保留多久。behind-proxy: true告诉 ntfy 它在 Nginx 后面,需要信任X-Forwarded-For头来识别真实客户端 IP。auth-default-access: deny-all是安全底线——默认拒绝匿名访问,只有授权的用户才能收发。
开启认证:给推送加一把锁
有了 auth-file 配置后,用容器内的命令创建用户。注意先重启一次让配置文件生效:
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
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 秒会被 Nginx 掐断导致反复重连;Upgrade 和 Connection 头是必须的。
证书用 certbot 一键签:
certbot --nginx -d ntfy.example.com
手机端接入:Android 和 iOS 各有讲究
去应用商店装官方 App「ntfy」(Android 在 Play 商店或 F-Droid,iOS 在 App Store)。首次打开后:
- 在设置里把 Default server 改成你的
https://ntfy.example.com,否则默认连的是官方的 ntfy.sh。 - 订阅你的 topic,比如
mytopic。 - 如果服务端开了认证,App 里会要求你填用户名密码(或粘贴 token)。
iOS 有个特殊情况要提醒:iOS 上 ntfy 依赖 Apple 的推送服务(APNs)作为长连接的兜底,但自建实例要启用 APNs 需要自行托管转发服务,配置比 Android 麻烦,很多个人用户的折中方案是在 iOS 上同时开着 App 后台长连接(在系统设置里允许 ntfy 后台刷新)。Android 则相对简单,前台服务常驻即可。
另外 ntfy 支持用 URL 直接订阅并指定参数,非常适合做成一个「一键订阅」链接:
ntfy://ntfy.example.com/mytopic
接进你的监控和脚本:几个真实场景
有了推送通道,接下来的问题是怎么让各种系统都用上它。
场景一:磁盘写满告警。写一个 cron 脚本:
#!/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:
[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,部署成功或失败都推一条,比翻日志省事得多。
进阶:给自己的脚本用一个「心跳」防沉默
告警系统最怕的不是误报,而是「安静地坏掉」——推送服务本身挂了,或者脚本被注释掉了,你却以为一切正常。解决方法是所谓的心跳(heartbeat)机制。
做法是准备两个 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:消息延迟严重,甚至会「攒一批一起发」。这是长连接类服务反代的通病,不是 ntfy 独有。 - 没配
proxy_read_timeout:默认 60 秒断开,手机端反复重连,耗电且丢消息。 - token 和密码写死在脚本里并提交到 Git:一旦仓库公开就等于把推送通道送人。用环境变量或一个
chmod 600的凭据文件。 - 只监控不验证:装完 ntfy 一定要做一次「断网演练」——手动把一个服务停掉,确认告警真的到了手机上。没验证过的告警通道等于没有。
小结
ntfy 的价值不在于技术多复杂,而在于它用一个极简的 HTTP 模型,把「服务器事件」和「人」之间的最后一米打通了。它单二进制、无外部依赖、支持 Docker、也支持无 App 的网页订阅,非常适合个人站长这种「自己就是运维」的场景。
部署要点回顾:Docker 起来 → 配置文件定住 base-url 和缓存 → 开认证建 token → Nginx 反代记得关 buffering、拉长超时 → 手机装 App 改默认服务器 → 用 curl 接进磁盘告警和服务失败通知 → 最后做一次断网演练确认真的能收到。做到这几步,你半夜就再也不用靠用户投诉来发现服务器出问题了。