Pular para o conteúdo
Docker

changedetection.io: vigiando mudanças em sites na sua VPS

Uma coruja mágica no alto da torre de um castelo vigia com uma luneta janelas de sites flutuando, com linhas verdes e vermelhas marcando o que mudou, enquanto um unicórnio toca um sino dourado de aviso

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.

  1. Visitar: de tempos em tempos (o padrão é a cada 3 horas), ele baixa a página.
  2. Guardar: transforma o HTML em texto e guarda uma "foto" desse texto, que ele chama de snapshot.
  3. Comparar: na visita seguinte, compara o texto novo com o anterior, linha por linha.
  4. 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.

changedetection.io no GitHubO repositório oficial do projeto, com o docker-compose.yml de referência e a documentação.github.com

🖥️ 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 esse 127.0.0.1 na 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.

A aba General das configurações do changedetection.io, com o intervalo padrão de 3 horas entre checagens e o campo Password preenchido

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 tela de login do changedetection.io com o aviso You must be logged in, please log in e o campo Password

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.

O campo Web page URL do changedetection.io preenchido com https://blog.palomamacetko.com.br/ ao lado do botão Watch

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.

A aba General da edição do monitoramento, com a URL do blog, o grupo blog, o título Blog: lista de posts e o intervalo de 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":

A aba Filters and Triggers com o filtro .cartao-titulo no campo CSS/JSONPath/JQ/XPath Filters e o campo Remove elements vazio logo abaixo

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:

O preview do monitoramento Blog: lista de posts mostrando apenas os títulos dos artigos da home, um por linha

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 tela de diferenças do changedetection.io para o RSS do blog, com uma única linha alterada: a data no fim da descrição do feed passou de 10:09:22 para 10:12:12

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:

A aba Filters and Triggers do monitoramento do RSS com lastBuildDate escrito no campo Remove elements

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.

A lista de monitoramentos do changedetection.io com Blog: RSS e Blog: lista de posts, ambos no grupo blog, com as colunas Checked e Changed

🔔 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:

A tela Default Notifications do changedetection.io com a URL ntfys com o tópico, o título Mudou: {{watch_title}} e o corpo com {{watch_url}} e {{diff_added_clean}}

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:

Uma notificação no ntfy com o título Mudou: Blog: RSS, o endereço do feed e a linha da descrição do blog antes e depois, agora com a data no fim

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?
É um projeto de código aberto (licença Apache 2.0) e não cobra nada para rodar no seu servidor. Na minha VPS ele ficou entre 95 e 120 MB de memória, com uma imagem Docker de 1,66 GB. Os avisos pelo ntfy.sh também são gratuitos.
Por que o changedetection.io não abre pelo IP da VPS?
Porque o 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?
Porque o feed provavelmente carimba a hora do acesso no <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?
Na aba Filters & Triggers, escreva um seletor CSS no campo CSS/JSONPath/JQ/XPath Filters. No meu blog, .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ê?
O formato padrão é HTML Color com o {{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?
Não. Tudo fica no volume 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