SSD Nodes Learn 🎉 VPS $5.50/月起
指南 Matt Connor作者: Matt Connor

自架 ntfy 伺服器:Docker 部署與推播通知設定教學

透過 Docker Compose 在 VPS 自架 ntfy 伺服器,並啟用 TLS 加密與 ACL 存取控制。本指南教您如何整合 cron 與 systemd OnFailure,將伺服器監控警報推送到手機,確保敏感通知的安全性與隱私。

自架 ntfy 伺服器的功能

自架 ntfy 伺服器能將 HTTP POST 請求轉換為手機上的推播通知。您可以使用 curl 進行發布,訊息隨即會送達 Android 應用程式、iOS 應用程式、瀏覽器分頁,或任何能維持 HTTP 連線的裝置。此過程無需安裝客戶端函式庫,也不需執行訊息代理程式(message broker)。

ntfy 透過「主題」(topic)來定址訊息。主題是 URL 路徑中的名稱,例如 https://ntfy.example.com/alerts,只要有人向該路徑發布訊息,主題便會立即建立。在預設安裝下,任何知道該名稱的人皆可讀取或寫入該主題,因此專案官方文件將主題名稱比喻為密碼。這種模式適用於公開的 ntfy.sh 服務,但對於處理備份失敗通知等敏感資訊的伺服器而言並不安全,因此本指南將在發送第一則訊息前,先行啟用身份驗證機制。

開始前的準備工作

您需要一台執行 Ubuntu 24.04 或 Debian 13 的 VPS,並安裝 Docker Engine 與 Compose 外掛程式,同時準備一個網域名稱與極少的 RAM。請建立一筆指向 ntfy.example.com 的 DNS (網域名稱系統) A 記錄,使其對應到伺服器的公開 IP 位址,並在進行任何後續動作前,先確認該記錄已正確解析。

dig +short ntfy.example.com
sudo ufw allow 80,443/tcp
sudo ufw status

dig 必須輸出您伺服器的 IP。若無法輸出任何內容,憑證簽發將會失敗,因為憑證授權中心 (Certificate Authority) 會從外部驗證該名稱。Port 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.yml
base-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 會以此位址建立附件連結以及網頁應用程式自身的請求;若數值錯誤,網頁應用程式雖然能載入,但執行任何動作時皆會失敗。listen-http: ":2586" 會綁定至容器內的所有介面,這看起來雖不嚴謹,但卻是正確的作法:容器擁有獨立的網路命名空間,若綁定至 127.0.0.1,該連接埠將無法從宿主機存取,Docker 發布的連接埠也將無法連線。auth-default-access: "deny-all" 是整體的安全防線,因為它會拒絕任何未經明確授權的讀寫請求。behind-proxy: true 指示 ntfy 從 X-Forwarded-For 標頭取得客戶端位址,確保速率限制是針對真實訪客進行統計,而非將反向代理視為單一高負載客戶端。

enable-login: true 允許網頁與手機應用程式透過密碼登入。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 -uid -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-stopped
cd /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,因此容器僅能從主機的 loopback 位址存取。若寫成 2586:2586,Docker 會將其自身的防火牆規則插入到您的規則之前,這意味著即使 ufw status 顯示該連接埠已關閉,它仍會從網際網路回應請求。

若 curl 顯示 Connection refused,請閱讀容器日誌。若 /var/lib/ntfy/user.db 出現權限錯誤,代表 user: 行的設定與這些目錄的擁有者不符,導致處理程序無法建立自己的資料庫而退出。VPS 的 Docker Compose 基礎指南 對於 Volume 擁有權與重啟策略有更詳細的說明。

使用 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 確認具體原因。

若您已在使用 nginx,請複製 ntfy 文件中建議的代理設定:proxy_http_version 1.1proxy_buffering offproxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for,並將讀取與傳送逾時時間設定為至少 3 分鐘。訂閱者在監聽時會保持一個 HTTP 連線開啟,而 nginx 預設會在 60 秒後關閉閒置的上游連線,這會導致訂閱者不斷重新連線,進而遺失連線間隙中傳送的訊息。

建立使用者並鎖定主題權限

驗證機制已啟用,目前尚未開放任何存取權限,這正是預期的狀態。請為您自己建立一個管理員帳號,並為腳本建立一個機器帳號。這些指令會從容器內部讀取 /etc/ntfy/server.yml,這也是為何設定檔必須透過 volume mount 掛載的原因。

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 access

ACL(存取控制清單)條目由使用者、主題與權限組成。主題可以是具體名稱,也可以是模式,其中 * 可匹配任何內容,因此 alerts_* 可涵蓋 alerts_backupalerts_db,無須為每個主機個別執行指令。權限 write 代表僅能發布(publish),即使 cron job 的 token 被竊,攻擊者也無法訂閱並讀取已發送的內容。特殊使用者名稱 everyone 用於設定未經身分驗證的訪客權限,僅在您刻意開放公開主題(如 ntfy access everyone status read)時才使用。

腳本應使用 token 而非您的密碼。

sudo docker compose exec ntfy ntfy token add robot

該指令會輸出一個以 tk_ 開頭的 token。Token 會完全繼承所屬使用者的存取權限,因此此 token 僅能發布至 alerts 主題,無法執行其他操作。ntfy token list 可列出現有的 token,ntfy token remove 則可在不更動使用者密碼的情況下撤銷特定 token。

發送第一則訊息並驗證鎖定功能是否運作

首先檢查門是否已關閉。

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,或透過名稱從 minurgent,它決定了手機是否會發出提示音。當名稱符合已知的 emoji 簡碼時,Tags 會在通知中顯示為 emoji,若不符合則維持純文字。

若要從終端機監控主題,請串流該主題:

curl -s -u admin https://ntfy.example.com/alerts/raw

curl 會提示輸入密碼。每則訊息會以單行顯示,偶爾出現的空白行則是連線存活訊號(keepalives)。在瀏覽器中開啟 https://ntfy.example.com 並使用相同帳號登入,即可使用該串流的網頁應用程式版本。

設定速率限制以防止單一指令碼癱瘓伺服器

預設情況下,每位訪客擁有 60 次請求的配額,並以每 5 秒補充 1 次請求的速度恢復。對於私人伺服器而言,此設定已相當寬鬆,但若指令碼陷入重試迴圈,將會耗盡所有配額。請將限制設定加入 server.yml

visitor-request-limit-burst: 30
visitor-request-limit-replenish: "10s"
visitor-message-daily-limit: 500
sudo docker compose restart ntfy

超過限制的訪客將收到 HTTP 429 錯誤,而非預期的訊息。此限制是依據訪客位址進行計算,這正是 behind-proxy: true 至關重要的原因:若未設定此項,ntfy 將僅能識別 Caddy 的位址,導致所有客戶端被視為同一位址,進而使單一異常指令碼耗盡您手機與其他伺服器共同使用的配額。

來自失敗 cron job 的警報

請勿將 token 放在命令列中。ps aux 會向系統上的所有使用者顯示每個執行中處理程序的完整命令列,因此透過 -H 傳遞的 token 在 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 job 為何無法執行的指南 涵蓋了這些環境陷阱。請務必在所有地方使用絕對路徑,並在第一次排程執行後讀取日誌檔案,而非憑空假設。

當 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/false
sudo 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 次重啟時,它才會被視為失敗。請為任何您希望收到通知的服務設定這兩個值,否則崩潰迴圈可能會在背景持續運作數日。Timer 是上述 cron 模式更乾淨的替代方案,因為 Timer 的服務單元會自動獲得 OnFailure=,而 VPS 上的 systemd 服務與 Timer 指南 說明了如何進行轉換。

將正常運行時間監控整合至相同主題

Uptime Kuma,自架狀態監控工具 內建 ntfy 通知類型。請開啟 Settings,進入 Notifications,點選 Setup Notification,選擇 Ntfy,將伺服器 URL 設定為 https://ntfy.example.com,主題設定為 alerts,選擇優先級,並貼上 robot 存取權杖。儲存前請先發送測試通知,因為若主題名稱錯誤,且 write 授權未涵蓋該主題,系統將會靜默失敗。

此架構的實際限制如下:運行於同一台 VPS 的監控器無法在該 VPS 離線時發出通知,且 ntfy 無法在自身服務中斷時傳遞訊息。請將監控器部署於不同機器,並為監控 ntfy 本身的監控器設定第二種通知管道(例如電子郵件)。Uptime Kuma 的 Push 監控類型可彌補另一個盲點:您的 cron job 在成功執行後會呼叫一個 push URL,當這些呼叫停止時,Kuma 便會發出警報。失敗分支僅在工作執行時觸發,因此無法偵測到未啟動的工作。

自架 ntfy 是否支援 Android 與 iPhone?

在 Android 上,完全支援。請從 Google Play 或 F-Droid 安裝應用程式,開啟設定,將預設伺服器設為 https://ntfy.example.com,在使用者管理畫面新增帳號,接著訂閱 alerts。即時傳遞功能會維持一個前景服務運作,確保手機即使處於休眠模式也能收到訊息;隨之出現的永久通知是 Android 對前景服務的要求,並非程式錯誤。F-Droid 版本完全不含 Firebase 程式碼,因此所有訂閱皆使用即時傳遞。ntfy 亦可作為 UnifiedPush 分發器,這是 Google 推播服務的開放替代方案,讓其他支援 UnifiedPush 的應用程式也能透過您的伺服器進行傳遞。

在 iOS 上,運作時有一項無法移除的依賴條件。Apple 僅透過 APNs (Apple push notification service) 喚醒背景應用程式,且只有持有該應用程式簽署憑證的一方才能發送通知,因此您的伺服器無法直接連線至應用程式。ntfy 透過中繼機制解決此問題:您的伺服器會發送一個包含訊息 ID 的 poll_request 給 ntfy.sh,該服務再透過 Firebase 與 APNs 喚醒應用程式,隨後應用程式會從您的伺服器擷取訊息內容。

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

請務必釐清其代價。訊息內容會保留在您的伺服器上,但「訊息已送達」的事實及其 ID 會經過非您維運的基礎設施。若未啟用此設定,自架伺服器發送至 iPhone 的通知將會延遲或無法送達,因為沒有任何機制能喚醒應用程式。若要移除此中繼機制,唯一的方法是使用您個人的 Apple 開發者帳號與 APNs 金鑰自行建置並發布 iOS 應用程式,這意味著每年需支付費用,且每次更新皆須重新建置。若您無法接受此中繼機制,建議將警報功能保留在 Android 或桌面版網頁應用程式上。

備份、升級與鎖定映像檔

有兩個路徑無法重新產生:/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 檔案中的標籤並執行 pull。

sudo docker compose pull
sudo docker compose up -d
curl -s https://ntfy.example.com/v1/health

請務必先閱讀發行說明。SQLite 資料庫會在啟動時進行遷移,因此在結構變更後,降級至舊版標籤並不安全。在新版本穩定執行一天之前,請務必保留剛才建立的備份。

Gotify 與 Apprise

Gotify 是較輕量的選擇:它是一個包含網頁介面與 Android 應用程式的單一執行檔,不支援主題萬用字元(topic wildcards),也沒有官方的 iOS 客戶端,適合僅以 Android 為目標的私人伺服器。Apprise 則是一個 Python 函式庫與命令列工具,而非伺服器軟體;它能將單一訊息同時發送至超過一百種服務(包含 ntfy),適合需要同時通知多個管道的指令碼。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 push notification service) 喚醒應用程式,且僅有該 App 的發布者能向其發送通知,因此 ntfy 會將包含訊息 ID 的 poll_request 轉發至 ntfy.sh,再由該處中繼至裝置。請在 server.yml 中設定 upstream-base-url: "https://ntfy.sh" 並重新啟動容器。訊息內容本身仍會從您的伺服器擷取。若未進行此設定,iOS 通知將會延遲或無法顯示。

為什麼我的 cron job 發出的 ntfy 警報從未送達?

請先單獨執行該 curl 指令,以驗證權杖與主題是否正確。若手動執行正常但 cron 無法運作,問題出在警報觸發之前:cron 執行任務時環境變數極少且 PATH 較短,因此若指令僅使用名稱而非完整路徑,腳本可能在執行到 curl 之前就已終止。請使用絕對路徑,將任務輸出重新導向至日誌檔案,並在下次執行後檢查該檔案。若收到 429 回應而非成功送達,代表速率限制已觸發,且您的腳本重試頻率過高。

我應該將 ntfy 暴露在公用網際網路上嗎?

手機 App 需要從行動網路連線至伺服器,因此標準做法是建立一個具備 auth-default-access: "deny-all" 與主題層級 ACL 的公開 HTTPS 端點;只要沒有任何主題允許 everyone 讀取,此設定即為安全。若所有訂閱者皆為您管理的機器,則僅限 VPN 存取的執行個體是合理的。但此方式不適合手機,因為 App 僅在通道連線時才能接收訊息,導致警報會堆積直到手機重新連線為止。