ntfyをVPSでセルフホストして通知を送る方法
Docker ComposeでntfyをVPSに構築し、TLSで安全に公開する手順です。ユーザーとACLでtopicを保護し、cronやsystemdのOnFailureから障害通知を送信します。
セルフホストした ntfy サーバーの役割
セルフホストした ntfy サーバーは、HTTP POST をスマートフォンへのプッシュ通知に変換します。curlでメッセージを公開すると、Android アプリ、iOS アプリ、ブラウザーのタブ、または HTTP 接続を開いたままにできるその他のクライアントにメッセージが届きます。クライアントライブラリをインストールする必要も、メッセージブローカーを実行する必要もありません。
ntfy は topic によってメッセージを識別します。topic は https://ntfy.example.com/alerts のように URL パスに含める名前で、誰かがそこに公開した時点で作成されます。デフォルトのインストールでは、その名前を知っている人なら誰でも topic の読み取りと書き込みができます。そのため、プロジェクトの公式ドキュメントでは topic 名をパスワードにたとえています。このモデルは公開 ntfy.sh サービスには適しています。しかし、バックアップの失敗通知を扱うサーバーには適していません。そこで、このガイドでは最初のメッセージを送信する前に認証を有効にします。
開始前に必要なもの
Ubuntu 24.04 または Debian 13 を実行し、Docker Engine と Compose plugin を導入した VPS が必要です。ドメイン名と、少量の RAM も必要です。ドメインネームシステム(DNS)の A レコードで、ntfy.example.com をサーバーのパブリック IP アドレスに向けます。その後、ほかの作業を始める前に名前解決を確認します。
dig +short ntfy.example.com
sudo ufw allow 80,443/tcp
sudo ufw statusdig の出力には、サーバーの IP が表示される必要があります。何も表示されない場合、証明書の発行に失敗します。認証局は外部から名前を確認するためです。Let's Encrypt の基盤である ACME(自動証明書管理環境)プロトコルは、HTTP チャレンジにポート 80 を使用します。そのため、ポート 80 は開いたままにします。ntfy コンテナ自体がパブリックポートを取得することはありません。
ntfy の設定ファイルを作成する
Docker イメージには設定ファイルが含まれていないため、作成します。このガイドの後続の各コマンドは、このファイルを読み込みます。まず、コンテナが実行される user ID と group 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.ymlbase-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このうち、特に重要なのは 4 行です。base-url には、公開 HTTPS アドレスを正確に指定する必要があります。ntfy はこの値から添付ファイルのリンクと Web アプリ自身のリクエスト先を生成するため、値が間違っていると Web アプリは読み込めても、すべての操作に失敗します。listen-http: ":2586" はコンテナ内のすべてのインターフェースにバインドします。一見無頓着に見えますが、これが正しい設定です。コンテナには独自の network namespace があるため、そこで 127.0.0.1 にバインドすると、ホストからポートに到達できず、Docker が公開したポートから接続できません。auth-default-access: "deny-all" はセキュリティ設定の中核です。明示的な許可がないユーザーには、読み取りも書き込みも拒否します。behind-proxy: true は、X-Forwarded-For ヘッダーからクライアントアドレスを取得するよう ntfy に指示します。これにより、レート制限は reverse proxy を 1 つの非常に活発なクライアントとして数えるのではなく、実際の訪問者単位で適用されます。
enable-login: true により、Web アプリとスマートフォンアプリはパスワードでサインインできます。enable-signup は false のままにします。プライベートサーバーで利用者自身によるアカウント作成を許可すると、手順が増えただけの無制限な入口になるためです。
sudo chown "$(id -u):$(id -g)" /etc/ntfy/server.yml
sudo chmod 600 /etc/ntfy/server.ymlDocker Compose で ntfy を実行する
1000:1000をid -uとid -gという、上に表示された 2 つの数値に置き換えて、/opt/ntfy/compose.yamlに記述します。
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-stoppedcd /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 ファイルには、意図的に指定している点が 2 つあります。イメージはlatestではなく、2026 年 8 月時点の現行リリースであるv2.27.0に固定しています。latestを使用すると、次のdocker compose pullによってサーバーのバージョンが変更され、変更履歴を後から確認して初めて気付くためです。ポートは127.0.0.1:2586:2586として公開しているため、コンテナに接続できるのはホストのループバックアドレスからだけです。代わりに2586:2586と記述すると、Docker は独自のファイアウォールルールをユーザーのルールより先に挿入します。その結果、ufw statusではポートが閉じていると表示されても、インターネットからポートに応答できてしまいます。
curl がConnection refusedを出力した場合は、コンテナのログを確認します。/var/lib/ntfy/user.dbの権限エラーは、user:行の指定がそれらのディレクトリの所有者と一致していないことを意味します。そのため、プロセスは自身のデータベースを作成できず終了します。VPS 向け Docker Compose の基本ガイドでは、ボリュームの所有権と再起動ポリシーについて詳しく説明しています。
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 の内容を3行に置き換えます。
ntfy.example.com {
reverse_proxy 127.0.0.1:2586
}sudo systemctl reload caddy
curl -s https://ntfy.example.com/v1/health同じ {"healthy":true} を HTTPS 経由で実行し、経路全体が機能することを確認します。Caddy から 502 が返る場合、ntfy は待ち受けていません。sudo ss -lntp | grep 2586 で確認してください。証明書エラーは通常、DNS レコードが正しくないか、ポート 80 がブロックされていることを示します。sudo journalctl -u caddy -n 50 にどちらが原因かが示されます。
すでに nginx を運用している場合は、ntfy のドキュメントにあるプロキシ設定を適用します。proxy_http_version 1.1、proxy_buffering off、proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for、および少なくとも3分の読み取りタイムアウトと送信タイムアウトを設定してください。サブスクライバーは、待ち受けている間、1つの HTTP 接続を開いたままにします。一方、nginx はデフォルトでアイドル状態の upstream 接続を60秒後に閉じます。そのため、サブスクライバーはループ状に再接続し、その間に送信されたメッセージを取りこぼします。
ユーザーを作成し、トピックへのアクセスを制限する
認証は有効になっていますが、まだ誰も何にもアクセスできません。これが意図した状態です。自分用の管理者アカウントを1つ、スクリプト用のマシンアカウントを1つ作成します。これらのコマンドはコンテナ内の /etc/ntfy/server.yml を読み取ります。設定ファイルがボリュームマウントされているためです。
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 accessACL(アクセス制御リスト)のエントリは、ユーザー、トピック、権限で構成されます。トピックにはリテラル名またはパターンを指定できます。* は任意の文字列に一致するため、alerts_* で alerts_backup と alerts_db を対象にできます。ホストごとにコマンドを実行する必要はありません。write は公開のみを意味します。そのため、cron ジョブからトークンが盗まれても、そのトークンで送信内容を購読して読み返すことはできません。特殊なユーザー名 everyone では、認証されていない訪問者に許可する操作を設定します。意図的に公開する場合だけ使用してください。たとえば ntfy access everyone status read などが該当します。
スクリプトにはパスワードではなくトークンを持たせてください。
sudo docker compose exec ntfy ntfy token add robotこのコマンドは tk_ で始まるトークンを出力します。トークンには、そのトークンが属するユーザーとまったく同じアクセス権が適用されます。そのため、このトークンは alerts トピックに公開できるだけで、その他の操作はできません。ntfy token list では登録済みの内容を確認でき、ntfy token remove ではユーザーのパスワードに影響を与えずに1つのトークンを無効化できます。
最初のメッセージを送信し、ロックが機能することを確認する
まず、扉が閉まっていることを確認します。
curl -s -o /dev/null -w '%{http_code}\n' -d "hello" https://ntfy.example.com/alerts403 と表示されます。403 が正しい回答です。auth-default-access: "deny-all" は匿名の publish を拒否します。次に、実際のメッセージを送信します。
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 の値を取り、min から urgent までの名前でも指定できます。これによって、スマートフォンで通知音を鳴らすかどうかが決まります。名前が既知の絵文字の short code と一致すると、Tags は通知上で絵文字に変換されます。一致しない場合はプレーンテキストのままです。
端末から topic を監視するには、ストリームとして取得します。
curl -s -u admin https://ntfy.example.com/alerts/rawcurl はパスワードの入力を求めます。各メッセージは 1 行で届き、ときどき現れる空行は keepalive です。ブラウザーで https://ntfy.example.com を開き、同じアカウントでサインインすると、同じストリームを Web アプリ版で利用できます。
制限を設定して、1 つのスクリプトがサーバーに大量のリクエストを送信できないようにする
デフォルトでは、各訪問者に 60 リクエスト分のバケットが割り当てられ、5 秒に 1 リクエストの割合で補充されます。プライベートサーバーには十分余裕のある設定ですが、リトライループに陥ったスクリプトはこの枠を使い切ります。server.yml に制限を追加します。
visitor-request-limit-burst: 30
visitor-request-limit-replenish: "10s"
visitor-message-daily-limit: 500sudo docker compose restart ntfy制限を超えた訪問者には、メッセージを配信する代わりに HTTP 429 を返します。この制限は訪問者のアドレスごとにカウントされます。そのため behind-proxy: true が非常に重要です。これがないと ntfy から見えるのは Caddy のアドレスだけになり、すべてのクライアントが同じ訪問者として扱われます。その結果、1 つの負荷の高いスクリプトが、スマートフォンやほかのサーバーと共有しているバケットを使い切ります。
cron ジョブが失敗したときのアラート
トークンをコマンドラインに含めないでください。ps aux は実行中のすべてのプロセスの完全なコマンドラインを同じサーバー上の全ユーザーに表示するため、-H で渡したトークンは、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 にして 1 回実行し、全体をテストしてください。
実行されない失敗分岐は、アラートがない場合よりも悪い結果を招きます。無音が成功を意味すると誤解されるためです。cron はジョブにほとんど空の環境と、ログインシェルよりも大幅に短い PATH を提供します。そのため、手動実行では動作するスクリプトでも、curl の行に到達する前に終了することがあります。cron ジョブが実行されない理由に関するガイド では、このような環境上の問題を説明しています。すべての場所で絶対パスを使用し、最初のスケジュール実行後にログファイルを確認してください。思い込みで判断しないでください。
systemd ユニットの失敗時に通知する
Cron はスケジュールされた処理を担当します。長時間実行するサービスには OnFailure= が必要です。systemd は、ユニットが failed 状態になるたびにこれを実行します。テンプレートユニットを1つ作成し、サーバー上のすべてのサービスで再利用します。次の内容を /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/alertsdrop-in でサービスに関連付けると、パッケージのアップグレードで変更内容が上書きされません。
sudo systemctl edit myapp.service[Unit]
OnFailure=ntfy-unit-failed@%n.service%n は完全なユニット名に展開されるため、インスタンス名は ntfy-unit-failed@myapp.service になります。また、テンプレート内の %i は myapp.service をスクリプトの最初の引数として渡します。これにより、1つのテンプレートをすべてのユニットで使用できます。意図的に失敗するユニットを /etc/systemd/system/ntfy-selftest.service として保存し、動作を確認します。
[Unit]
Description=Deliberately failing unit
OnFailure=ntfy-unit-failed@%n.service
[Service]
Type=oneshot
ExecStart=/bin/falsesudo systemctl daemon-reload
sudo systemctl start ntfy-selftest.servicestart コマンドはゼロ以外の終了ステータスで終了し、Job for ntfy-selftest.service failed because the control process exited with error code を出力します。約1秒後にスマートフォンへ通知が届きます。確認後、テストユニットを削除します。
注意すべき落とし穴があります。OnFailure= は、ユニットが failed 状態になった場合にのみ実行されます。Restart=always を設定したサービスは、systemd が再起動を続けるため、この状態にならないことがあります。ユニットは、StartLimitIntervalSec 内に StartLimitBurst 回を超えて再起動した場合にのみ失敗します。通知を受け取りたいサービスでは、この2つの値を設定してください。設定しないと、クラッシュループが何日も気付かれないまま続く可能性があります。上記の cron パターンの代わりには、タイマーのほうが適しています。タイマーのサービスユニットには OnFailure= が自動的に適用されるためです。VPS で systemd のサービスとタイマーを使用するガイドでは、その変換方法を説明しています。
同じトピックに稼働監視を接続する
自ホスト型ステータス監視ツール Uptime Kuma には、ntfy の通知タイプが用意されています。Settings、Notifications、Setup Notification の順に開き、Ntfy を選択します。Server URL に https://ntfy.example.com、Topic に alerts を設定し、優先度を選択して、robot のアクセストークンを貼り付けます。保存する前にテスト通知を送信してください。write の grant がそのトピックを対象としていない場合、トピック名が間違っていても通知はエラーなしで失敗するためです。
この構成には明確な限界があります。同じ VPS 上で動作する監視ツールでは、その VPS 自体が停止したことを検知できません。また、ntfy が停止していることを ntfy で通知することもできません。監視ツールは別のマシンで実行し、ntfy 自体を監視する監視ツール用に、メールなどの第 2 の通知チャネルも設定してください。Uptime Kuma の Push 監視タイプを使うと、もう 1 つの死角にも対応できます。cron ジョブが正常終了した後に push URL を呼び出し、呼び出しが届かなくなった時点で Kuma が通知します。ただし、失敗時の分岐はジョブが実行された場合にだけ動作するため、ジョブ自体が開始されなかったことは検知できません。
自己ホストの ntfy は Android と iPhone で動作しますか?
Android では、制限なく動作します。Google Play または F-Droid からアプリをインストールし、Settings を開いて、デフォルトサーバーを https://ntfy.example.com に設定します。次に、ユーザー管理画面でアカウントを追加し、alerts を購読します。Instant delivery ではフォアグラウンドサービスが実行されるため、スマートフォンが doze モード中でもメッセージが届きます。それに伴う常時表示の通知は、バグではなく、Android がフォアグラウンドサービスに要求しているものです。F-Droid 版には Firebase のコードが一切含まれていないため、すべての購読で instant delivery が使用されます。ntfy は UnifiedPush のディストリビューターとしても動作します。UnifiedPush は Google のプッシュサービスに代わるオープンな仕組みです。そのため、UnifiedPush に対応する他のアプリも、あなたのサーバー経由で配信できます。
iOS では、取り除けない依存関係が1つあります。Apple はバックグラウンド中のアプリを APNs(Apple push notification service)経由でのみ起動します。また、アプリの署名資格情報を持つ主体だけがそのアプリへ送信できます。そのため、あなたのサーバーからアプリへ直接到達することはできません。ntfy は relay でこの問題を解決します。あなたのサーバーはメッセージ ID を含む poll_request を ntfy.sh に送信します。ntfy.sh はそれを Firebase と APNs 経由で転送してアプリを起動します。その後、アプリがあなたのサーバーからメッセージ本文を取得します。
upstream-base-url: "https://ntfy.sh"この仕組みに伴う影響を明確に理解してください。メッセージの内容はあなたのサーバーに残ります。しかし、メッセージが到着した事実とその ID は、あなたが運用していないインフラを通過します。この設定を使用しない場合、自己ホストサーバーから iPhone への通知は遅れて届くか、まったく届きません。アプリを起動する仕組みがないためです。relay をなくす唯一の方法は、独自の Apple developer account と独自の APNs keys を使用して、iOS アプリを自分でビルドして配布することです。その場合、年額料金が必要になり、更新のたびに再ビルドしなければなりません。relay を利用できない場合は、Android またはデスクトップの Web アプリでアラートを運用してください。
バックアップ、アップグレード、イメージの固定
再生成できないパスは2つあります。/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 データベースは起動時にマイグレーションされるため、スキーマ変更後に古いタグへロールバックするのは安全ではありません。新しいバージョンを1日運用するまで、取得したばかりのバックアップを保管してください。
Gotify と Apprise
Gotify はより小規模な選択肢です。Web UI と Android アプリを備えた単一バイナリで、トピックのワイルドカードには対応せず、公式の iOS クライアントもありません。Android だけを通知先とするプライベートなサーバーに適しています。Apprise はサーバーではなく、Python ライブラリとコマンドラインツールです。1 件のメッセージを ntfy など100以上のサービスへ同時に送信できます。そのため、スクリプトから複数の送信先へ一度に通知する用途に適しています。ntfy はサーバー、HTTP API、モバイル両プラットフォーム向けアプリを提供します。そのため、レンタルサーバーからのアラート通知では通常 ntfy が選ばれます。
FAQ
ntfy サーバーへの発行で 403 が返るのはなぜですか?
auth-default-access: "deny-all" が server.yml に設定されている場合、匿名での発行は拒否されます。これは意図した動作です。-u user:pass または -H "Authorization: Bearer tk_..." で認証情報を送信してください。すでに token を送信しているのに 403 が返る場合、その token に対応するユーザーに、対象 topic の ACL エントリがありません。ntfy access を実行すると、完全な一覧を表示できます。write の grant では subscribe は許可されない点に注意してください。そのため、発行できるアカウントでも、同じ topic を読み取ろうとすると拒否されます。
自己ホスト型 ntfy サーバーで iPhone に通知できますか?
できます。ただし、回避できない relay を経由します。Apple は APNs (Apple push notification service) 経由でのみアプリを起動でき、APNs に送信できるのはアプリの publisher だけです。そのため ntfy はメッセージ ID を含む poll_request を ntfy.sh に転送し、ntfy.sh がデバイスへ中継します。server.yml で upstream-base-url: "https://ntfy.sh" を設定し、container を再起動してください。メッセージ本文自体は引き続きサーバーから取得されます。この設定がない場合、iOS の通知は遅延するか、表示されません。
cron job の ntfy alert が届かなかったのはなぜですか?
まず curl の行を単独で実行し、token と topic が正しいことを確認してください。手動では動作するのに cron からは動作しない場合、alert より前の段階で失敗しています。cron は最小限の環境と短い PATH で job を実行するため、コマンドを名前だけで呼び出す script は curl の行に到達する前に終了することがあります。絶対パスを使用し、job の出力を log file にリダイレクトして、次回の実行後にその file を確認してください。delivery ではなく 429 response が返る場合、rate limit は機能しており、script の retry が速すぎます。
ntfy を public internet に公開すべきですか?
phone app は mobile network から ntfy に接続する必要があるため、auth-default-access: "deny-all" と topic ごとの ACL を設定した public HTTPS endpoint が一般的です。everyone から読み取り可能な topic がなければ、安全に運用できます。すべての subscriber が管理下の machine である場合は、VPN 限定の instance も適切です。ただし phone には向きません。tunnel が確立している間しか app が通知を受信できないため、phone が再接続するまで alert が queue に残るからです。