SSD Nodes Learn 🎉 VPS desde $5.50/mês
Guias Matt ConnorPor Matt Connor

Como auto-hospedar ntfy com Docker e alertas

Instale o ntfy no seu VPS com Docker Compose e TLS, proteja tópicos com usuários e ACLs e envie alertas de cron e unidades systemd OnFailure.

O que um servidor ntfy auto-hospedado faz

Um servidor ntfy auto-hospedado transforma um HTTP POST numa notificação push no seu telemóvel. Publique com curl, e a mensagem chega à aplicação Android, à aplicação iOS, a um separador do navegador ou a qualquer outro cliente que consiga manter uma ligação HTTP aberta. Não é necessário instalar uma biblioteca de cliente nem executar um message broker.

O ntfy endereça as mensagens por tópico. Um tópico é um nome no caminho do URL, como https://ntfy.example.com/alerts, e existe assim que alguém publica nele. Numa instalação predefinida, qualquer pessoa que conheça esse nome pode ler o tópico e escrever nele. Por isso, a própria documentação do projeto compara o nome de um tópico com uma palavra-passe. Esse modelo é adequado para o serviço público ntfy.sh. Não é adequado para um servidor que transporta falhas dos seus backups. Por isso, este guia ativa a autenticação antes de a primeira mensagem ser enviada.

O que precisa antes de começar

Precisa de um VPS com Ubuntu 24.04 ou Debian 13, Docker Engine e o plugin Compose instalados, um nome de domínio e muito pouca RAM. Crie um registo A do DNS (sistema de nomes de domínio) que aponte ntfy.example.com para o endereço IP público do servidor. Depois, confirme que o nome é resolvido antes de fazer qualquer outra alteração.

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

dig tem de apresentar o IP do servidor. A emissão do certificado falha se não apresentar nada, porque a autoridade certificadora verifica o nome a partir do exterior. A porta 80 permanece aberta porque o ACME (ambiente de gestão automática de certificados), o protocolo utilizado pelo Let's Encrypt, usa-a para o desafio HTTP. O contentor ntfy não expõe nenhuma porta pública.

Criar o ficheiro de configuração do ntfy

A imagem Docker não inclui um ficheiro de configuração, por isso tem de criar um. Todos os comandos seguintes deste guia leem esse ficheiro. Primeiro, obtenha o ID do utilizador e o ID do grupo com que o contentor será executado.

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

Quatro dessas linhas são essenciais. base-url tem de ser exatamente o endereço HTTPS público, porque o ntfy cria a partir dele os links para anexos e os próprios pedidos da aplicação web. Um valor incorreto faz com que a aplicação web carregue, mas falhe em todas as ações. listen-http: ":2586" associa-se a todas as interfaces dentro do contentor. Isto parece descuidado, mas está correto: o contentor tem o seu próprio namespace de rede, por isso associar-se a 127.0.0.1 nesse contexto tornaria a porta inacessível a partir do host e a porta publicada pelo Docker nunca conseguiria estabelecer a ligação. auth-default-access: "deny-all" define toda a postura de segurança, porque recusa operações de leitura e escrita a qualquer pessoa sem uma autorização explícita. behind-proxy: true instrui o ntfy a obter o endereço do cliente a partir do cabeçalho X-Forwarded-For, para que os limites de taxa contabilizem os visitantes reais em vez de contabilizarem o reverse proxy como um único cliente muito ativo.

enable-login: true permite que a aplicação web e as aplicações móveis iniciem sessão com uma palavra-passe. enable-signup permanece definido como false, porque permitir a criação autónoma de contas num servidor privado seria uma porta de entrada aberta com passos adicionais.

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

Executar ntfy com Docker Compose

Coloque isto em /opt/ntfy/compose.yaml, substituindo 1000:1000 pelos dois números id -u e id -g apresentados acima.

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

Um servidor funcional responde em {"healthy":true}. Há dois detalhes deliberados nesse ficheiro Compose. A imagem está fixada em v2.27.0, a versão atual em agosto de 2026, em vez de latest, porque, com latest, o próximo docker compose pull altera a versão do servidor e só descobre isso depois, ao ler o changelog. A porta é publicada como 127.0.0.1:2586:2586, por isso o contentor só pode ser acedido através do endereço de loopback do host. Se escrever 2586:2586, o Docker insere as suas próprias regras de firewall antes das suas, o que faz com que a porta responda a partir da Internet, mesmo que ufw status indique que a porta está fechada.

Se o curl apresentar Connection refused, consulte o log do contentor. Um erro de permissões em /var/lib/ntfy/user.db significa que a linha user: não corresponde ao proprietário desses diretórios. Por isso, o processo não consegue criar a sua própria base de dados e termina. O guia básico de Docker Compose para um VPS explica com mais detalhe a propriedade de volumes e as políticas de reinício.

Coloque o TLS à frente com Caddy

O Caddy solicita e renova o certificado automaticamente. Esta é a forma mais curta de obter TLS funcional (segurança da camada de transporte).

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

Substitua o conteúdo de /etc/caddy/Caddyfile por três linhas.

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

O mesmo {"healthy":true} através de HTTPS confirma que todo o caminho funciona. Um 502 do Caddy indica que o ntfy não está a escutar; verifique com sudo ss -lntp | grep 2586. Um erro de certificado normalmente significa que o registo DNS está incorreto ou que a porta 80 está bloqueada. O sudo journalctl -u caddy -n 50 indica qual dos dois problemas ocorreu.

Se já utiliza nginx, copie as definições de proxy documentadas pelo ntfy: proxy_http_version 1.1, proxy_buffering off, proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for, e use timeouts de leitura e envio de pelo menos três minutos. Um subscritor mantém uma ligação HTTP aberta durante todo o tempo em que está a escutar. Por predefinição, o nginx fecha uma ligação upstream inativa após 60 segundos. Por isso, os subscritores ligam-se novamente num ciclo e as mensagens enviadas durante a interrupção são perdidas.

Criar utilizadores e restringir o acesso aos tópicos

A autenticação está ativa e ninguém tem acesso a nada, como pretendido. Crie uma conta de administrador para si e uma conta de máquina para os scripts. Estes comandos leem /etc/ntfy/server.yml a partir do interior do contentor. Por isso, o ficheiro de configuração é montado como 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

Cada comando pede uma palavra-passe. Um administrador ignora a lista de controlo de acesso e pode ler e escrever em todos os tópicos. Por isso, reserve essa conta para si e para a aplicação do telemóvel. robot é um utilizador normal sem qualquer acesso até lhe conceder permissões.

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

Uma entrada de ACL (lista de controlo de acesso) especifica um utilizador, um tópico e uma permissão. O tópico pode ser um nome literal ou um padrão em que * corresponde a qualquer valor. Assim, alerts_* abrange alerts_backup e alerts_db sem um comando por host. A permissão write significa apenas publicar. Deste modo, um token roubado de uma tarefa cron não pode subscrever nem ler o que publicou. O nome de utilizador especial everyone define o que um visitante não autenticado pode fazer. Utilize-o apenas para abrir deliberadamente algo público, como ntfy access everyone status read.

Os scripts devem utilizar um token, não a sua palavra-passe.

sudo docker compose exec ntfy ntfy token add robot

O comando apresenta um token que começa por tk_. Um token herda exatamente as permissões do utilizador a que pertence. Por isso, este token pode publicar nos tópicos alerts e não pode fazer mais nada. ntfy token list mostra o que existe e ntfy token remove revoga um token sem alterar a palavra-passe do utilizador.

Envie a sua primeira mensagem e confirme que o bloqueio funciona

Comece por verificar se a porta está fechada.

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

Isto apresenta 403, e 403 é a resposta correta: auth-default-access: "deny-all" recusa uma publicação anónima. Agora envie uma mensagem real.

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

O servidor responde com a mensagem armazenada em JSON. Assim, sabe que ela foi aceite e não descartada. Title é a primeira linha em negrito. Priority vai de 1 a 5 ou, por nome, de min a urgent, e determina se o telefone emite um som. Tags transforma-se em emoji na notificação quando o nome corresponde a um código abreviado de emoji conhecido e permanece como texto simples quando não corresponde.

Para monitorizar um tópico a partir de um terminal, transmita-o:

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

O curl pede a palavra-passe. Cada mensagem chega numa única linha, e as linhas vazias que aparecem ocasionalmente são mensagens de manutenção da ligação. Abrir https://ntfy.example.com num navegador e iniciar sessão com a mesma conta apresenta a versão web do mesmo fluxo.

Defina limites de taxa para impedir que um único script sobrecarregue o servidor

Por predefinição, cada visitante recebe um balde de 60 pedidos, reabastecido à razão de um pedido a cada 5 segundos. Esse limite é generoso para um servidor privado, e um script preso num ciclo de novas tentativas irá consumi-lo por completo. Adicione limites a server.yml.

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

Um visitante que exceda o limite recebe HTTP 429 em vez de uma mensagem entregue. O limite é contabilizado por endereço do visitante. Por isso, behind-proxy: true é tão importante: sem essa configuração, o ntfy vê apenas o endereço do Caddy, todos os clientes são contabilizados como o mesmo visitante e um único script ruidoso esgota o balde partilhado pelo seu telemóvel e pelos restantes servidores.

Alerta de um cron job que falha

Mantenha o token fora da linha de comandos. ps aux mostra a linha de comandos completa de todos os processos em execução a todos os utilizadores do sistema. Por isso, um token passado com -H pode ser lido por qualquer conta local enquanto o curl estiver em execução. Um ficheiro de configuração do curl evita esse 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

Agora encapsule o job. Guarde isto como /usr/local/bin/backup-with-alert.sh e torne-o executável com 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

$? é capturado na linha imediatamente a seguir ao comando, porque a execução do comando seguinte iria substituí-lo. A saída passa por tail -c 1000 porque o ntfy impõe um tamanho máximo para as mensagens e uma notificação não substitui um visualizador de logs. O exit "$code" de fecho preserva o estado original, para que qualquer outro componente que monitorize este job continue a detetar uma falha. Teste tudo apontando o script para /bin/false numa execução.

Uma ramificação de falha que nunca é executada é pior do que não ter alertas. Dá a entender que o silêncio significa sucesso. O cron fornece ao job um ambiente quase vazio e um PATH muito mais curto do que o da sua shell de login. Por isso, um script que funciona quando é executado manualmente pode terminar antes de chegar à linha do curl. O guia sobre os motivos pelos quais um cron job não é executado aborda essas armadilhas relacionadas com o ambiente. Use caminhos absolutos em todo o lado e leia o ficheiro de log depois da primeira execução agendada, em vez de presumir que tudo funcionou.

Alertar quando uma unidade systemd falhar

O Cron trata das tarefas agendadas. Os serviços de execução prolongada precisam de OnFailure=, que o systemd executa sempre que uma unidade entra no estado failed. Crie uma unidade de modelo e reutilize-a para todos os serviços do servidor. Guarde-a como /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

Depois, /usr/local/bin/ntfy-unit-failed, modo 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

Associe-a a um serviço através de um drop-in, para que uma atualização de pacote não substitua a sua edição.

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

%n é expandido para o nome completo da unidade, por isso a instância passa a ser ntfy-unit-failed@myapp.service, e %i dentro do modelo passa myapp.service ao script como primeiro argumento. É isso que permite usar um único modelo para todas as unidades. Confirme que funciona com uma unidade que falha de propósito, guardada como /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

O comando de arranque termina com um código diferente de zero e apresenta Job for ntfy-selftest.service failed because the control process exited with error code, e o telemóvel deverá emitir um alerta cerca de um segundo depois. Elimine a unidade de teste no fim.

Há uma armadilha importante. OnFailure= só é executado quando uma unidade alcança o estado failed, e um serviço com Restart=always pode nunca o alcançar, porque o systemd continua a reiniciá-lo. A unidade só falha depois de exceder StartLimitBurst reinícios dentro de StartLimitIntervalSec. Defina estes dois valores em todos os serviços sobre os quais pretende receber alertas; caso contrário, um ciclo de falhas e reinícios poderá decorrer silenciosamente durante dias. Os timers são uma substituição mais adequada para o padrão de cron apresentado acima, porque a unidade de serviço de um timer recebe OnFailure= automaticamente, e o guia sobre serviços e timers systemd num VPS explica como converter um deles.

Conecte um monitor de disponibilidade ao mesmo tópico

Uptime Kuma, o monitor de estado autoalojado, inclui um tipo de notificação ntfy. Abra Settings, depois Notifications e Setup Notification, escolha Ntfy, defina o URL do servidor como https://ntfy.example.com e o tópico como alerts, selecione uma prioridade e cole o token de acesso robot. Envie a notificação de teste antes de guardar, porque um nome de tópico incorreto falha silenciosamente quando a concessão write não o inclui.

O limite desta configuração é claro: um monitor executado na mesma VPS não consegue indicar que a VPS está indisponível, e o ntfy não consegue entregar a informação de que o ntfy está indisponível. Execute o monitor noutra máquina e atribua-lhe um segundo canal de notificação, como o email, para o monitor que monitoriza o próprio ntfy. O tipo de monitor Push do Uptime Kuma cobre o outro ponto cego: o seu cron job chama um URL de push depois de uma execução bem-sucedida, e o Kuma gera um alerta quando essas chamadas deixam de chegar. Um ramo de falha é acionado apenas quando o job é executado, por isso não informa que o job nunca foi iniciado.

O ntfy auto-hospedado funciona no Android e no iPhone?

No Android, sim, sem limitações. Instale a aplicação a partir do Google Play ou do F-Droid, abra Settings, defina o servidor predefinido como https://ntfy.example.com, adicione a sua conta no ecrã de gestão de utilizadores e subscreva alerts. A entrega instantânea mantém um serviço em primeiro plano em execução, para que as mensagens cheguem mesmo quando o telefone está no modo doze. A notificação permanente associada é uma exigência do Android para serviços em primeiro plano, não um erro. A compilação do F-Droid não contém qualquer código Firebase, pelo que todas as subscrições usam a entrega instantânea. O ntfy também pode funcionar como distribuidor UnifiedPush, uma alternativa aberta ao serviço de push da Google. Assim, outras aplicações compatíveis com UnifiedPush também podem entregar notificações através do seu servidor.

No iOS, funciona com uma dependência que não pode remover. A Apple só reativa uma aplicação em segundo plano através do APNs (Apple push notification service), e apenas a entidade que possui as credenciais de assinatura da aplicação lhe pode enviar notificações. Por isso, o seu servidor não consegue contactar diretamente a aplicação. O ntfy resolve isto com um relay: o seu servidor envia um poll_request com o ID da mensagem para ntfy.sh, que o encaminha através do Firebase e do APNs para reativar a aplicação. A aplicação obtém depois o corpo da mensagem a partir do seu servidor.

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

Tenha em conta o custo desta configuração. O conteúdo da mensagem permanece no seu servidor, mas o facto de uma mensagem ter chegado e o respetivo ID passam por uma infraestrutura que não controla. Sem esta configuração, as notificações no iPhone provenientes de um servidor auto-hospedado chegam atrasadas ou não chegam, porque nada reativa a aplicação. A única forma de remover o relay é compilar e publicar a aplicação iOS por conta própria, usando a sua própria conta de programador Apple e as suas próprias chaves APNs. Isto implica uma taxa anual e uma nova compilação para cada atualização. Se o relay não for aceitável no seu caso, mantenha os alertas no Android ou na aplicação Web para desktop.

Backups, upgrades and fixação da imagem

Dois caminhos não podem ser regenerados: /etc/ntfy/server.yml e /var/lib/ntfy/user.db. O segundo contém todos os utilizadores, hashes de palavras-passe, entradas de ACL e tokens, por isso deve ser tratado como uma chave privada.

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

Copie esse ficheiro para fora do servidor. cache.db contém apenas mensagens recentes, correspondentes a 12 horas com o cache-duration acima, por isso a sua perda não elimina nada que valha a pena proteger. Para atualizar, edite a tag no ficheiro Compose e faça o pull.

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

Leia primeiro as notas da versão. As bases de dados SQLite são migradas no arranque, por isso não é seguro reverter para uma tag mais antiga depois de uma alteração de esquema. Mantenha o backup que acabou de criar até a nova versão ter funcionado durante um dia.

Gotify e Apprise

Gotify é a opção mais pequena: um único binário com uma interface web e uma aplicação Android. Não suporta curingas em tópicos nem tem um cliente oficial para iOS. É adequado para um servidor privado em que Android é o único destino. Apprise é uma biblioteca Python e uma ferramenta de linha de comandos, não um servidor. Distribui uma única mensagem por mais de uma centena de serviços, incluindo ntfy. É adequado para um script que tem de enviar alertas para vários destinos em simultâneo. ntfy é a opção que fornece um servidor, uma API HTTP e aplicações para ambas as plataformas móveis. Por isso, é normalmente a resposta para alertas enviados a partir de um servidor alugado.

FAQ

Porque é que a publicação no meu servidor ntfy devolve 403?

Com auth-default-access: "deny-all" em server.yml, uma publicação anónima é recusada, e esse é o comportamento esperado. Envie as credenciais com -u user:pass ou -H "Authorization: Bearer tk_...". Se já estiver a enviar um token e continuar a receber 403, o utilizador associado a esse token não tem uma entrada ACL correspondente ao tópico. Execute ntfy access para apresentar a lista completa. Tenha em atenção que uma concessão write não permite subscrever, por isso uma conta que publica corretamente continuará a ser recusada quando tentar ler o mesmo tópico.

As notificações funcionam no iPhone com um servidor ntfy alojado localmente?

Funcionam, através de um relay que não pode evitar. A Apple ativa as aplicações apenas através do APNs (Apple push notification service), e apenas o publicador da aplicação pode enviar notificações para esse serviço. Por isso, o ntfy encaminha um poll_request que contém o ID da mensagem para ntfy.sh, que o retransmite para o dispositivo. Defina upstream-base-url: "https://ntfy.sh" em server.yml e reinicie o contentor. O corpo da mensagem continua a ser obtido a partir do seu servidor. Sem essa definição, as notificações no iOS sofrem atrasos ou nunca aparecem.

Porque é que o alerta ntfy do meu cron nunca chegou?

Execute primeiro a linha curl isoladamente para confirmar que o token e o tópico estão corretos. Se funcionar manualmente, mas não a partir do cron, a falha ocorre antes do alerta: o cron executa as tarefas com um ambiente mínimo e um PATH curto, por isso um script que chama um comando apenas pelo nome pode terminar antes de chegar à linha curl. Use caminhos absolutos, redirecione a saída da tarefa para um ficheiro de log e leia esse ficheiro depois da execução seguinte. Uma resposta 429 em vez de uma entrega significa que o limite de taxa está a funcionar e que o seu script está a repetir as tentativas demasiado depressa.

Devo expor o ntfy na Internet pública?

As aplicações móveis precisam de lhe conseguir aceder através de redes móveis, por isso um endpoint HTTPS público com auth-default-access: "deny-all" e ACLs por tópico é a configuração normal. É seguro desde que nenhum tópico possa ser lido por everyone. Uma instância acessível apenas por VPN é adequada quando todos os subscritores são máquinas que controla. É pouco adequada para telemóveis, porque a aplicação só recebe notificações enquanto o túnel está ativo. Assim, os alertas ficam em fila até o telemóvel voltar a ligar-se.