SSD Nodes Learn 🎉 VPS da $5.50/mese
Guide Matt ConnorDi Matt Connor

Self-host ntfy: alert push per server con Docker

Configura ntfy sul tuo VPS con Docker Compose e TLS: proteggi i topic con utenti e ACL, poi invia avvisi da cron e unit systemd OnFailure.

Cosa fa un server ntfy self-hosted

Un server ntfy self-hosted trasforma una richiesta HTTP POST in una notifica push sul telefono. Pubblicare un messaggio è sufficiente con curl: il messaggio arriva nell'app Android, nell'app iOS, in una scheda del browser o in qualsiasi altro client in grado di mantenere aperta una connessione HTTP. Non è necessario installare una libreria client né eseguire un message broker.

ntfy indirizza i messaggi tramite topic. Un topic è un nome nel percorso dell'URL, ad esempio https://ntfy.example.com/alerts, e viene creato nel momento in cui qualcuno pubblica un messaggio su quel topic. In un'installazione predefinita, chiunque conosca quel nome può leggere il topic e pubblicarvi messaggi. Per questo la documentazione del progetto paragona il nome di un topic a una password. Questo modello è adatto al servizio pubblico ntfy.sh. Non è adatto a un server che deve notificare i problemi dei backup. Per questo motivo, questa guida abilita l'autenticazione prima dell'invio del primo messaggio.

Cosa serve prima di iniziare

Serve un VPS con Ubuntu 24.04 o Debian 13, Docker Engine con il plugin Compose, un nome di dominio e una quantità molto ridotta di RAM. Crea un record DNS A (domain name system) che punti ntfy.example.com all'indirizzo IP pubblico del server, quindi verifica che la risoluzione funzioni prima di procedere.

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

dig deve restituire l'indirizzo IP del server. Il rilascio del certificato non riesce se non restituisce alcun valore, perché la certification authority verifica il nome dall'esterno. La porta 80 deve rimanere aperta perché ACME (automatic certificate management environment), il protocollo usato da Let's Encrypt, la utilizza per la HTTP challenge. Il container ntfy non espone mai una porta pubblica.

Scrivere il file di configurazione di ntfy

L'immagine Docker non include un file di configurazione, quindi è necessario crearne uno. Tutti i comandi successivi di questa guida lo leggono. Per prima cosa, individuare l'ID utente e l'ID gruppo con cui verrà eseguito il container.

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

Quattro di queste righe sono fondamentali. base-url deve essere l'indirizzo HTTPS pubblico esatto, perché ntfy lo usa per creare i link agli allegati e le richieste dell'applicazione web. Un valore errato produce un'applicazione web che si carica, ma non completa nessuna operazione. listen-http: ":2586" effettua il binding su tutte le interfacce del container. Sembra una configurazione poco restrittiva, ma è corretta: il container ha il proprio namespace di rete, quindi effettuare il binding su 127.0.0.1 impedirebbe al traffico proveniente dall'host di raggiungere la porta e la porta pubblicata da Docker non riuscirebbe a connettersi. auth-default-access: "deny-all" definisce l'intero criterio di sicurezza, perché impedisce le operazioni di lettura e scrittura a chiunque non disponga di un'autorizzazione esplicita. behind-proxy: true indica a ntfy di ricavare l'indirizzo del client dall'header X-Forwarded-For, così i limiti di frequenza contano i visitatori reali invece del reverse proxy come se fosse un unico client molto attivo.

enable-login: true consente all'applicazione web e alle app per smartphone di accedere con una password. enable-signup resta impostato su false, perché consentire la creazione autonoma degli account su un server privato equivale a lasciare aperto l'accesso, aggiungendo soltanto alcuni passaggi.

sudo chown "$(id -u):$(id -g)" /etc/ntfy/server.yml
sudo chmod 600 /etc/ntfy/server.yml

Eseguire ntfy con Docker Compose

Inserisci questo in /opt/ntfy/compose.yaml, sostituendo 1000:1000 con i due numeri id -u e id -g riportati sopra.

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

Un server operativo risponde con {"healthy":true}. Due dettagli in quel file Compose sono intenzionali. L'immagine è fissata a v2.27.0, la release corrente ad agosto 2026, invece di latest, perché con latest il successivo docker compose pull modifica la versione del server e te ne accorgi solo dopo, leggendo il changelog. La porta è pubblicata come 127.0.0.1:2586:2586, quindi il container è raggiungibile soltanto dall'indirizzo di loopback dell'host. Se scrivi 2586:2586, Docker inserisce le proprie regole firewall prima delle tue. Di conseguenza, la porta risponde da Internet anche se ufw status indica che è chiusa.

Se curl stampa Connection refused, consulta il log del container. Un errore di autorizzazione su /var/lib/ntfy/user.db indica che la riga user: non corrisponde al proprietario di quelle directory. Il processo non può quindi creare il proprio database e termina. La guida di base a Docker Compose per un VPS descrive più dettagliatamente la proprietà dei volumi e le policy di riavvio.

Metti TLS davanti al servizio con Caddy

Caddy richiede e rinnova autonomamente il certificato. È il modo più rapido per ottenere TLS funzionante (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

Sostituisci il contenuto di /etc/caddy/Caddyfile con tre righe.

ntfy.example.com {
    reverse_proxy 127.0.0.1:2586
}
sudo systemctl reload caddy
curl -s https://ntfy.example.com/v1/health

Lo stesso {"healthy":true} su HTTPS dimostra che l’intero percorso funziona. Un 502 da Caddy indica che ntfy non è in ascolto: verifica con sudo ss -lntp | grep 2586. Un errore del certificato indica in genere che il record DNS è errato o che la porta 80 è bloccata; sudo journalctl -u caddy -n 50 specifica quale delle due condizioni si verifica.

Se esegui già nginx, copia le impostazioni proxy documentate da ntfy: proxy_http_version 1.1, proxy_buffering off, proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for, e imposta timeout di lettura e invio di almeno tre minuti. Un subscriber mantiene aperta una connessione HTTP per tutta la durata dell’ascolto. Per impostazione predefinita, nginx chiude una connessione upstream inattiva dopo 60 secondi. Di conseguenza, i subscriber si riconnettono in ciclo e i messaggi inviati durante l’intervallo vengono persi.

Creare utenti e limitare l'accesso ai topic

L'autenticazione è attiva e, per ora, nessuno può accedere a nulla. È il comportamento previsto. Creare un account amministratore personale e un account macchina per gli script. Questi comandi leggono /etc/ntfy/server.yml dall'interno del container; per questo il file di configurazione è montato come volume.

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

Ogni comando richiede una password. Un amministratore ignora la lista di controllo degli accessi e può leggere e scrivere su ogni topic. Riservare quindi questo account all'uso personale e all'app del telefono. robot è un utente normale, che non dispone di alcun accesso finché non ne viene concesso uno.

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

Una voce ACL (access control list) associa un utente, un topic e un permesso. Il topic può essere un nome letterale oppure un pattern in cui * corrisponde a qualsiasi valore. In questo modo alerts_* include alerts_backup e alerts_db senza eseguire un comando separato per ogni host. Il permesso write consente solo la pubblicazione. Se un token viene sottratto a un cron job, non può quindi sottoscrivere topic e rileggere i dati inviati. Il nome utente speciale everyone definisce le operazioni consentite a un visitatore non autenticato. Usarlo solo per esporre deliberatamente un contenuto pubblico, come ntfy access everyone status read.

Gli script devono usare un token, non la password.

sudo docker compose exec ntfy ntfy token add robot

Il comando stampa un token che inizia con tk_. Un token eredita esattamente i permessi dell'utente a cui appartiene. Questo token può quindi pubblicare sui topic alerts e non può eseguire altre operazioni. ntfy token list mostra gli elementi presenti, mentre ntfy token remove revoca un token senza modificare la password dell'utente.

Invia il primo messaggio e verifica che il blocco funzioni

Inizia verificando che la porta sia chiusa.

curl -s -o /dev/null -w '%{http_code}\n' -d "hello" https://ntfy.example.com/alerts

Il comando stampa 403 e 403 è la risposta corretta: auth-default-access: "deny-all" rifiuta una pubblicazione anonima. Ora invia un messaggio reale.

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

Il server restituisce il messaggio memorizzato in formato JSON. Questo conferma che il messaggio è stato accettato e non ignorato. Title è la prima riga in grassetto. Priority usa valori da 1 a 5 oppure, per nome, da min a urgent; determina se il telefono emette un suono. Tags diventano emoji nella notifica quando il nome corrisponde a uno short code emoji noto e restano testo normale negli altri casi.

Per monitorare un topic dal terminale, trasmetti il relativo flusso:

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

curl richiede la password. Ogni messaggio arriva su una singola riga; le righe vuote che compaiono occasionalmente sono keepalive. Aprendo https://ntfy.example.com in un browser e accedendo con lo stesso account, ottieni la versione web dello stesso flusso.

Impostare i limiti di frequenza per impedire che uno script sovraccarichi il server

Per impostazione predefinita, ogni visitatore dispone di un bucket di 60 richieste, rifornito al ritmo di una richiesta ogni 5 secondi. Per un server privato è un limite ampio e uno script bloccato in un ciclo di retry lo esaurisce rapidamente. Aggiungere i limiti a server.yml.

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

Un visitatore che supera il limite riceve HTTP 429 invece del messaggio recapitato. Il limite viene conteggiato per indirizzo del visitatore. Per questo behind-proxy: true è così importante: senza questa impostazione, ntfy vede solo l'indirizzo di Caddy, tutti i client vengono considerati lo stesso visitatore e uno script rumoroso esaurisce il bucket condiviso dal telefono e dagli altri server.

Avviso da un cron job che non riesce

Non inserire il token nella riga di comando. ps aux mostra la riga di comando completa di ogni processo in esecuzione a tutti gli utenti del sistema, quindi un token passato con -H è leggibile da qualsiasi account locale per tutta la durata di curl. Un file di configurazione di curl evita questo problema.

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

Ora racchiudi il job in uno script. Salva il contenuto come /usr/local/bin/backup-with-alert.sh e rendilo eseguibile con 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

$? viene acquisito nella riga immediatamente successiva al comando, perché l'esecuzione del comando seguente lo sovrascriverebbe. L'output passa attraverso tail -c 1000 perché ntfy impone una dimensione massima per i messaggi e una notifica non sostituisce un visualizzatore di log. exit "$code" finale conserva lo stato originale, quindi qualsiasi altro componente che controlla questo job continua a rilevare un errore. Verifica l'intera procedura configurando lo script in modo che utilizzi /bin/false per una sola esecuzione.

Un ramo di errore che non viene mai eseguito è peggiore dell'assenza di notifiche, perché può far sembrare che il silenzio indichi un'esecuzione riuscita. Cron fornisce al job un ambiente quasi vuoto e un PATH molto più breve rispetto alla shell di login, quindi uno script che funziona quando lo esegui manualmente può terminare prima di arrivare alla riga curl. La guida sul motivo per cui un cron job non viene eseguito descrive questi problemi legati all'ambiente. Usa sempre percorsi assoluti e leggi il file di log dopo la prima esecuzione pianificata, senza dare per scontato che tutto funzioni.

Avviso quando un’unità systemd non riesce ad avviarsi

Cron gestisce le attività pianificate. I servizi a esecuzione prolungata richiedono OnFailure=, che systemd esegue ogni volta che un’unità entra nello stato failed. Crea un’unica unità modello e riutilizzala per ogni servizio del server. Salvala in /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

Poi esegui /usr/local/bin/ntfy-unit-failed e imposta la modalità 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

Associala a un servizio tramite un drop-in, in modo che un aggiornamento del pacchetto non possa sovrascrivere le tue modifiche.

sudo systemctl edit myapp.service
[Unit]
OnFailure=ntfy-unit-failed@%n.service

%n si espande nel nome completo dell’unità, quindi l’istanza diventa ntfy-unit-failed@myapp.service, mentre %i all’interno del modello passa myapp.service allo script come primo argomento. In questo modo un solo modello può essere usato per ogni unità. Verifica che funzioni con un’unità che fallisce intenzionalmente, salvata in /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

Il comando di avvio termina con un codice diverso da zero e visualizza Job for ntfy-selftest.service failed because the control process exited with error code; il telefono dovrebbe emettere una notifica circa un secondo dopo. Elimina l’unità di test al termine.

È importante considerare un possibile problema. OnFailure= viene eseguito solo quando un’unità raggiunge lo stato failed, e un servizio con Restart=always potrebbe non raggiungerlo mai, perché systemd continua invece a riavviarlo. L’unità risulta non riuscita solo dopo aver superato StartLimitBurst riavvii nell’intervallo StartLimitIntervalSec. Imposta questi due valori su ogni servizio per cui vuoi ricevere notifiche; in caso contrario, un ciclo di arresto e riavvio può proseguire senza notifiche per giorni. I timer sono un sostituto più appropriato del modello basato su cron descritto sopra, perché l’unità di servizio di un timer riceve OnFailure= automaticamente; la guida ai servizi e ai timer systemd su un VPS illustra la procedura di conversione.

Collega un monitor di uptime allo stesso argomento

Uptime Kuma, il monitor di stato self-hosted, include un tipo di notifica ntfy. Apri Settings, quindi Notifications e poi Setup Notification, seleziona Ntfy, imposta l'URL del server su https://ntfy.example.com e l'argomento su alerts, scegli una priorità e incolla il token di accesso robot. Invia la notifica di test prima di salvare, perché un nome dell'argomento errato non genera errori visibili con un'autorizzazione write che non lo include.

Il limite concreto di questa configurazione è il seguente: un monitor eseguito sullo stesso VPS non può rilevare che il VPS è inattivo e ntfy non può comunicare che ntfy è inattivo. Esegui il monitor su un'altra macchina e assegnagli un secondo canale di notifica, ad esempio l'e-mail, per il monitor che controlla ntfy. Il tipo di monitor Push di Uptime Kuma copre l'altro punto cieco: il cron job chiama un URL push dopo un'esecuzione completata correttamente e Kuma invia un avviso quando le chiamate smettono di arrivare. Un ramo di errore si attiva solo quando il job viene eseguito, quindi non segnala un job che non è mai stato avviato.

Funziona ntfy self-hosted su Android e iPhone?

Su Android, sì, senza limitazioni. Installa l’app da Google Play o F-Droid, apri Settings, imposta il server predefinito su https://ntfy.example.com, aggiungi il tuo account nella schermata di gestione degli utenti, quindi sottoscrivi alerts. La consegna istantanea mantiene in esecuzione un foreground service, quindi i messaggi arrivano anche quando il telefono è in modalità doze. La notifica permanente associata è un requisito di Android per i foreground service, non un bug. La build F-Droid non contiene alcun codice Firebase, quindi ogni sottoscrizione usa la consegna istantanea. ntfy può anche agire come distributore UnifiedPush, un’alternativa open al servizio push di Google. Anche le altre applicazioni che supportano UnifiedPush possono quindi usare il tuo server per la consegna.

Su iOS funziona con una dipendenza che non puoi rimuovere. Apple riattiva un’app in background solo tramite APNs (Apple push notification service) e solo chi dispone delle credenziali di firma dell’app può inviarle notifiche. Il tuo server non può quindi raggiungere direttamente l’app. ntfy risolve il problema tramite un relay: il tuo server invia a ntfy.sh un poll_request contenente l’ID del messaggio. ntfy.sh lo inoltra tramite Firebase e APNs per riattivare l’app. L’app recupera quindi il corpo del messaggio dal tuo server.

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

Considera con attenzione il costo di questa soluzione. Il contenuto del messaggio resta sul tuo server, ma il fatto che sia arrivato un messaggio, insieme al relativo ID, passa attraverso un’infrastruttura che non gestisci. Senza questa impostazione, le notifiche su iPhone provenienti da un server self-hosted arrivano in ritardo o non arrivano affatto, perché nulla riattiva l’app. L’unico modo per rimuovere il relay consiste nel compilare e distribuire personalmente l’app iOS usando il tuo account Apple Developer e le tue chiavi APNs. Questo comporta un costo annuale e una nuova build per ogni aggiornamento. Se il relay non è accettabile nel tuo scenario, mantieni gli avvisi su Android o sull’app web desktop.

Backup, aggiornamenti e blocco dell'immagine

Esistono due percorsi che non possono essere rigenerati: /etc/ntfy/server.yml e /var/lib/ntfy/user.db. Il secondo contiene tutti gli utenti, gli hash delle password, le voci ACL e i token. Trattalo come una chiave privata.

sudo tar czf ntfy-backup.tgz -C / etc/ntfy var/lib/ntfy
sudo chmod 600 ntfy-backup.tgz

Copia quel file fuori dal server. cache.db contiene solo i messaggi recenti, relativi a 12 ore insieme al valore cache-duration indicato sopra. La sua perdita non comporta nulla che valga la pena proteggere. Per eseguire l'aggiornamento, modifica il tag nel file Compose ed esegui il pull.

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

Leggi prima le note di rilascio. I database SQLite vengono migrati all'avvio, quindi il rollback a un tag precedente dopo una modifica dello schema non è sicuro. Conserva il backup appena creato finché la nuova versione non è rimasta in esecuzione per un giorno.

Gotify e Apprise

Gotify è l'opzione più compatta: un singolo binario con interfaccia web e un'app Android, senza wildcard per gli argomenti e senza un client iOS ufficiale. È adatto a un server privato destinato esclusivamente ad Android. Apprise è una libreria Python e uno strumento da riga di comando, non un server. Invia lo stesso messaggio a più di cento servizi, incluso ntfy, ed è adatto a uno script che deve raggiungere più destinazioni contemporaneamente. ntfy è la soluzione che offre un server, un'API HTTP e app per entrambe le piattaforme mobili. Per questo è la scelta più comune per gli avvisi inviati da un server noleggiato.

FAQ

Perché la pubblicazione sul mio server ntfy restituisce 403?

Con auth-default-access: "deny-all" in server.yml, la pubblicazione anonima viene rifiutata: è il comportamento previsto. Invia le credenziali con -u user:pass o -H "Authorization: Bearer tk_...". Se stai già inviando un token e ricevi ancora 403, l'utente associato a quel token non dispone di una voce ACL corrispondente per l'argomento. Esegui ntfy access per visualizzare l'elenco completo. Tieni presente che un'autorizzazione write non consente la sottoscrizione. Un account che può pubblicare correttamente verrà quindi comunque rifiutato quando prova a leggere lo stesso argomento.

Le notifiche funzionano su iPhone con un server ntfy self-hosted?

Sì, tramite un relay che non puoi evitare. Apple riattiva le applicazioni solo tramite APNs (Apple push notification service), e solo il publisher dell'app può inviarvi notifiche. Per questo ntfy inoltra a ntfy.sh un poll_request contenente l'ID del messaggio, che il servizio inoltra al dispositivo. Imposta upstream-base-url: "https://ntfy.sh" in server.yml e riavvia il container. Il corpo del messaggio viene comunque recuperato dal tuo server. Senza questa impostazione, le notifiche su iOS arrivano in ritardo o non vengono visualizzate.

Perché l'avviso ntfy del mio cron job non è mai arrivato?

Esegui prima la riga curl separatamente, per verificare che il token e l'argomento siano corretti. Se funziona manualmente ma non da cron, il problema si verifica prima dell'avviso: cron esegue i job con un ambiente minimale e un PATH breve. Di conseguenza, uno script che richiama un comando usando solo il nome può terminare prima di raggiungere la riga curl. Usa percorsi assoluti, reindirizza l'output del job a un file di log e leggi quel file dopo l'esecuzione successiva. Una risposta 429 invece della consegna indica che il rate limiting funziona e che lo script sta effettuando nuovi tentativi troppo rapidamente.

Devo esporre ntfy su Internet pubblico?

Le applicazioni per telefono devono raggiungere il server dalle reti mobili. Per questo, un endpoint HTTPS pubblico con auth-default-access: "deny-all" e ACL per argomento è la configurazione normalmente utilizzata. È sicura finché nessun argomento è leggibile da everyone. Un'istanza accessibile solo tramite VPN è adatta quando ogni sottoscrittore è una macchina sotto il tuo controllo. È invece poco adatta ai telefoni, perché l'app riceve notifiche solo mentre il tunnel è attivo. Gli avvisi restano quindi in coda finché il telefono non si riconnette.