changedetection.io: vigiando mudanças em sites na sua VPS
Olá meus Unicórnios! 🦄✨
Sabe quando você fica entrando num site todo dia só para ver se "saiu alguma coisa"? O edital que não publica, o preço que não baixa, a página de documentação que mudou sem aviso e quebrou a sua integração. 😅 Eu fazia isso na mão, e sempre descobria a mudança tarde demais.
O changedetection.io faz essa visita por você. Ele abre a página de tempos em tempos, compara com a visita anterior e, quando algo muda, mostra exatamente o quê, com o antes em vermelho e o depois em verde. E ainda manda o aviso para o celular.
Para testar, eu instalei na minha VPS e apontei para o site que eu conheço melhor: este blog. Parecia o teste mais tranquilo do mundo. Até o robô me avisar que o meu RSS mudava a cada checagem, sem eu ter publicado nada. 🤯 Spoiler: ele estava certo, e o problema era meu.
Neste artigo eu mostro o caminho inteiro: o que é o changedetection.io, como instalar numa VPS Ubuntu com Docker (explicando cada comando, mesmo que você nunca tenha mexido num servidor), como proteger a tela com senha, como vigiar só o pedaço da página que importa, e como receber o aviso no celular.
🔭 O que é o changedetection.io
É um programa de código aberto (licença Apache 2.0, mais de 34 mil estrelas no GitHub) que você hospeda no seu próprio servidor. A ideia dele cabe numa frase: visitar, guardar, comparar, avisar.
- Visitar: de tempos em tempos (o padrão é a cada 3 horas), ele baixa a página.
- Guardar: transforma o HTML em texto e guarda uma "foto" desse texto, que ele chama de snapshot.
- Comparar: na visita seguinte, compara o texto novo com o anterior, linha por linha.
- Avisar: se alguma linha mudou, registra a mudança no histórico e manda uma notificação.
Repare no detalhe do passo 2: ele compara texto, não o HTML. Se o site trocar uma classe CSS ou um script, você não recebe aviso nenhum. Isso já elimina muito barulho. Mas não todo, e a gente vai ver isso na prática lá no RSS.
Por padrão ele baixa a página como um curl faria, sem executar JavaScript. Para site que monta o conteúdo no navegador (React, por exemplo), o projeto oferece um navegador Chrome à parte, mas isso fica para outro dia: o meu blog entrega o HTML pronto, e é o caso mais comum.
🖥️ Entrando na VPS
Usei uma VPS com Ubuntu 24.04. O changedetection.io é leve: parado, ele ficou entre 95 e 120 MB de memória, e a imagem ocupa 1,66 GB de disco. Qualquer VPS pequena dá conta.
Para entrar na VPS, abra o terminal do seu computador (no Windows, o PowerShell; no Mac e no Linux, o Terminal) e conecte com o ssh, trocando pelo IP da sua máquina:
ssh root@SEU_IP_AQUI
Ele pede a senha. Um aviso que trava todo mundo na primeira vez: enquanto você digita a senha, nada aparece na tela, nem asterisco. Não está travado, é assim mesmo. Digite e aperte Enter.
O changedetection.io roda em Docker, que é um jeito de rodar um programa dentro de uma "caixinha" com tudo de que ele precisa, sem instalar Python nem biblioteca nenhuma na sua máquina. Confira se o Docker e o docker compose (quem lê o arquivo de configuração) estão lá:
docker --version
docker compose version
Docker version 29.8.1, build 4a63305
Docker Compose version v5.5.1
Se aparecer command not found, o Docker ainda não está instalado. O passo a passo está no começo do artigo OpenSEO: instalando na VPS e auditando um site; volte aqui depois.
📝 O arquivo docker-compose.yml
Primeiro, uma pasta só para ele. O mkdir -p cria a pasta, e o cd entra nela:
mkdir -p /opt/changedetection
cd /opt/changedetection
Agora o arquivo que diz ao Docker o que rodar. Para criar e editar arquivo no terminal, a gente usa o nano, um editor de texto simples:
nano docker-compose.yml
A tela do terminal vira um editor em branco. Cole este conteúdo inteiro (com Ctrl+Shift+V, lembra?):
services:
changedetection:
image: ghcr.io/dgtlmoon/changedetection.io
container_name: changedetection
volumes:
- changedetection-data:/datastore
environment:
- TZ=America/Sao_Paulo
ports:
- 127.0.0.1:5000:5000
restart: unless-stopped
volumes:
changedetection-data:
Para salvar, aperte Ctrl+O (a letra O), confirme o nome com Enter, e saia com Ctrl+X. No rodapé do nano aparecem atalhos como ^O Write Out e ^X Exit: o ^ significa a tecla Ctrl.
Esse arquivo é uma versão enxuta do exemplo oficial do projeto (que tem dezenas de linhas comentadas). Linha a linha, o que importa:
image: qual programa baixar. É a imagem oficial, publicada pelo próprio autor.volumes: onde ficam os dados. Tudo que o changedetection.io guarda (monitoramentos, histórico, senha) vai para/datastore, e essa pasta mora num volume do Docker, fora do contêiner. É o que faz os dados sobreviverem quando o contêiner é recriado.TZ=America/Sao_Paulo: o fuso horário que ele usa para agendar as checagens.127.0.0.1:5000:5000: a porta. Guarde esse127.0.0.1na memória, porque ele vai dar o que falar daqui a pouco.restart: unless-stopped: se a VPS reiniciar, ele volta sozinho.
⚠️ A armadilha do YAML: os espaços são o código
Esse arquivo é YAML, e em YAML os espaços no começo da linha não são enfeite: são eles que dizem quem está dentro de quem. O image tem 4 espaços porque pertence ao changedetection, que tem 2 porque pertence ao services. Copie o bloco inteiro e não "arrume" o alinhamento.
Para conferir se o arquivo está certo antes de subir qualquer coisa, peça ao Docker para lê-lo:
docker compose config
Se estiver tudo certo, ele devolve o arquivo "traduzido", com os nomes completos que vai usar:
name: changedetection
services:
changedetection:
container_name: changedetection
environment:
TZ: America/Sao_Paulo
image: ghcr.io/dgtlmoon/changedetection.io
networks:
default: null
ports:
- mode: ingress
host_ip: 127.0.0.1
target: 5000
published: "5000"
protocol: tcp
restart: unless-stopped
volumes:
- type: volume
source: changedetection-data
target: /datastore
volume: {}
networks:
default:
name: changedetection_default
volumes:
changedetection-data:
name: changedetection_changedetection-data
E se não estiver? Eu tirei dois espaços do image de propósito, numa cópia do arquivo, para você ver a cara do erro:
services:
changedetection:
image: ghcr.io/dgtlmoon/changedetection.io
container_name: changedetection
go-yaml load error in scanner at L4.C19: mapping values are not allowed in this context
Repare no detalhe cruel: o erro aponta a linha 4, mas o defeito está na linha 3. O YAML só percebe que algo está errado quando a linha seguinte não se encaixa. Viu erro de YAML? Olhe a linha indicada e a de cima.
🚀 Subindo o changedetection.io
Com o arquivo conferido, um comando sobe tudo. O up cria e liga o contêiner, e o -d deixa rodando em segundo plano, devolvendo o terminal para você:
docker compose up -d
Na primeira vez ele baixa a imagem (1,66 GB, pode levar alguns minutos) e termina assim:
Network changedetection_default Created
Container changedetection Creating
Container changedetection Created
Container changedetection Starting
Container changedetection Started
Para ver se está de pé:
docker compose ps
NAME IMAGE COMMAND SERVICE CREATED STATUS PORTS
changedetection ghcr.io/dgtlmoon/changedetection.io "/docker-entrypoint.…" changedetection 27 minutes ago Up 27 minutes 127.0.0.1:5000->5000/tcp
O Up na coluna STATUS é o que interessa. E o log confirma a versão que subiu (o docker logs mostra o que o programa escreveu; o grep SUCCESS filtra só as linhas de sucesso):
docker logs changedetection 2>&1 | grep SUCCESS
2026-09-24 10:00:28.352 | SUCCESS | changedetectionio:main:329 - changedetection.io version 0.60.7 starting.
2026-09-24 10:00:28.568 | SUCCESS | changedetectionio.store:_rehydrate_watches:171 - Rehydrated 2 watches into Watch objects
Guarde esse 2 watches: numa instalação nova, ele já vem com dois monitoramentos de exemplo. A gente vai apagá-los daqui a pouco.
🔒 Por que ele não abre pelo IP (e ainda bem)
Agora vem a parte que confunde todo mundo: você abre http://SEU_IP_AQUI:5000 no navegador e... nada. A página não carrega.
Não é defeito. É o 127.0.0.1 daquela linha do ports. Ele diz ao Docker: "publique a porta 5000 só para dentro da própria VPS". Quem vem da internet não enxerga. O exemplo oficial do projeto vem assim de propósito, e faz sentido: a tela do changedetection.io mostra tudo o que você vigia e deixa mudar tudo, então ela não deveria ficar aberta para qualquer um.
Então como a gente abre a tela? Com um túnel SSH. Ele faz a porta 5000 do seu computador "virar" a porta 5000 da VPS, passando por dentro da conexão SSH, que já é criptografada e já pede senha. No terminal do seu computador (não na VPS):
ssh -N -L 5000:127.0.0.1:5000 root@SEU_IP_AQUI
Traduzindo: -L 5000:127.0.0.1:5000 liga a porta 5000 daqui ao 127.0.0.1:5000 de lá, e o -N diz "não quero abrir um terminal, só o túnel". Ele pede a senha, e depois parece que travou, sem mostrar nada. É assim mesmo: deixe essa janela aberta enquanto usa o changedetection.io. Fechou a janela, fechou o túnel.
Com o túnel aberto, no navegador do seu computador:
http://localhost:5000
🔑 A primeira coisa: senha na tela
O túnel já protege, mas eu não gosto de depender de uma camada só. O changedetection.io tem senha própria, e ela vem desligada. Então a primeira coisa que eu fiz foi ligá-la: clique em Settings, no alto da tela, e na aba General preencha o campo Password.
Repare também no Time Between Check dessa mesma tela: 3 horas. É o intervalo padrão de todos os monitoramentos, e dá para mudar em cada um deles.
Ao clicar em Save, ele te joga direto na tela de login. É a prova de que a senha pegou:
A senha fica guardada no volume, junto com o resto. Derrubei e recriei o contêiner (docker compose down e depois docker compose up -d) e a tela continuou pedindo a mesma senha, com os monitoramentos todos no lugar.
🧹 Limpando os exemplos
Lembra dos 2 watches do log? São o Hacker News e o changelog do próprio projeto. Para apagar, marque as duas caixinhas da lista, clique em Delete e confirme.
Só que eles deixam rastro: os grupos Tech news e changedetection.io continuam aparecendo no topo da lista, mesmo sem nenhum monitoramento dentro. Apagar o monitoramento não apaga o grupo. Para sumir com eles, abra Watch Groups (o ícone de camadas na barra lateral) e apague cada um por lá.
👀 Vigiando o meu blog
Hora do teste de verdade. No campo Web page URL, no topo da tela, colei o endereço da home do blog e cliquei em Watch. A opção marcada embaixo (Webpage Text/HTML, JSON and PDF changes) é a de comparar texto, que é o que eu quero. A outra serve para acompanhar preço e estoque de uma página de produto.
Ele faz a primeira visita na hora. Depois, clicando em Edit na linha do monitoramento, a aba General deixa dar um nome (Title), um grupo (Group Tag) e o intervalo próprio desse monitoramento. Para mudar o intervalo, desmarque Use global settings for time between check: os campos só valem com essa caixa desmarcada. Coloquei 1 hora.
🎯 O filtro CSS: só o que importa
Vigiar a página inteira funciona, mas avisa de tudo: um resumo de post editado, um item novo no menu, uma data. Eu queria saber de uma coisa só: saiu post novo?
Para isso existe o filtro. Na home do blog, cada título de post está num elemento com a classe cartao-titulo. (Para descobrir a classe de qualquer site, clique com o botão direito no texto que você quer, escolha Inspecionar, e olhe o class="..." que o navegador destaca.)
Na aba Filters & Triggers, o campo CSS/JSONPath/JQ/XPath Filters recebe o seletor. Um ponto antes do nome quer dizer "classe":
Salvei, e o botão Preview mostra o que ele passou a enxergar. Sai o menu, sai o rodapé, saem os resumos. Fica só a lista de títulos, na ordem da home:
Agora, quando um post novo aparecer, o aviso vai dizer exatamente qual título entrou.
😱 O RSS que mentia a cada checagem
Empolgada, cadastrei também o RSS do blog, o /rss/. Feed é ótimo para isso: é um arquivo feito para máquina ler, com a lista dos posts. Deixei a primeira visita acontecer, cliquei em Recheck três minutos depois e... mudou. 🤔
Mudou o quê, se eu não publiquei nada? Cliquei em History, que mostra a comparação entre duas visitas. Vermelho é o antes, verde é o depois:
A única linha diferente termina numa data: Thu, 24 Sep 2026 10:09:22 -0300 virou 10:12:12. Exatamente a hora de cada visita. É o <lastBuildDate> do feed, e o meu RSS preenche esse campo com a hora do acesso, não com a data do último post. 😳 O changedetection.io não errou: quem mentia era o meu feed, dizendo "mudei agora" a cada pedido. E, sem resolver isso, o alarme ia tocar a cada 3 horas pelo resto da vida.
Qualquer página com relógio, contador de visitas ou "atualizado há X minutos" tem esse mesmo problema. A solução está na mesma aba Filters & Triggers, logo abaixo do filtro: o campo Remove elements tira elementos da página antes de comparar. Ele aceita seletor CSS, e funcionou direto no XML do feed com o nome da tag:
Depois de salvar, o preview mostrou a linha da descrição terminando em pt-BR, sem a data. E para ter certeza, cliquei em Recheck mais duas vezes, com uns 15 segundos entre elas: a coluna Checked foi atualizando, a coluna Changed ficou parada, e o histórico não ganhou nenhuma versão nova. Alarme falso resolvido.
🔔 O aviso no celular
Detectar sem avisar não adianta nada. O changedetection.io manda notificações usando o Apprise, uma biblioteca que fala com dezenas de serviços (Telegram, Discord, e-mail, Slack...). Cada serviço é escrito como uma URL.
O mais simples de todos para começar é o ntfy: um serviço gratuito de notificação que não pede cadastro. Você inventa o nome de um "tópico", e quem assinar esse tópico recebe as mensagens. No celular, instale o app ntfy e assine o tópico; no computador, a página https://ntfy.sh/nome-do-topico mostra as mesmas mensagens.
Clique no sininho da barra lateral (Default Notifications) e, no campo Notification URL List, escreva ntfys:// seguido do seu tópico (o s no fim de ntfys é de "seguro", HTTPS). Essa configuração vale para todos os monitoramentos.
Aí cliquei em Send test notification e recebi... um arquivo. 😅 Em vez da mensagem, o ntfy mostrou "You received a file", com um .txt de 59 KB anexado. Abrindo o anexo, estava lá o diff inteiro da home do blog, em HTML, com um <span style="background-color: #fadad7..."> em cada linha.
São dois padrões que não combinam com celular: o formato HTML Color (que pinta o diff para e-mail) e o corpo com {{diff}}, que manda a comparação completa. O ntfy só aceita mensagem pequena; passou do limite, ele transforma em anexo.
A correção fica em Customise notifications, logo abaixo do campo da URL. Troquei o título, o corpo e, principalmente, o formato para Plain Text:
O modelo que eu usei, para você copiar. No título:
Mudou: {{watch_title}}
E no corpo:
{{watch_url}}
O que mudou:
{{diff_added_clean}}
As palavras entre chaves duplas são trocadas na hora do envio: {{watch_title}} vira o nome do monitoramento, {{watch_url}} o endereço, e {{diff_added_clean}} as linhas que mudaram, sem marcação nenhuma. A lista completa aparece em Show token/placeholders, na mesma tela.
Para ver um aviso de verdade chegando, sem mexer no blog, usei o próprio alarme falso: tirei o lastBuildDate do Remove elements do RSS e salvei. A data voltou para o texto, isso é uma mudança, e o aviso chegou em segundos:
Repare que a linha aparece duas vezes: a primeira é como ela estava, a segunda é como ficou (agora com a data no fim). Texto limpo, curto, legível no celular. Depois devolvi o lastBuildDate ao Remove elements, e o RSS voltou a ficar quieto.
Por hoje é só, meus unicórnios! 🦄✨
Que a magia do arco-íris continue brilhando em suas vidas! Até mais! 🌈🌟
Perguntas frequentes
O changedetection.io é gratuito?
Por que o changedetection.io não abre pelo IP da VPS?
docker-compose.yml oficial publica a porta só em 127.0.0.1:5000, de propósito. Para abrir a tela, crie um túnel SSH com ssh -N -L 5000:127.0.0.1:5000 root@SEU_IP_AQUI e acesse http://localhost:5000 no seu navegador.Por que o changedetection.io avisa mudança toda vez no meu RSS?
<lastBuildDate>, e cada visita traz um valor novo. Foi o que aconteceu com o meu. Coloque lastBuildDate no campo Remove elements da aba Filters & Triggers e a data sai antes da comparação.Como vigiar só um pedaço da página?
.cartao-titulo deixou só a lista de títulos dos posts. Lembre que salvar um filtro novo já conta como uma mudança, porque o texto guardado muda.A notificação chegou no ntfy como um arquivo .txt. Por quê?
{{diff}} inteiro, e a minha notificação de teste deu 59 KB. O ntfy só aceita mensagens pequenas e transformou tudo num anexo. Troque o formato para Plain Text e use {{diff_added_clean}} no corpo.Perco os monitoramentos se recriar o contêiner?
changedetection-data, montado em /datastore. Depois de um docker compose down e um docker compose up -d, o log mostrou Loaded 2 watches from disk e a tela continuou pedindo a mesma senha.Leia também
OpenSEO: instalando na VPS e auditando um site
Como instalar o OpenSEO, alternativa aberta ao Semrush, numa VPS com Docker, auditar um site de verdade e entender a chave da DataForSEO.
Project NOMAD: seu servidor offline de emergência
Instale o Project NOMAD numa VPS Ubuntu e tenha Wikipédia, mapas, cursos e IA funcionando sem nenhuma conexão com a internet.
7 repositórios open source que valem o clone
CRM com agente de IA, SEO sem Semrush, monitor de sites, clonador de páginas, raspagem, OCR e OSINT: sete projetos open source e a armadilha de cada um.