在 VPS 上部署 ntfy:把脚本通知送到手机,并分开读写权限

用 ntfy 2.28.0、Docker Compose 和 Caddy 搭建私有通知服务,配置默认拒绝、主题读写账号和访问令牌,说明 Android、iOS 与浏览器的接收条件,以及缓存、备份和恢复方法。

·13 minDockerVPSntfy通知Caddy
土耳其 VDS,完整 root 权限|BRNCHOST · 自管服务器
建站开服,云上轻松起步|雨云 RCS · 宝塔 / 1Panel 预装
低价年付,搭起你的应用|RackNerd · KVM VPS · SSD 存储
香港轻量,按配置选型|晚安云 · 云服务器
NVMe 机型,关注磁盘 I/O|野草云 · 香港 VPS
大陆优化,连接海外应用|搬瓦工 · CN2 GIA / CTGNet 套餐
读文件、写文档、跑任务|WorkBuddy · AI 工作台
CVM 云主机,配置按需选|腾讯云 · 云服务器
中国方向优化,认准系列|DMIT · Premium / CN2 GIA
双 ISP 住宅 VPS|丽萨主机 · 原生 IP · 多地区产品
每周自动异地备份|Evoxt · 高频 CPU · 云服务器
京东云轻量云主机:2核2G,129元/年,新人限购1台

备份脚本跑完、证书续期失败、磁盘快满了,这些消息如果只留在服务器日志里,往往要等下次登录才看见。ntfy 可以接收一个 HTTP 请求,把消息送到订阅了对应主题的手机或浏览器。发送端不必安装专门的客户端,能发 HTTP 请求就可以接入。

部署前先把权限配好。默认启动的 ntfy 允许任何人读写主题,换一个难猜的主题名,并不等于建立了私人通知服务。本文把它配置成默认拒绝访问,再分别给发送脚本和接收账号授权,同时说明 Android、iPhone 和浏览器的接收条件。

这套服务负责传消息,不负责判断服务器是否正常

ntfy 的主题可以理解成一个消息地址。备份脚本向 ops-backup 发消息,手机订阅同一个主题,就能收到它;站点检查可以使用另一个主题,避免所有事情挤在一处。

它并不会自动知道备份是否成功。判断依据来自调用它的脚本、监控工具或应用:程序在哪里判断失败,通知也要放在那个分支。如果脚本从来不运行,ntfy 自然没有消息可送。因此,“备份成功通知”只能说明发送端走到了对应位置,不能证明备份内容能够恢复;对于重要任务,还需要检查未按时执行的情况。

已有 Gotify 或监控告警渠道时,先看是否确实需要多个接收平台和 HTTP 主题接口。没有必要只因为看到一个新工具,就把已有通知系统全部迁走。下面按私人通知服务的用途配置账号,不开放公共主题。

准备一台 VPS、一个域名和 Docker Compose

以下配置使用 ntfy 2.28.0,这是截至 2026 年 10 月 3 日查到的最新发布版本。服务器先安装 Docker 与 Compose 插件,并准备一个自己的子域名,例如 notify.example.com。文中的这个地址是占位示例,配置和客户端里都要替换成实际域名。

反向代理使用安装在宿主机上的 Caddy,公网开放它需要的 HTTP/HTTPS 入口。域名解析到服务器,确认没有失效的 IPv6 解析干扰访问;如果已有网站占用端口,就把新域名加入现有 Caddy 配置,不要另起一个争用 80、443 的实例。

这套示例不接外部 PostgreSQL,使用本地 SQLite 保存消息缓存和账号权限。发文字通知也不需要对象存储。消息量、缓存保留时间和附件会影响磁盘占用,不能只凭“通知工具”几个字判断资源始终很少。

如果需要独立机器,可以查看雨云服务器方案,优惠码为 KuZhuJi。选择时确认手机所在网络能访问该地区服务器,套餐有合适的公网入口,域名使用条件也符合自己的情况。服务端跑起来与手机能及时收到,是两件需要分别检查的事情。

把数据与配置留下来

以普通 SSH 用户的家目录作为项目位置:

mkdir -p ~/ntfy/config ~/ntfy/data
cd ~/ntfy
chmod 700 config data

创建 config/server.yml:

base-url: "https://notify.example.com"
listen-http: ":80"
behind-proxy: true

cache-file: "/var/lib/ntfy/cache.db"
cache-duration: "12h"
auth-file: "/var/lib/ntfy/auth.db"
auth-default-access: "deny-all"
enable-login: true
enable-signup: false

再创建 compose.yaml:

services:
  ntfy:
    image: binwiederhier/ntfy:v2.28.0
    container_name: ntfy
    command: serve
    restart: unless-stopped
    ports:
      - "127.0.0.1:8082:80"
    volumes:
      - ./config:/etc/ntfy:ro
      - ./data:/var/lib/ntfy
    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "3"

auth-file 保存账号、授权和访问令牌;cache-file 保存消息缓存。两者放进同一个持久化目录,但作用不同。把容器删掉再创建时,data/ 中的内容仍在;使用其他路径或删除这个目录,则可能得到一个没有账号和旧消息的新实例。

cache-duration: "12h" 表示消息缓存期限,不是永久聊天记录。离线超过缓存窗口后,不要期待所有旧通知仍能补收。需要长期追溯的备份或监控记录,应保存在原来的任务日志或审计记录里。

这里使用镜像默认运行身份,没有添加任意 user:。如果你自行指定容器用户,必须同步检查 data/ 的写权限,否则认证库和缓存可能建不起来。配置目录是只读挂载,CLI 管理账号仍可写入 data/ 中的认证库。

先检查 Compose 格式并启动:

docker compose config
docker compose up -d
docker compose logs --tail=100 ntfy

格式检查能发现 YAML 或变量展开问题;域名、TLS、账号权限和手机接收要分别检查。日志有错误时先处理启动问题,不要直接把“容器存在”当作部署完成。

用 Caddy 提供 HTTPS 入口

在宿主机 Caddy 的配置中添加:

notify.example.com {
    reverse_proxy 127.0.0.1:8082
}

示例中的反向代理地址是宿主机回环端口。若 Caddy 本身运行在另一个容器里,127.0.0.1 指向的是那个 Caddy 容器,需要改用共享 Docker 网络和 ntfy 服务名,不能照抄这个地址。

使用系统服务安装的 Caddy,可以先验证再重载:

sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy

Caddy 根据域名提供 HTTPS,ntfy 的 base-url 也必须使用对外的 HTTPS 地址。behind-proxy: true 告诉 ntfy 从代理头中识别客户端地址;只有可信代理应该能访问后端,所以示例只把 8082 映射到宿主机回环地址,不额外对公网开放它。

不要再给整个域名套一个与 ntfy 无关的网页登录拦截,除非你已确认原生客户端和脚本如何通过。消息 API、长连接订阅与浏览器页面共用这个服务,网页看似正常,不代表手机或发送脚本也能通过另一套认证。

发送账号只写,接收账号只读

在项目目录执行下面的命令,交互输入两套不同密码:

docker compose exec ntfy ntfy user add backupbot
docker compose exec ntfy ntfy user add reader

它们都是普通用户,默认没有任何主题访问权限。给 backupbot 写入 ops-backup 的权限,给 reader 读取同一主题的权限:

docker compose exec ntfy ntfy access backupbot ops-backup write-only
docker compose exec ntfy ntfy access reader ops-backup read-only
docker compose exec ntfy ntfy access

这组权限有明确的用途:发送脚本可以写通知,但不能读取这个主题的历史内容;手机上的接收账号可以看消息,但不能冒充备份脚本发布“备份成功”。其他主题继续由默认拒绝策略保护。

不要为了省两条授权命令就把脚本账号设成管理员。ntfy 的 admin 角色可以读写全部主题,细粒度 ACL 不会把管理员限制在 ops-backup。需要管理全站时再单独创建管理员账号,不要把它的凭据分发给每个任务。

如果以后使用 ops-* 这样的通配主题,要先考虑它是否涵盖了原本不打算开放的主题。主题名不是保密凭据,也不是用户名;知道主题名的人,仍应经过账号授权才能读写。

给脚本发令牌,别共用手机密码

给发送账号建立一枚有效期为 30 天、带用途标签的令牌:

docker compose exec ntfy ntfy token add --expires=30d --label=backup-job backupbot

命令会输出令牌,应当按密码保存。这里用有效期说明轮换方式,并不要求所有任务都固定为 30 天;选多长时间,要结合能否及时更换脚本中的凭据。到期后发送会失败,需要提前安排轮换。

令牌继承对应用户的权限。标签 backup-job 只是说明用途,不能自动把令牌限制到某个主题;只写 ops-backup 的边界来自前面给 backupbot 设置的 ACL。若多个任务要求不同权限,使用不同用户,不要仅创建多个标签就以为已经隔离。

在 Bash 中发送第一条文字消息,可以临时读取令牌,避免直接把真实值写进示例命令和 shell 历史:

read -r -s -p "ntfy token: " NTFY_TOKEN
printf '\n'
curl --fail --silent --show-error \
  -H "Authorization: Bearer ${NTFY_TOKEN}" \
  -H "Title: Backup job" \
  --data-binary 'Backup command finished. Check the backup log.' \
  https://notify.example.com/ops-backup
unset NTFY_TOKEN

这段示例没有上传数据库或日志附件,只发一条状态消息。不要把完整访问令牌、数据库连接串、客户数据或整段错误日志作为通知内容。即使主题有权限限制,消息也可能显示在手机锁屏上。

长期任务应把凭据放在仅运行用户可读的文件或凭据管理机制中,不提交到 Git。用 curl -H 传值仍可能让本机有权限查看进程的用户观察到参数;有多用户隔离要求的机器,需要再选择符合自己凭据管理规则的传递方式。

在手机上确认自己订阅的是这台服务器

安装 ntfy 客户端后,添加主题时选择自己的服务器地址 https://notify.example.com,主题填写 ops-backup。接收账号使用 reader 和它的密码。不要仍使用默认的公共服务器地址,否则你订阅的是另一台服务器上的同名主题。

ntfy 官方 Android 客户端的主题消息列表示例

图中是官方文档的 Android 历史界面示例(截图日期为 2021 年),展示主题内的通知内容;实际界面随客户端版本变化。

Android 客户端文档说明了即时接收方式:自建服务器不使用公共 ntfy.sh 的 Firebase 通道,需要客户端维持对应的订阅连接。F-Droid 版不包含 Firebase,默认使用即时接收。还要检查系统通知权限、后台运行和省电设置,不能只在打开 App 时看见消息就认定锁屏后也正常。

iPhone 的限制不同。服务端配置文档说明,自建服务器要实现即时通知,需要向上游发送唤醒请求,再由客户端回到自己的服务器拉取原消息。需要这一功能时,可以在 server.yml 追加:

upstream-base-url: "https://ntfy.sh"

然后重启服务:

docker compose restart ntfy

这意味着 iOS 即时通知链路仍依赖上游及系统推送服务,不能称为所有环节都独立。上游收到的是用于唤醒的轮询请求,不是整条原消息正文;对这一依赖有要求时,先阅读官方说明再决定是否开启。不配置上游,消息可能延迟,不能承诺与前台收取相同的到达速度。

浏览器的 Web Push 又是另一项配置,需要推送密钥、持久化订阅信息等条件。本篇没有开启它。能打开网页并浏览通知,不表示关掉网页后仍会收到系统推送。

检查权限时,要检查被拒绝的情况

第一条消息正常到达后,还应确认匿名请求无法写入私人主题:

curl --silent --show-error \
  -o /dev/null -w '%{http_code}\n' \
  --data-binary 'anonymous permission check' \
  https://notify.example.com/ops-backup

这里应返回拒绝访问,而不是成功发布。如果匿名请求也能写入,检查生效的配置文件、默认访问策略以及是否另有匿名授权;不要用改主题名掩盖权限配置问题。

接着分别用发送账号尝试读取、用接收账号尝试写入,确认也被拒绝。检查时使用不包含业务数据的消息;完成后再把真实任务接过来。命令返回成功只能确认服务端接受了请求,手机是否显示、锁屏后是否收到、离线一段时间能否补收,都要在接收端看。

多个机器共用通知服务时,最好每个任务使用独立发送身份。某台机器停用后撤销对应令牌,不必更换所有手机的密码。可以查看与删除令牌:

docker compose exec ntfy ntfy token list backupbot
docker compose exec ntfy ntfy token remove backupbot TOKEN_TO_REMOVE

TOKEN_TO_REMOVE 是占位符,替换为要撤销的实际令牌。不要把完整令牌清单贴到公开排障帖子中。

更新与备份,把权限库一起保存

升级前记录当前镜像标签,并保存 compose.yaml、config/ 和 data/。这套配置使用 SQLite,停服务后归档整个项目目录,可以避免复制过程中数据库仍在写入,也一并保存可能存在的 SQLite 辅助文件。

在 ~/ntfy 中操作,备份文件放在项目目录之外:

docker compose stop ntfy
umask 077
sudo tar -czf - -C "$HOME" ntfy \
  > "$HOME/ntfy-backup-$(date +%Y%m%d-%H%M%S).tar.gz"
docker compose start ntfy

停服务期间无法正常接收新请求,发送端应有自己的失败处理。示例让 tar 有权限读取容器创建的文件,再由当前用户写出归档。这个备份包含认证库和历史消息,需要按敏感数据保护,并保留一份不依赖当前 VPS 的副本。备份打包失败时,先检查错误并让服务恢复,再修正归档问题;不能因为前一个命令失败就忘了服务仍然停着。

恢复时将归档解到独立目录,先使用原来的镜像版本与配置检查账号、授权和数据是否存在。不要直接覆盖运行中的数据库。恢复后的发布、订阅和匿名拒绝都需要重新确认,然后再切换域名或任务入口。

以后更新镜像时,应明确修改版本标签并阅读相应发布记录。数据库格式变化时,仅把镜像标签改回去未必能回滚,旧版镜像应配合更新前的数据副本。通知丢失也不要只盯着服务端:任务有没有执行、请求有没有被接受、客户端有没有保持连接,这三处都有各自的记录可查。