希曼日记

自建 ntfy:Docker 服务端部署与手机、桌面客户端接入

约 12 分钟4,414 字

ntfy 可以把一次 HTTP 请求变成手机或桌面上的通知。脚本完成、服务器告警、博客催更,都可以通过同一套服务发布消息,客户端按主题订阅。

自建之后,最容易出错的不是启动容器,而是把几组配置混在一起:手机应该填写公网服务地址,不能填写 Docker 服务名;订阅账号需要读取权限,不能直接使用告警程序的只写 Token;消息已经进入 ntfy,也不等于手机后台已经弹出了通知。

本文给出一套独立的安装与接入流程。服务端以本站实际接入时使用的 v2.11.0 为版本基线,不把它当作最新版本推荐;新部署应检查官方发布说明,选择并固定经过验证的版本。客户端说明参考官方文档,菜单名称可能随版本变化。域名、主题和路径均为示例,不包含实际部署凭据。

先看清服务端、发布端和订阅端

Text
Alertmanager → 告警转发脚本 ─┐
├─ HTTP 发布 → ntfy 服务端 → Android / iOS / Web
博客催更接口 ────────────────┘ ↑
账号与主题权限

ntfy 服务端负责接收、缓存和分发消息。告警脚本、博客接口是发布端,手机应用和网页是订阅端。发布端不一定需要读取权限,订阅端也不一定需要发布权限。

本文统一使用以下示例,部署时需要整体替换,不能只改其中一处:

项目示例值用途
公网服务地址https://ntfy.example.com手机、桌面和跨主机发布程序访问
主题blog-updates发布端与订阅端必须完全一致
发布账号blog-reminder对目标主题只写
订阅账号notify-reader对目标主题只读
内网发布地址http://ntfy/blog-updates仅供处于同一 Docker 网络的程序使用

一条订阅由服务地址和主题共同确定。 https://ntfy.sh/blog-updates 与 https://ntfy.example.com/blog-updates 是两条不同的通知流。

ntfy 不需要先执行「创建主题」命令。这里真正需要提前配置的是账号及其主题权限;在默认拒绝访问的实例上,知道主题名并不代表可以读写。

用 Docker 部署服务端

准备目录和配置文件

以下示例假设 Linux 主机已安装 Docker Engine 和 Compose 插件,并准备由同一宿主机上的 Nginx 终结 TLS。已有 ntfy 实例应先备份配置和数据,不要直接覆盖。

Shell
sudo mkdir -p /srv/ntfy/data/cache /srv/ntfy/data/lib

在 /srv/ntfy/server.yml 中写入:

YAML
base-url: "https://ntfy.example.com"
listen-http: ":80"
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
# 使用官方 iOS App 的后台即时通知时需要,相关隐私边界见后文。
upstream-base-url: "https://ntfy.sh"

这份配置没有开放附件上传,先把纯文本通知跑通。需要附件时再配置单独的附件目录、大小和保留时限,不要把其他文件放进 ntfy 管理的附件目录。

三个关键点:

  • base-url 填客户端实际访问的公网 HTTPS 地址,不填容器名或带主题的地址。
  • auth-default-access: deny-all 配合 auth-file,默认拒绝未授权读写;enable-login 只是启用 Web 登录入口,不能单独代替访问控制。
  • cache-file 让缓存落盘。只设缓存时长、不设持久化文件,不能保证消息缓存跨重启保留。

cache-duration: 12h 是消息在服务端的保留时间,与博客按 IP 12 小时去重无关。修改这里的缓存时长,不会改变催更服务的计数窗口。

配置 Compose 并启动

在 /srv/ntfy/compose.yml 中写入:

YAML
name: ntfy
services:
ntfy:
image: binwiederhier/ntfy:v2.11.0
command: serve
restart: unless-stopped
ports:
- "127.0.0.1:8080:80"
volumes:
- ./server.yml:/etc/ntfy/server.yml:ro
- ./data/cache:/var/cache/ntfy
- ./data/lib:/var/lib/ntfy

server.yml 必须事先存在且是文件,否则绑定挂载可能产生错误的目录。数据目录需要允许容器实际运行用户写入;如果自行指定非 root UID,应同步处理宿主目录权限,不要通过 chmod 777 绕过问题。

启动并查看状态:

Shell
docker compose -f /srv/ntfy/compose.yml config --quiet
docker compose -f /srv/ntfy/compose.yml up -d
docker compose -f /srv/ntfy/compose.yml ps
docker compose -f /srv/ntfy/compose.yml logs --tail 50 ntfy

然后在宿主机检查健康接口:

Shell
curl --fail-with-body -sS http://127.0.0.1:8080/v1/health

监听在回环地址,可以避免绕过反向代理从公网直接访问明文端口。如果 Nginx 或 Traefik 也运行在容器里,不能让它连接自己的 127.0.0.1:8080。 应改为共享受控 Docker 网络,通过 http://ntfy:80 转发,并根据实际拓扑决定是否还需要宿主端口映射。

本站现有环境通过容器网络和公网网关转发,原理相同,但不是本文的宿主机 Nginx 示例拓扑。

配置公网 HTTPS 入口

前置条件是域名已经解析到入口地址,证书覆盖 ntfy.example.com,并提供设备信任的完整证书链。以下示例假设证书已存在;证书签发和续期应交给现有自动化流程维护。

Web 客户端可能使用 WebSocket,订阅也会使用长连接。代理必须保留这些行为,不能只验证首页能返回 HTML。

Nginx
# map 放在 http 上下文中,不要放进 server。
map $http_upgrade $ntfy_connection {
default upgrade;
'' close;
}
server {
listen 80;
server_name ntfy.example.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
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:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $ntfy_connection;
proxy_buffering off;
proxy_read_timeout 1h;
}
}

这是公网直接进入此 Nginx 的单跳示例。若前面还有 CDN 或其他网关,应先配置可信代理来源,不能照抄后就宣称拿到了真实客户端 IP。behind-proxy: true 依赖可信入口提供正确的转发头,不应同时允许任意客户端绕过入口直连后端。

通过语法检查后再重载:

Shell
sudo nginx -t
sudo systemctl reload nginx
curl --fail-with-body -sS https://ntfy.example.com/v1/health

不要为了原生客户端接入,在 ntfy API 前再强制跳转到一个需要浏览器交互的统一登录页面。机器程序和手机 App 需要得到 ntfy 的 API 响应,而不是登录页 HTML。确需网关认证时,必须验证客户端兼容性,同时保留 ntfy 自己的主题访问控制。

分开创建发布账号和订阅账号

以下命令在 ntfy 所在服务器执行。创建用户时交互输入随机密码,不把明文密码写在命令行参数里:

Shell
docker compose -f /srv/ntfy/compose.yml exec ntfy ntfy user add blog-reminder
docker compose -f /srv/ntfy/compose.yml exec ntfy ntfy user add notify-reader

再授予最小权限:

Shell
docker compose -f /srv/ntfy/compose.yml exec ntfy \
ntfy access blog-reminder blog-updates write-only
docker compose -f /srv/ntfy/compose.yml exec ntfy \
ntfy access notify-reader blog-updates read-only
docker compose -f /srv/ntfy/compose.yml exec ntfy ntfy access

预期结果是:发布账号只能写这个主题,订阅账号只能读这个主题,匿名用户仍无权访问。若已有账号或主题,应先查看现有 ACL,再增加所需权限,不要为了接一个客户端重置全局权限。

下一条命令会输出完整 Token。 在受控终端执行,将结果写入发布程序的受限配置文件,不要放进文章、截图或公共日志:

Shell
docker compose -f /srv/ntfy/compose.yml exec ntfy \
ntfy token add --label=blog-reminder blog-reminder

Token 继承所属账号的权限,不是创建一个不同标签就自动变成不同权限。要隔离发布和订阅,应使用不同账号及 ACL,而不是把管理员 Token 分发给每台设备。

手机端优先使用 notify-reader 的用户名和密码。只有客户端明确提供 Token 或自定义认证入口时,才使用订阅账号对应的 Token;不要把只写的 blog-reminder Token 当作订阅凭据,也不要假设把 Bearer Token 直接填进密码框总能工作。

客户端连接参数

手机或桌面客户端需要的是这组配置:

客户端字段填写内容
服务器、基础 URL 或自定义服务器https://ntfy.example.com
主题、Topicblog-updates
用户名notify-reader
密码创建 notify-reader 时设置的密码
通知权限允许,并检查主题是否静音

如果客户端只提供一个完整订阅 URL 字段,则填写 https://ntfy.example.com/blog-updates。如果服务地址和主题分成两个字段,就按表格分别填写,不要把主题重复拼接。

不应填写的值包括:http://ntfy、Docker 私网 IP、博客域名、/api/remind,以及服务端 iOS 配置中的 https://ntfy.sh。后者是唤醒上游,不是手机要改订阅的服务器。

账号也属于特定服务器。即使在 ntfy.sh 注册了同名用户,它也不能代替自建实例中的 notify-reader。

Android 安装与订阅

官方 Android 应用可从 Google Play、F-Droid 或 官方 APK 发布页 安装。不要使用来源不明的修改包。

  1. 安装 ntfy,允许系统通知权限。
  2. 添加订阅,选择其他服务器或自定义服务器,填写公网基础 URL 和主题。
  3. 在该服务器的认证提示或用户设置中添加 notify-reader 及密码。账号必须关联到同一个服务器地址。
  4. 打开主题,先确认没有连接或授权错误,再让发布端发送一条测试消息。
  5. 熄屏并等待设备进入后台状态后再测试,检查是否仍能收到通知。

自建服务器不能直接套用 ntfy.sh 的 Firebase 投递行为。官方文档说明,Android 自建订阅使用即时投递机制;F-Droid 版本不包含 Firebase,所有订阅默认使用即时投递,因此不一定显示同名开关。

若前台正常、熄屏延迟,应检查系统电池优化、后台运行限制和厂商自启动设置,而不是先去开放匿名权限。保留前台订阅服务可能带来常驻通知,这是维持后台连接的一部分。

iPhone / iPad 安装与订阅

从 App Store 的官方 ntfy 页面 安装,允许通知,然后添加自建服务器订阅。基础 URL、主题和只读账号与前面的参数表相同;认证入口可能位于新增订阅流程或设置中的用户配置,具体以客户端版本为准。

iOS 需要额外检查服务端配置:

YAML
base-url: "https://ntfy.example.com"
upstream-base-url: "https://ntfy.sh"

官方 iOS App 的后台即时通知需要通过上游唤醒,再从自建服务器取回消息。公网 base-url 必须与客户端订阅地址一致,手机也必须能访问该地址;配置一个只在家中局域网可用的名字,离开局域网后并不会自动可达。

按官方说明,上游请求携带原消息 ID 和主题完整 URL 的 SHA-256 校验值,不转发实际消息正文。它仍然依赖外部上游以及 Apple 的推送链路,不能描述成完全离线、完全无第三方依赖的自托管推送。

因此验收应分两次:先确认 App 打开时能读取,再退到后台或锁屏发送新消息。前台能看到历史记录,不代表后台唤醒链路已经配置正确。

桌面 Web 与 PWA

桌面端不必额外安装一个原生客户端,可以直接打开自建实例的 Web 页面:

Text
https://ntfy.example.com/app

登录 notify-reader,订阅 blog-updates,并允许浏览器和操作系统的通知权限。若浏览器支持,可以通过安装应用或添加到主屏幕,把它作为 PWA 使用。

普通网页订阅与后台推送要分开理解:未启用后台通知时,需要保留活动的 ntfy 页面;关闭标签页后的通知依赖 Web Push,不是添加到桌面就自动完成服务端配置。

如需 Web Push,可在受控终端生成 VAPID 密钥。输出中的私钥属于敏感配置,不要提交到仓库:

Shell
docker compose -f /srv/ntfy/compose.yml exec ntfy ntfy webpush keys

把生成的值合并到 server.yml,以下均为占位符:

YAML
web-push-public-key: "<GENERATED_PUBLIC_KEY>"
web-push-private-key: "<GENERATED_PRIVATE_KEY>"
web-push-file: "/var/lib/ntfy/webpush.db"
web-push-email-address: "admin@example.com"

配置后重启 ntfy,并在自建实例的 Web 应用里启用后台通知。保存好私钥和 webpush.db,不要在每次重建容器时重新生成密钥。此时 server.yml 也包含私钥,应限制宿主文件权限,同时确保容器能读取。

后台通知仅支持 Web 应用所在的同一服务器,所以应从自建域名打开 Web 应用,而不是在 ntfy.sh 网页里配置另一台服务器后就期待后台投递。浏览器完全退出后能否通知还取决于平台;iOS 的 PWA 另有系统版本和添加到主屏幕的要求,不能与原生 App 的上游配置混为一谈。

先验证读权限,再发布一条测试通知

客户端安装后,先用命令行区分认证失败和推送失败。以下 curl 命令仅提供用户名,curl 会提示输入密码,避免把明文密码写入命令历史。

读取缓存中的消息,空结果也可能只是主题尚无消息:

Shell
curl --fail-with-body -sS --user notify-reader \
'https://ntfy.example.com/blog-updates/json?poll=1'

也可以保持一个实时订阅,按 Ctrl+C 结束:

Shell
curl --fail-with-body -N --user notify-reader \
'https://ntfy.example.com/blog-updates/json'

下一条命令会向真实主题发布消息,应使用专门的测试主题或明确标注测试内容:

Shell
curl --fail-with-body -sS --user blog-reminder \
-H 'Content-Type: text/plain; charset=utf-8' \
-H 'Title: ntfy connection test' \
-H 'Priority: 3' \
--data-binary 'ntfy 联调测试:确认手机和桌面都能收到。' \
'https://ntfy.example.com/blog-updates'

发布接口返回成功,只能证明 ntfy 接受了请求。完整验收还要观察订阅连接是否收到事件、客户端是否显示消息、后台是否弹出通知。

同时检查最小权限是否有效:匿名读取不应成功,notify-reader 发布不应成功,blog-reminder 读取也不应成功。不要使用管理员账号测试后就认定普通客户端的 ACL 正确。

参考 Alertmanager 转发脚本时,注意 URL 的含义

参考部署仓库中的 alertmanager-ntfy 目录包含:

  • pigsty-alert-ntfy.py:接收 Alertmanager 请求并发布到 ntfy,同时包含可选 Bugsink 转发入口。
  • pigsty-alert-audit.service:以 prometheus 用户运行脚本,从 /etc/infra-alerts/ntfy.env 加载配置。

这不是 ntfy 服务端,它是一个发布端适配器。Alertmanager 的 webhook 发往适配器,适配器整理消息,再带 Bearer Token 调用 ntfy。

脚本的发布配置拆成三个变量:

dotEnv
NTFY_URL=https://ntfy.example.com
NTFY_TOPIC=blog-updates
NTFY_TOKEN=<PUBLISHER_TOKEN>

脚本内部将地址拼接为 NTFY_URL/NTFY_TOPIC。相比之下,博客催更服务的配置是:

dotEnv
NTFY_URL=http://ntfy/blog-updates
NTFY_TOKEN=<BLOG_REMINDER_TOKEN>

同名环境变量在两个程序里含义不同。 把催更服务的完整主题 URL 直接填进转发脚本,会重复拼接主题;把转发脚本的基础 URL 直接填进催更服务,则缺少目标主题。

转发脚本会把 Alertmanager 的 commonLabels、commonAnnotations 和告警数量整理成正文:恢复消息优先级为 3,crit 或 critical 告警为 5,其他未恢复告警为 4。标题经过编码后放入 HTTP 头,正文按 UTF-8 发送,网络超时为 10 秒。这些是参考脚本的规则,不是 ntfy 自动提供的业务语义。

Alertmanager 接收端绑定在 127.0.0.1:9099,路径是 /alerts,没有单独的请求认证,安全前提是只允许可信本机调用。Alertmanager 如果运行在另一个容器里,127.0.0.1 指向的是它自己的容器,需要重新设计可达路径和访问控制,不能只把监听地址改成 0.0.0.0 后开放公网。

参考脚本没有博客的 IP 12 小时去重逻辑。ntfy 发布失败时,适配器会返回 502;Alertmanager 后续重试是否造成重复,应按告警链路单独设计,不能因为两者使用同一个 ntfy 就认为去重行为相同。

手机只需订阅最终的 ntfy 服务地址和主题,不需要配置 Alertmanager 的 /alerts,也不需要知道脚本的监听端口。真实运维中可以让告警与催更使用不同主题和独立发布账号,避免紧急告警被普通消息淹没。

如何接收本站催更通知

博客按钮到 ntfy 的实现见 给静态博客加一个催更按钮:ntfy 接入、IP 去重与 Docker 部署。

已经有 ntfy 服务时,客户端只需要完成三件事:

  1. 填写该服务对外的 HTTPS 基础 URL,不使用发布程序的 Docker 内网地址。
  2. 订阅发布程序实际使用的主题:催更程序取完整 NTFY_URL 中的主题,Alertmanager 转发脚本取 NTFY_TOPIC。
  3. 使用对该主题有 read-only 权限的账号登录,允许通知,并做一次前台及后台测试。

本站催更沿用了已有通知主题,示例中的 blog-updates 不是对真实主题名的披露。已有订阅账号若能读取实际主题,就不需要为催更重复建账号或另订一个示例主题。

常见故障按层排查

现象优先检查
无法连接域名解析、公网端口、TLS 证书链、客户端是否误填 Docker 名称
返回登录页 HTML入口网关是否把 ntfy API 重定向到交互式登录页面
401 / 403账号属于哪台服务器、密码或 Token 是否有效、是否拥有目标主题读写权限
只写 Token 能发布但无法订阅这是预期 ACL 行为,改用只读订阅账号,不要扩大发布账号权限
Web 页面可开但订阅断开WebSocket 转发、代理缓冲、长连接超时
Android 熄屏后延迟即时投递、前台订阅服务、电池优化及系统后台限制
iOS 前台正常、后台无通知base-url 是否匹配、upstream-base-url、上游可达性、系统通知设置
关闭网页后不通知Web Push 是否配置,是否从自建实例打开 Web 应用,以及浏览器平台限制
重启后用户或缓存丢失auth-file、cache-file 所在目录是否真的持久化
数据库写入失败挂载目录权限、磁盘剩余空间,是否错误地挂成只读

维护时应备份配置及认证数据,对正在使用的 SQLite 采用一致性备份方式;不要只复制活动数据库的主文件而忽略 WAL。升级前保留旧镜像版本和数据备份,不要无条件跟随 latest,也不要把清空数据目录当成排障第一步。

本文核对了参考转发脚本和官方文档,没有重新部署该目录中的服务,也没有在实体 Android 或 iOS 设备上逐项实测菜单。最终验收应以目标设备在实际网络中的收取结果为准。

参考资料