ntfyを自宅サーバーで構築してプッシュ通知を送る方法
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 サービスには適しています。しかし、バックアップ失敗通知を扱うサーバーには適していません。そこで、このガイドでは最初のメッセージを送信する前に認証を有効にします。
開始前に必要なもの
Docker Engine と Compose plugin をインストールした Ubuntu 24.04 または Debian 13 が稼働する VPS、ドメイン名、少量の RAM が必要です。DNS(domain name system)で、サーバーのパブリック IP アドレスを指す A レコードとして ntfy.example.com を作成します。その後、他の作業を行う前に名前解決を確認します。
dig +short ntfy.example.com
sudo ufw allow 80,443/tcp
sudo ufw statusdig はサーバーの IP アドレスを出力する必要があります。何も出力されない場合、証明書の発行に失敗します。認証局は外部から名前を確認するためです。Let's Encrypt の基盤である ACME(automatic certificate management environment)では、HTTP チャレンジにポート 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" はコンテナ内のすべてのインターフェイスにバインドします。一見すると無防備に見えますが、これが正しい設定です。コンテナには独自のネットワーク名前空間があるため、そこで 127.0.0.1 にバインドすると、ホストからポートに到達できず、Docker が公開したポートも接続できません。auth-default-access: "deny-all" はセキュリティ設定全体を担います。明示的な許可がないユーザーには、読み取りも書き込みも許可しません。behind-proxy: true は、X-Forwarded-For ヘッダーからクライアントアドレスを取得するよう ntfy に指示します。これにより、レート制限はリバースプロキシを 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 を実行する
これを /opt/ntfy/compose.yaml に記述し、1000:1000 を上に表示された 2 つの数値 id -u と id -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-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 つあります。イメージは v2.27.0 に固定しています。これは 2026 年 8 月時点の現行リリースです。latest にはしていません。latest を使うと、次の docker compose pull によってサーバーのバージョンが変わり、変更内容を後から changelog で知ることになるためです。ポートは 127.0.0.1:2586:2586 として公開しています。そのため、コンテナに接続できるのはホストの loopback アドレスからだけです。2586:2586 と記述すると、Docker が独自の firewall ルールを自分のルールより前に挿入します。その結果、ufw status ではポートが閉じていると表示されても、インターネットからそのポートに応答します。どちらの運用方法も、次に追加するコンテナにそのまま適用できます。自己ホスト型の RustDesk relay も同じ方法でイメージタグを固定します。ただし、signal ポートと relay ポートはインターネットから応答する必要があるため、loopback の背後に隠すことはできません。
curl が Connection refused を出力した場合は、コンテナのログを確認します。/var/lib/ntfy/user.db の権限エラーは、user: の行がそれらのディレクトリの所有者と一致していないことを示します。そのため、プロセスは自身のデータベースを作成できず終了します。VPS 向け Docker Compose の基本ガイドでは、ボリュームの所有権と再起動ポリシーについて詳しく説明しています。
Caddy で TLS を前段に配置する
Caddy は証明書を自動で取得・更新するため、動作する TLS(transport layer security)を構成する最短の方法です。
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でどちらかを特定できます。後で別のサービスを追加する場合は、同じ Caddyfile にホスト名ブロックを1つ追加します。これにより、Jellyfin ライブラリ用の90年代風ビデオ店フロントエンドである Halcyonのようなサービスを、同じサーバーの2つ目のサブドメインで公開できます。
すでに 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 は publish のみを許可します。そのため、cron ジョブからトークンが盗まれても、そのトークンで送信した内容を subscribe して読み返すことはできません。特殊なユーザー名 everyone では、認証されていない訪問者に許可する操作を指定します。これを使うのは、ntfy access everyone status read のように意図的に公開するものだけにしてください。
スクリプトにはパスワードではなくトークンを持たせてください。
sudo docker compose exec ntfy ntfy token add robotこのコマンドは tk_ で始まるトークンを出力します。トークンは、所属するユーザーのアクセス権をそのまま引き継ぎます。そのため、このトークンは alerts トピックへの publish のみを実行でき、それ以外は何もできません。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 までの名前で指定し、スマートフォンで通知音を鳴らすかどうかを決めます。名前が既知の絵文字の短縮コードと一致すると、Tags は通知上で絵文字に変換されます。一致しない場合はプレーンテキストのまま表示されます。
端末からトピックを監視するには、ストリームとして取得します。
curl -s -u admin https://ntfy.example.com/alerts/rawcurl はパスワードの入力を求めます。各メッセージは 1 行で届き、ときどき現れる空行はキープアライブです。ブラウザーで https://ntfy.example.com を開き、同じアカウントでサインインすると、同じストリームを Web アプリ版で確認できます。
スクリプトによるサーバーへの大量リクエストを防ぐレート制限を設定する
デフォルトでは、各訪問者に 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 の unit が失敗したときに通知する
Cron はスケジュールされた処理を対象とします。長時間実行するサービスには OnFailure= が必要です。systemd は unit が failed 状態になったときにこれを実行します。テンプレート unit を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 を実行して mode 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 は完全な unit 名に展開されるため、インスタンス名は ntfy-unit-failed@myapp.service になります。テンプレート内の %i は、myapp.service をスクリプトの第1引数として渡します。これにより、1つのテンプレートをすべての unit で使用できます。意図的に失敗する unit を /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 コマンドは0以外の終了ステータスで終了し、Job for ntfy-selftest.service failed because the control process exited with error code を出力します。その約1秒後にスマートフォンへ通知が届きます。確認後、テスト用 unit を削除します。
注意すべき落とし穴があります。OnFailure= は unit が failed 状態になったときだけ実行されます。Restart=always が設定されたサービスは、systemd が再起動を続けるため、その状態にならないことがあります。StartLimitBurst 回の再起動を StartLimitIntervalSec 内に超えた場合にのみ、unit は失敗します。通知が必要なサービスには、この2つの値を設定してください。設定しないと、クラッシュループが数日間気付かれないまま続く可能性があります。上記の cron パターンには、timer のほうが適しています。timer の service unit には OnFailure= が自動的に適用されるためです。VPS 上の systemd サービスと timer のガイドでは、その変換方法を説明しています。
同じトピックに稼働監視を組み込む
自ホスト型ステータス監視ツール Uptime Kuma は、ntfy の通知タイプを提供しています。Settings、Notifications、Setup Notification の順に開き、Ntfy を選択します。サーバー URL に https://ntfy.example.com、トピックに 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 に設定します。user management 画面でアカウントを追加し、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 または desktop web app に限定してください。
バックアップ、アップグレード、イメージの固定
再生成できないパスは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 に保存されるのは直近12時間分のメッセージだけで、cache-duration も含まれます。そのため、これを失っても保護すべきデータは失われません。アップグレードするには、compose ファイルのタグを編集してから pull します。
sudo docker compose pull
sudo docker compose up -d
curl -s https://ntfy.example.com/v1/health最初にリリースノートを読んでください。SQLite データベースは起動時に移行されるため、スキーマ変更後に古いタグへロールバックするのは安全ではありません。新しいバージョンを1日稼働させるまで、取得したばかりのバックアップを保持してください。このサーバー上の各サービスには、再生成できないパスの短い一覧があります。同じ種類の VPS 上でフォトライブラリを運用する場合は、PhotoPrism と Immich の比較でその一覧と、対応するバックアップコマンドを確認できます。
Gotify と Apprise
Gotify はより小規模な選択肢です。Web UI と Android アプリを備えた単一バイナリで、トピックのワイルドカードには対応せず、公式の iOS クライアントもありません。Android だけを通知先とするプライベートなサーバーに適しています。Apprise はサーバーではなく、Python ライブラリとコマンドラインツールです。1 つのメッセージを ntfy を含む100以上のサービスへ一斉に送信できます。複数の宛先へ同時に通知する必要があるスクリプトに適しています。ntfy はサーバー、HTTP API、両方のモバイルプラットフォーム向けアプリを提供します。そのため、レンタルサーバーからのアラート通知では通常 ntfy が選ばれます。
FAQ
ntfy サーバーへの publish が 403 を返すのはなぜですか?
server.yml で auth-default-access: "deny-all" を使用している場合、匿名での publish は拒否されます。これは意図した動作です。-u user:pass または -H "Authorization: Bearer tk_..." で認証情報を送信してください。すでに token を送信しているにもかかわらず 403 が返る場合、その token に対応するユーザーには、対象 topic に一致する ACL エントリがありません。ntfy access を実行して、一覧全体を表示してください。write の grant では subscribe は許可されない点に注意してください。そのため、publish は正常に行えるアカウントでも、同じ 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 を実行するため、コマンドを bare name で呼び出す script は curl の行に到達する前に終了することがあります。絶対パスを使用し、job の出力を log file にリダイレクトして、次回実行後にその file を確認してください。配信ではなく 429 response が返る場合、rate limit は機能しており、script が早すぎる間隔で再試行しています。
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 に残るからです。