[{"categories":["Linux","macOS"],"content":"Como já discuti anteriormente no post sobre secret management no macOS e no Linux, o grande problema de gerenciar chaves e tokens não é a criptografia em si, mas sim reduzir o vazamento acidental sem transformar a rotina do sysadmin num inferno burocrático. Nos últimos anos, porém, uma dupla de ferramentas ganhou espaço e mudou completamente essa dinâmica: o Mozilla SOPS e o age. Juntos, eles permitem uma abordagem declarativa (GitOps-friendly), extremamente segura e com fricção quase zero. Este post é uma análise detalhada sobre o funcionamento dessas ferramentas e como integrá-las de forma prática no seu dia a dia.\nage: Criptografia moderna para a vida real O age (criado por Filippo Valsorda) foi projetado com uma premissa simples: ser uma ferramenta de criptografia de arquivos moderna, segura e, acima de tudo, descomplicada. Ele foi feito para substituir o GPG nos casos de uso cotidianos de infraestrutura.\nEnquanto o GPG é um monólito com décadas de legado e suporte a dezenas de algoritmos obsoletos, o age usa criptografia de ponta por padrão (como X25519 e ChaCha20-Poly1305) e não tenta gerenciar sua identidade. Ele não tem \u0026ldquo;trust networks\u0026rdquo; ou base de dados local de chaves.\nUma chave do age é simplesmente um arquivo de texto contendo duas strings:\nA chave pública (que começa com age1...), usada para criptografar. A chave privada (que começa com AGE-SECRET-KEY-1...), usada para descriptografar. O age na prática Instalar o age é trivial em qualquer sistema. No macOS você usa brew install age, e na maioria das distribuições Linux ele já está nos repositórios oficiais (apt install age).\nPara gerar um par de chaves, basta rodar:\nage-keygen -o key.txt O arquivo gerado será parecido com isto:\n# created: 2026-05-26T07:15:00-03:00 # public key: age1y3d8q2u9vx8zux5mshq8387hws55v7pyll6zdfp4slx2p8nlyspqyv2pns AGE-SECRET-KEY-1R4X2QYLWMVPTQYP... Criptografar um arquivo usando a chave pública é direto:\nage -r age1y3d8q2u9vx8zux5mshq8387hws55v7pyll6zdfp4slx2p8nlyspqyv2pns -o segredos.enc segredos.txt E para descriptografar usando a chave privada:\nage -d -i key.txt segredos.enc É apenas isso. Sem demônios em background, sem chaves expiradas travando seu script, sem chaves públicas importadas em um banco de dados local. Apenas arquivos de texto e fluxos de entrada e saída.\nMozilla SOPS: Criptografia parcial e inteligente Embora o age resolva o problema da criptografia de arquivos de forma brilhante, ele ainda opera no nível do arquivo inteiro. Se você tem um arquivo YAML de configuração com 50 linhas e apenas duas delas são segredos (como a senha do banco de dados), criptografar o arquivo inteiro com age traz dois problemas clássicos no controle de versão:\nVocê perde a visibilidade do que mudou no Git (o diff vira apenas um bloco binário opaco). Resolver conflitos de merge em arquivos binários criptografados é virtualmente impossível. É aqui que entra o Mozilla SOPS (Secrets Operations).\nO SOPS é um editor de arquivos criptografados que suporta YAML, JSON, TOML, arquivos .env e binários. A sua grande sacada é a criptografia parcial: ele analisa a estrutura do seu arquivo e criptografa apenas os valores, mantendo as chaves estruturais em texto claro.\nO SOPS em ação Imagine que você tem o seguinte arquivo de configuração (secrets.yaml):\ndatabase: host: db.internal username: app_user password: \u0026#34;minha-senha-super-secreta\u0026#34; api: token: \u0026#34;sk_live_abcdef123456\u0026#34; Ao rodar o comando do SOPS configurado para usar a sua chave do age:\nsops --encrypt --age age1y3d8q2u9vx8zux5mshq8387hws55v7pyll6zdfp4slx2p8nlyspqyv2pns secrets.yaml \u0026gt; secrets.enc.yaml O arquivo secrets.enc.yaml resultante terá esta cara:\ndatabase: host: db.internal username: app_user password: ENC[AES256_GCM,data:lTqgB4e5aY1G8pQ=,iv:...,tag:...] api: token: ENC[AES256_GCM,data:2u7gB4a...,iv:...,tag:...] sops: age: - recipient: age1y3d8q2u9vx8zux5mshq8387hws55v7pyll6zdfp4slx2p8nlyspqyv2pns enc: | -----BEGIN AGE ENCRYPTED FILE----- ... -----END AGE ENCRYPTED FILE----- lastmodified: \u0026#34;2026-05-26T07:22:00Z\u0026#34; version: 3.9.0 Repare no quanto isso é elegante:\nAs chaves database.host e database.username permanecem legíveis. Qualquer um que ler o repositório sabe que o host é db.internal. Apenas os valores confidenciais (password e token) foram criptografados com AES-256-GCM. Os metadados sobre como descriptografar o arquivo (incluindo a chave pública do destinatário) ficam anexados na chave sops. No Git, se você alterar o host do banco de dados, o diff mostrará exatamente essa linha mudando. Se você rotacionar a senha, apenas a linha do valor criptografado mudará. O diff continua limpo, legível e útil.\nAlém disso, editar o arquivo é extremamente transparente. Se você definir a variável de ambiente SOPS_AGE_KEY_FILE=/path/to/key.txt e rodar:\nsops secrets.enc.yaml O SOPS lerá a sua chave privada, descriptografará o arquivo temporariamente na memória, abrirá o seu editor padrão (definido na variável $EDITOR), esperará você fazer as alterações, re-criptografará o arquivo e o salvará de volta no disco assim que o editor for fechado. A chave privada nunca toca o arquivo salvo e o texto claro nunca toca o disco.\nO poder do arquivo .sops.yaml Ficar passando chaves públicas na linha de comando toda vez que você criptografa um arquivo é inviável e propício a erros. O SOPS resolve isso através de um arquivo de configuração declarativo colocado na raiz do seu repositório: o .sops.yaml.\nEste arquivo define regras sobre quais chaves devem criptografar quais arquivos baseando-se em expressões regulares.\nAqui está um exemplo real de infraestrutura:\ncreation_rules: # Regra para segredos de produção - path_regex: secrets/production/.*\\.yaml$ key_groups: - age: - age1y3d8q2u9vx8zux5mshq8387hws55v7pyll6zdfp4slx2p8nlyspqyv2pns # Dev Principal (Janio) - age1r78k4u9vx8zux5mshq8387hws55v7pyll6zdfp4slx2p8nlq928asl0212 # Chave do Servidor de Prod - age1backup9u9vx8zux5mshq8387hws55v7pyll6zdfp4slx2p8nlyspqkey00 # Chave de Backup Fria # Regra para segredos de staging/desenvolvimento - path_regex: secrets/staging/.*\\.yaml$ key_groups: - age: - age1y3d8q2u9vx8zux5mshq8387hws55v7pyll6zdfp4slx2p8nlyspqyv2pns # Dev Principal - age1dev01u9vx8zux5mshq8387hws55v7pyll6zdfp4slx2p8nlyspqyvdev01 # Chave do time de desenvolvimento Com essa estrutura, quando qualquer pessoa do time executa sops secrets/production/app.yaml, o SOPS lê a configuração automaticamente e criptografa o arquivo de forma que qualquer um dos três destinatários listados na regra de produção consiga descriptografá-lo usando sua respectiva chave privada do age.\nIsso elimina a necessidade de compartilhar chaves privadas. O desenvolvedor usa a chave dele, o servidor usa a chave dele, e o backup usa a chave dele.\nAdicionando novas máquinas e rotacionando chaves Se você precisar autorizar um novo servidor a ler os segredos de produção, o fluxo é simples:\nGere uma chave age no novo servidor. Pegue a chave pública e adicione-a na lista de destinatários em .sops.yaml. No seu computador de administrador, aplique a nova configuração aos arquivos existentes rodando: sops updatekeys secrets/production/app.yaml O SOPS vai descriptografar o arquivo usando a sua chave privada e re-criptografá-lo adicionando o novo servidor à lista de destinatários. Em seguida, basta commitar as mudanças no Git.\nComo consumir os segredos na prática (Sem fricção) Embora o ecossistema sops + age seja incrivelmente robusto para armazenamento, você ainda precisa ler esses segredos para utilizá-los nos seus scripts, deploys e aplicações. A boa notícia é que o SOPS oferece várias maneiras de fazer isso com fricção mínima.\n1. Injetando segredos no ambiente via CLI (sops exec-env) Se você tem um script, container ou aplicação que espera encontrar as chaves de API e senhas como variáveis de ambiente tradicionais, você não precisa mantê-las salvas em arquivos .env em texto puro. O SOPS possui um comando específico para descriptografar os segredos em memória e injetá-los no processo da sua aplicação:\nsops exec-env secrets.enc.yaml \u0026#39;npm run start\u0026#39; O comando npm run start será iniciado com acesso a todas as variáveis definidas no arquivo descriptografado, mas assim que o processo terminar, essas variáveis deixam de existir. Nenhum segredo foi persistido em texto claro no disco.\n2. Lendo segredos estruturados em scripts de Shell Se você precisa extrair apenas um valor específico de dentro de um arquivo YAML criptografado no seu script de automação, você pode combinar o SOPS com o utilitário yq (ou o próprio parser interno do SOPS):\n# Usando o extrator nativo do SOPS DB_PASS=$(sops -d --extract \u0026#39;[\u0026#34;database\u0026#34;][\u0026#34;password\u0026#34;]\u0026#39; secrets.enc.yaml) Isso é ideal para scripts de backup ou deploys automatizados via SSH, permitindo ler exatamente o segredo necessário na hora do uso.\n3. Integração direta no seu código (Python) Em linguagens de programação, você pode ler os arquivos criptografados do SOPS invocando o binário via subprocesso de forma limpa, retornando um dicionário estruturado diretamente em memória.\nAqui está um helper simples em Python para carregar segredos sem nunca tocá-los no disco em formato legível:\nimport json import subprocess def load_sops_secrets(filepath: str) -\u0026gt; dict: # Decodifica o arquivo diretamente na memória em formato JSON result = subprocess.run( [\u0026#34;sops\u0026#34;, \u0026#34;-d\u0026#34;, \u0026#34;--output-type\u0026#34;, \u0026#34;json\u0026#34;, filepath], capture_output=True, text=True, check=True ) return json.loads(result.stdout) # Uso prático secrets = load_sops_secrets(\u0026#34;secrets.enc.yaml\u0026#34;) db_password = secrets[\u0026#34;database\u0026#34;][\u0026#34;password\u0026#34;] Essa abordagem garante que mesmo se o servidor for comprometido e o disco for copiado, os segredos continuarão seguros (já que a chave do age pode estar isolada sob permissões restritas em /etc/ ou apenas carregada no agent do servidor).\nConclusão: Segurança real é aquela que você realmente usa Uma das maiores lições na administração de sistemas é que a segurança é inversamente proporcional à fricção. Se um método de segurança exige 10 passos manuais complexos, as pessoas vão encontrar um atalho — e esse atalho geralmente é uma senha em plaintext em um arquivo desprotegido.\nA combinação de SOPS + age brilha porque ela remove o \u0026ldquo;teatro de segurança\u0026rdquo;. Ela permite que você mantenha uma infraestrutura 100% declarativa, versionada e segura no Git, sem precisar gerenciar chaves GPG complexas ou provisionar servidores de segredos pesados para ambientes pequenos e médios.\nQuando você integra essa fundação na sua rotina de automação, o gerenciamento de segredos deixa de ser um peso operacional para se tornar uma parte natural, fluida e transparente do desenvolvimento.\nE você? Como gerencia os segredos na sua infraestrutura hoje? Já deu uma chance para o age ou ainda sofre com chaves GPG? Compartilhe sua experiência nos comentários abaixo.\n","date":"26/05/2026","lang":"pt","tags":["security","devops","sops","age","gitops"],"title":"SOPS + age: Gerenciamento de segredos declarativo, seguro e sem a dor de cabeça do GPG","url":"https://devops.sarmento.org/posts/sops-e-age-gerenciamento-de-segredos-na-pratica/"},{"categories":["macOS","Linux"],"content":"Existe um momento na vida de praticamente todo sysadmin em que ele percebe que espalhou segredos demais pelo ambiente: uma senha em .env aqui, um token no histórico do shell ali, um webhook esquecido dentro de um docker-compose.yml, uma API key hardcoded num script “temporário” que continua rodando dois anos depois. Nada disso parece grave isoladamente; o problema é que a infraestrutura quase nunca quebra por uma decisão única e gigantesca. Ela quebra pelo acúmulo de pequenas concessões feitas em nome da praticidade.\nE honestamente: a maior parte dos vazamentos não acontece por ataques cinematográficos. Acontece porque alguém:\ncommitou um arquivo errado; fez backup demais; exportou uma variável globalmente; enviou screenshot sem ofuscar informações sensíveis; deixou logs verbosos habilitados; reutilizou configuração antiga sem revisar o conteúdo. Quanto mais tempo você administra sistemas, mais percebe que gerenciamento de segredos não trata de construir uma fortaleza perfeita, e sim de reduzir a exposição acidental.\nO problema das variáveis de ambiente Variáveis de ambiente são muito úteis: simplificam automação, integração entre aplicações, CI/CD e configuração de serviços. O problema começa quando elas deixam de ser uma ferramenta e viram o único mecanismo de gerenciamento de segredos da infraestrutura inteira.\nTem uma diferença enorme entre:\nMYSQL_PASSWORD=\u0026#34;$(pass show production/mysql)\u0026#34; ./backup.sh e:\nexport MYSQL_PASSWORD=\u0026#34;super-secret-password\u0026#34; No primeiro caso, o segredo existe temporariamente para aquele processo específico. No segundo, ele passa a existir em toda a sessão do shell — e potencialmente em qualquer processo filho.\nDependendo do ambiente, variáveis podem aparecer em:\ndumps de processos; ferramentas de debug; logs; sessões de troubleshooting; scripts de suporte; históricos de terminal; monitoramento; crash reports. Muita gente trata .env como se fosse criptografia, o que está muito distante da verdade: é só um arquivo de texto com uma extensão socialmente aceita.\nO Keychain do macOS é melhor do que muita gente imagina No ecossistema Linux existe uma tendência quase automática de subestimar soluções da Apple, mas o Keychain do macOS resolve vários problemas de maneira surpreendentemente elegante.\nEle integra:\narmazenamento criptografado; sessão do usuário; desbloqueio biométrico; permissões por aplicação; experiência transparente no desktop. Adicionar um segredo via terminal é simples:\nsecurity add-generic-password \\ -a \u0026#34;$USER\u0026#34; \\ -s \u0026#34;github-token\u0026#34; \\ -w \u0026#34;YOUR_TOKEN_HERE\u0026#34; Recuperar depois também:\nTOKEN=\u0026#34;$(security find-generic-password \\ -a \u0026#34;$USER\u0026#34; \\ -s \u0026#34;github-token\u0026#34; \\ -w)\u0026#34; Isso já elimina vários problemas comuns:\ntokens esquecidos em arquivos; export permanente no shell; duplicação de segredos; cópia manual entre máquinas. O mais interessante é que o Keychain raramente entra em discussões modernas de DevOps porque a conversa inteira parece girar em torno de Kubernetes, Vault e YAMLs infinitos. Só que muita gente trabalha em ambientes menores, mais diretos e mais próximos do mundo real cotidiano de administração de sistemas.\nE, nesses cenários, o Keychain resolve bastante coisa.\nLinux: liberdade, fragmentação e escolhas demais No Linux, a história muda completamente.\nNão existe uma solução dominante equivalente ao Keychain. Em vez disso, existe um ecossistema inteiro de ferramentas parcialmente sobrepostas:\nGNOME Keyring; KWallet; pass; gopass; sops; Vault; secrets do systemd; Docker secrets; ferramentas específicas de provedores de cloud. Essa flexibilidade é poderosa, mas também produz um efeito colateral inevitável: muita gente nunca define uma estratégia consistente.\nResultado:\nmetade dos segredos fica em .env; outra parte em Ansible Vault; alguns tokens em shell scripts; certificados espalhados manualmente; backups contendo tudo. Depois de alguns anos, a infraestrutura vira arqueologia operacional.\nO charme — e o sofrimento — do pass Existe algo quase irresistível no pass.\nA ideia é brilhante na simplicidade:\ncada segredo é um arquivo; tudo é criptografado com GPG; Git pode versionar o repositório; shell integration funciona muito bem. Exemplo:\npass insert production/mysql Depois:\nMYSQL_PASSWORD=\u0026#34;$(pass show production/mysql)\u0026#34; É simples, auditável e extremamente Unix-like.\nO problema é que o pass herda toda a complexidade emocional do GPG.\nE qualquer sysadmin que já precisou:\nrenovar chaves; importar chaves em máquinas novas; explicar trust levels; resolver problemas de agente; recuperar ambiente quebrado; sabe exatamente o tamanho da dor de cabeça que isso pode virar.\nMesmo assim, continuo achando o pass uma solução excelente para ambientes pequenos e médios.\nO que mudou bastante nos últimos anos: sops e age Uma mudança interessante nos últimos anos foi a popularização do sops, principalmente combinado com age.\nA abordagem é diferente do pass.\nEm vez de armazenar segredos isoladamente, você consegue criptografar parcialmente arquivos YAML, JSON ou TOML inteiros.\nPor exemplo:\nsops secrets.yaml E dentro do arquivo:\ndatabase: host: db.internal username: app password: ENC[AES256_GCM,data:...] Isso funciona muito bem com:\nAnsible; Terraform; repositórios Git; infraestrutura declarativa; automação em geral. Além disso, age é absurdamente mais agradável de usar do que GPG na maioria dos cenários.\nNão porque seja “mais mágico”, mas porque reduz drasticamente a quantidade de atrito operacional.\nO erro mais comum: transformar segredo em configuração comum Esse provavelmente é o erro que mais vejo (e que — mea culpa — mais cometo).\nCom o tempo, segredos deixam de ser tratados como informação sensível e passam a ser tratados como simples configuração operacional.\nAcontece quando:\ntokens vão parar em templates; compose files acumulam senhas; backups incluem tudo indiscriminadamente; scripts carregam variáveis permanentes; arquivos são copiados entre servidores sem revisão; ambientes temporários viram permanentes. Em algum momento, alguém inevitavelmente faz:\ngrep -R PASSWORD . e descobre um cemitério inteiro de decisões antigas.\nSegurança real vs. teatro operacional Existe muito teatro em segurança:\nBase64 chamado de criptografia. “Secret” do Kubernetes armazenado praticamente em plaintext. Senha mascarada em CI, mas impressa em logs verbosos. Arquivo .env.enc cuja chave está no mesmo repositório. Às vezes a infraestrutura inteira parece segura porque produz uma sensação de complexidade suficiente para ninguém mais questionar.\nSó que a segurança operacional raramente vem da ferramenta mais sofisticada. Ela normalmente vem de:\nsuperfície reduzida; previsibilidade; simplicidade; auditoria possível; menos lugares contendo segredos. As ferramentas ajudam, mas a disciplina operacional ajuda mais.\nO que eu tento fazer hoje Hoje eu tento seguir algumas regras relativamente simples:\nevitar export permanente de variáveis; reduzir a quantidade de cópias do mesmo segredo; manter tokens fora de repositórios; evitar segredos hardcoded em automação; tratar backups como material sensível; preferir soluções auditáveis e simples. Também tento evitar transformar gerenciamento de segredos em religião tecnológica.\nNem todo ambiente precisa de Vault. Nem todo servidor precisa de uma arquitetura cloud-native inteira para armazenar duas chaves de API e uma senha de banco de dados.\nÀs vezes um ambiente pequeno, previsível e bem entendido é mais seguro do que uma pilha gigantesca que ninguém realmente compreende.\nConclusão Gerenciamento de segredos não é esconder tudo dentro de uma fortaleza impenetrável, \u0026ldquo;apenas\u0026rdquo; reduzir a exposição desnecessária, impedir vazamentos acidentais e evitar que a conveniência operacional vire dívida permanente.\nPorque, no mundo real, a maior parte dos problemas começa de maneira muito menos dramática do que as apresentações de segurança gostam de mostrar. Normalmente começa com alguém dizendo:\n— Depois eu organizo isso direito.\n","date":"17/05/2026","lang":"pt","tags":["secret-management","keychain","linux","macos","shell","segurança","devops","automatização","privacidade","backup"],"title":"Secret management no macOS e no Linux: uma abordagem teórico-prática","url":"https://devops.sarmento.org/posts/secret-management-macos-linux/"},{"categories":["Sites Estaticos"],"content":"No post sobre o Hugin apresentei a ferramenta que uso para gerar tags e resumos nos meus blogs Hugo. Duas semanas depois, no post sobre o Munin, mostrei o irmão dele: um segundo programa que descobre e insere links internos entre posts usando embeddings e LLM.\nOs dois funcionavam bem. Separados, funcionavam bem.\nO problema de ter dois programas Na teoria, dividir responsabilidades entre ferramentas é uma boa prática. Na prática, o fluxo para processar um post novo era assim: abrir o Hugin, navegar até o post, gerar tags, gerar resumo, fechar o Hugin. Abrir o Munin, esperar o modelo de embeddings carregar, navegar até o mesmo post, verificar links existentes, gerar sugestões de links, aplicar. Se precisasse corrigir um typo no título que só apareceu depois de olhar o post no Hugin, fechar tudo e abrir o Pages CMS ou o vim.\nCom 5 posts novos por semana, isso era um ritual de 20 minutos que poderia ser 5. O atrito não estava nas funcionalidades — estava na troca de contexto. Cada vez que eu saía de um programa e entrava no outro, perdia o fio do que estava fazendo. E a necessidade de ir ao Pages para corrigir uma frase ou um título com typo era o insulto final.\nA decisão de unificar Não tinha jeito: ou eu continuava convivendo com a fricção ou fundia tudo. Escolhi fundir. O Munin deixou de existir como programa separado, e toda a sua funcionalidade foi absorvida pelo Hugin. O resultado é um único comando que faz tudo: tags, resumos, links internos, sugestões de tópicos e edição de posts.\nO que antes eram dois programas com interfaces quase idênticas mas funções distintas virou uma única tela com todas as ações disponíveis por tecla. Você navega até um post e tem tudo ali: t para tags, s para resumo, i para links de entrada, o para links de saída, l para listar e remover links, u para sugestões de tópicos novos, e para editar o post. Sem fechar, sem reabrir, sem relocalizar.\nO que mudou Editor embutido A novidade mais significativa é o editor interno. Ao teclar e, abre uma tela cheia com campos editáveis para cada campo do frontmatter — título, data, descrição, slug, draft — e um TextArea com syntax highlighting de Markdown para o corpo do post. Tags aparecem como read-only porque têm o t dedicado para isso.\nO save é atômico: escreve num arquivo temporário e renomeia, exatamente como o Munin já fazia para links. Se o processo morrer no meio da escrita, o arquivo original continua intacto. E se você sair sem salvar, uma confirmação impede perdas acidentais.\nIsso eliminou a necessidade de ir ao Pages CMS ou abrir o vim só para corrigir um parágrafo. As correções rápidas acontecem ali mesmo, sem sair do fluxo.\nIndicador de loading Os spinners discretos na lista de posts foram substituídos por um modal com fundo semitransparente que aparece durante operações com o LLM. Não tem como não perceber que o programa está trabalhando. A mensagem muda conforme a operação: \u0026ldquo;Generating tags\u0026hellip;\u0026rdquo;, \u0026ldquo;Finding outgoing links\u0026hellip;\u0026rdquo;, \u0026ldquo;Suggesting new topics\u0026hellip;\u0026rdquo;.\nParâmetros por projeto Cada blog agora tem seu próprio arquivo de configuração com parâmetros que afetam o comportamento do LLM. Acessível pela tecla p:\nPalavras do resumo — o número alvo que vai no prompt. Meu blog pessoal usa 28, o técnico usa 20. Estilo do resumo — uma instrução de tom que vai direto para o LLM. Em vez de escolher entre presets como \u0026ldquo;formal\u0026rdquo; ou \u0026ldquo;casual\u0026rdquo;, você escreve exatamente o que quer: \u0026ldquo;Write it engaging, causing on the reader the wish to read the full post.\u0026rdquo; Palavras por link — controla a densidade de links internos. Um blog com 400 posts pode se dar ao luxo de ser liberal (150 palavras por link). Um com 18 precisa ser mais conservador (400 ou mais). Os parâmetros do projeto têm prioridade sobre os defaults globais. Se você não configurar nada, os valores do links.toml continuam valendo.\nDrafts fora do embedding Posts em rascunho não aparecem mais como candidatos para links internos. E se você mudar um post publicado para draft, ele é automaticamente removido do índice de embeddings na próxima execução. Isso evita sugestões de links para posts que não existem no site publicado.\nCache mais confiável Dois problemas que me morderam em produção foram corrigidos. Primeiro: posts que tinham sido deletados ficavam como fantasmas no cache de embeddings, gerando sugestões de links para páginas que não existiam mais. Agora o find_similar verifica se o arquivo ainda existe no disco antes de retornar um resultado.\nSegundo: URLs ficavam desatualizadas se a configuração de permalinks do Hugo mudasse. O Hugin agora re-resolve as URLs de todas as entradas do cache a cada inicialização, sem precisar limpar e reconstruir tudo.\nPersistência entre sessões O tema do Textual (aquela paleta de cores que você escolhe com Ctrl+P) agora persiste entre execuções. E o post que estava selecionado quando você saiu do programa é restaurado automaticamente na próxima vez. Pequenos confortos que fazem diferença quando você usa a ferramenta todo dia.\nO que ficou igual A arquitetura fundamental não mudou. O Hugin continua sendo uma TUI em Python que fala com qualquer endpoint compatível com a API OpenAI. Os motores, API keys e seleção de modelo funcionam exatamente como antes. Os prompts de tags e o normalizador são os mesmos. O sistema de embeddings continua local, sem gastar tokens. A escrita de links continua respeitando zonas protegidas do Markdown.\nSe você já usava o Hugin e o Munin separados, a transição é simples: atualize o repositório e reinstale. O comando munin deixou de existir — tudo é hugin agora. O arquivo de configuração munin.toml continua sendo lido como fallback se links.toml não existir, então nada quebra.\nFluxo atual Meu fluxo para processar um post novo agora é LOST:\nL (List) — vejo os links que já existem no post O (Outgoing) — peço sugestões de novos links S (Summary) — gero o resumo T (Tags) — gero as tags Tudo num único programa, sem trocar de janela, sem recarregar, sem perder o contexto. Se preciso corrigir algo no texto, e abre o editor ali mesmo.\nCódigo O Hugin é open source:\nRepositório: github.com/janiosarmento/hugin\n","date":"18/04/2026","lang":"pt","tags":["hugo","blog","open-source","tags","links-internos"],"title":"Hugin on steroids: tags, links e edição numa só TUI","url":"https://devops.sarmento.org/posts/hugin-on-steroids-unificando-tags-links-e-edicao-numa-so-tui/"},{"categories":["Sites Estaticos"],"content":"No post sobre o Hugin contei como resolvi o problema de tags e resumos no meu blog Hugo. Mas tinha outro problema, menos óbvio e mais chato: links internos. Aqueles links que conectam um post a outro, que ajudam o leitor a navegar pelo conteúdo relacionado, e que o Google adora ver num site bem estruturado.\nO problema é que ninguém linka nada. Você escreve um post sobre systemd timers, outro sobre cron, outro sobre launchd — e nenhum dos três menciona os outros. São ilhas de conteúdo que poderiam estar conectadas. A solução óbvia é reler cada post, lembrar quais outros existem, e ir inserindo links manualmente. Com 30 posts, é viável. Com 400, é insano.\nEntão construí o Munin.\nO que é Munin é o irmão do Hugin — o segundo corvo de Odin, o da memória. Enquanto o Hugin pensa (gera tags e resumos), o Munin lembra (encontra conexões entre posts). Na prática, é outra TUI em Python que varre o mesmo diretório de posts, mas em vez de gerar metadados, descobre onde inserir links internos.\nComo encontra posts relacionados Munin usa embeddings semânticos. Na primeira execução, baixa um modelo multilíngue (~400 MB, uma vez só) e gera um vetor para cada post baseado no título, tags e descrição. Esses vetores são guardados em cache e atualizados automaticamente quando um post muda.\nQuando você seleciona um post, o Munin calcula a similaridade coseno contra todos os outros. Não é busca por palavras-chave — é compreensão semântica. Um post sobre \u0026ldquo;agendamento de tarefas no Linux\u0026rdquo; vai encontrar o post sobre \u0026ldquo;systemd timers\u0026rdquo; mesmo que as palavras sejam diferentes.\nTudo isso é local, sem LLM, sem gastar tokens. O cálculo é instantâneo.\nLinks de entrada e saída A interface mostra duas operações para cada post:\nIncoming (i) mostra quais posts poderiam linkar para este. É a pergunta inversa: \u0026ldquo;quem no meu blog deveria estar apontando para cá?\u0026rdquo;. É útil para identificar oportunidades que você perdeu. Os resultados são links clicáveis que navegam direto para o post na lista.\nOutgoing (o) é onde o LLM entra. O Munin pega os candidatos que os embeddings encontraram e manda para o modelo junto com o corpo completo do post. O prompt pede para encontrar trechos exatos no texto que serviriam como âncora natural para cada candidato.\nCada sugestão aparece com contexto — o texto ao redor do trecho que será linkado, com a âncora destacada em bold. Você sabe exatamente o que vai acontecer antes de aprovar.\nSegurança do Markdown O Munin nunca insere links dentro de blocos de código, headings, code inline, imagens ou links que já existem. Antes de mostrar uma sugestão, verifica se o trecho está numa zona segura do Markdown. Se o parágrafo já tem um link, respeita o limite configurável por parágrafo.\nSe o LLM sugerir um trecho que não existe verbatim no post — e isso acontece — o Munin tenta uma vez corrigir. Se não conseguir, descarta silenciosamente. Nada de links quebrados ou texto alterado.\nOrçamento de links Nem todo post precisa de oito links internos. O Munin calcula um orçamento baseado no tamanho do post: um link a cada 300 palavras, com um teto de 8 por post. Posts muito curtos recebem um aviso no painel de metadados e nem oferecem a opção de buscar links.\nPosts que já foram analisados sem resultado ficam marcados com um indicador visual que persiste entre sessões — para você não perder tempo tentando de novo.\nSugestão de novos posts Uma funcionalidade que surgiu quase por acidente: ao pressionar s, o Munin pede ao LLM para sugerir tópicos de novos posts baseados no conteúdo atual. Antes de mostrar a lista, verifica nos embeddings se algum desses tópicos já existe no blog. O resultado são lacunas reais de conteúdo — ideias para posts que complementariam o que você já tem.\nNa prática munin ~/blog/content/posts Na primeira vez, o modelo de embeddings é baixado e todos os posts são indexados. Nas execuções seguintes, só posts novos ou editados são reprocessados. A interface mostra a contagem de links de entrada e saída nos metadados de cada post, então você vê de relance quais estão bem conectados e quais são ilhas.\nO fluxo típico: navega até um post, aperta o, revisa as sugestões com contexto, marca as que fazem sentido, aplica. O arquivo é salvo com escrita atômica (arquivo temporário + rename) para nunca corromper dados. O cache é atualizado automaticamente.\nCompartilhado com o Hugin Munin mora no mesmo repositório e compartilha a infraestrutura: engines, seleção de modelo, configuração. Se você já configurou o Hugin, o Munin funciona sem setup adicional. A mesma tecla e abre o mesmo seletor de engine nos dois.\nA configuração própria do Munin fica em ~/.hugin/munin.toml — limites de links, modelo de embeddings, campo de frontmatter para usar nas buscas. Os defaults funcionam bem para a maioria dos blogs.\nStack Python 3.11+, Textual para a TUI, sentence-transformers com backend ONNX para embeddings (evita o peso completo do PyTorch), httpx para as chamadas ao LLM, python-frontmatter para ler e escrever YAML. O cache de embeddings é um JSON por diretório.\nCódigo Hugin e Munin são open source e vivem no mesmo repositório:\nRepositório: github.com/janiosarmento/hugin\n","date":"17/04/2026","lang":"pt","tags":["hugo","blog","automatização","links-internos","embeddings","open-source","markdown","systemd"],"title":"Munin: links internos para Hugo com IA","url":"https://devops.sarmento.org/posts/munin-links-internos-para-hugo-com-ia/"},{"categories":["Sites Estaticos"],"content":"Quem mantém um blog estático com Hugo sabe que existem duas tarefas que ninguém gosta de fazer: classificar posts com tags e escrever meta descriptions. São aquelas coisas que você pula na hora de publicar porque o post já está pronto, o deploy já está configurado, e ficar escolhendo entre \u0026ldquo;selfhosted\u0026rdquo; e \u0026ldquo;self-hosted\u0026rdquo; não é exatamente o uso mais nobre do seu tempo. O resultado é previsível: posts sem tags, descriptions vazias ou copiadas do primeiro parágrafo, e uma taxonomia que mais atrapalha do que ajuda.\nEu estava nessa situação com dois blogs — um com quase 400 posts, outro crescendo rápido. Tags inconsistentes, posts sem nenhuma classificação, descriptions genéricas. Ferramentas de IA resolveriam parte do problema, mas nenhuma encaixava no fluxo que eu queria: algo que lesse os posts, sugerisse tags e resumos, mas me deixasse aprovar tudo antes de mexer nos arquivos. Sem surpresas, sem commits automáticos, sem tag inventada que eu nunca usaria.\nEntão construí o Hugin.\nO que é Hugin é uma TUI (terminal user interface) em Python que abre um diretório de posts Hugo, lista todos por data de publicação, e oferece duas operações principais: gerar tags e gerar resumos. Nos dois casos, o conteúdo do post é enviado a um LLM, a resposta é normalizada e apresentada para revisão antes de tocar no arquivo.\nO nome vem de Huginn, um dos corvos de Odin na mitologia nórdica — o corvo do pensamento, que voa pelo mundo coletando informações e reporta ao dono. Também rima com Hugo, o que não é coincidência.\nO problema das tags A parte mais irritante de manter tags consistentes em um blog não é criar tags novas — é lembrar quais já existem. Se você tem 400 posts e 60 tags únicas, é questão de tempo até aparecer \u0026ldquo;selfhosted\u0026rdquo; em um post e \u0026ldquo;self-hosted\u0026rdquo; em outro. Ou \u0026ldquo;automatização\u0026rdquo; e \u0026ldquo;automação\u0026rdquo; convivendo como se fossem conceitos diferentes.\nHugin resolve isso de duas formas. Primeiro, coleta todas as tags existentes no blog e envia ao LLM ordenadas por frequência de uso, com instrução explícita para preferir tags que já existem. Segundo, passa cada tag gerada por um normalizador que aplica lowercase, substitui espaços por hífens, remove artigos e deduplica contra o pool existente. Se o LLM sugerir \u0026ldquo;O Docker\u0026rdquo; para um post, o normalizador transforma em \u0026ldquo;docker\u0026rdquo; — que provavelmente já está no pool.\nTags novas aparecem marcadas com um indicador visual na interface. Se o LLM sugeriu algo que não existe no blog, você sabe antes de aplicar. E se quiser adicionar tags manualmente — porque às vezes o LLM simplesmente não sugere o óbvio — tem um campo de texto para isso.\nO problema dos resumos Meta descriptions são aqueles textos de 150 caracteres que aparecem nos resultados de busca. Todo mundo sabe que são importantes, ninguém gosta de escrevê-los. O resultado típico é um description vazio (o Hugo usa os primeiros caracteres do post) ou uma frase genérica que não diz nada sobre o conteúdo.\nHugin gera resumos entre 140 e 160 caracteres, no idioma do post, com uma persona de blogueiro escrevendo sobre seu próprio trabalho — não de redator SEO. Ou seja: nada de \u0026ldquo;Descubra como\u0026hellip;\u0026rdquo; ou \u0026ldquo;Aprenda a\u0026hellip;\u0026rdquo;. Se o resumo ficar longo demais, o LLM é automaticamente chamado de novo para encurtar. O resultado aparece com contagem de caracteres ao lado, e só é gravado se você aprovar.\nGerenciamento de tags Conforme o blog cresce, a taxonomia precisa de manutenção. Hugin tem uma tela dedicada para isso: uma lista de todas as tags ordenadas por frequência, com operações de renomear, mesclar e excluir. Mesclar é particularmente útil — seleciona a tag de origem, escolhe o destino, e todos os posts são atualizados de uma vez. Se você tem \u0026ldquo;linux\u0026rdquo; em 20 posts e \u0026ldquo;gnu-linux\u0026rdquo; em 3, resolve em dois toques.\nAgnóstico ao modelo Hugin não depende de nenhum provedor específico de IA. Qualquer endpoint compatível com a API OpenAI funciona: Cerebras, Groq, DeepSeek, OpenAI, LM Studio, Ollama. Os motores são cadastrados em um arquivo TOML simples, e a seleção de engine e modelo é feita direto na interface — inclusive com listagem automática dos modelos disponíveis no endpoint.\nNa prática, tenho usado o Cerebras para a maioria dos posts (rápido e barato) e o LM Studio com Gemma local para testes. Trocar entre eles é uma tecla.\nComo funciona na prática Você abre o Hugin apontando para o diretório de posts:\nhugin ~/blog/content/posts A interface mostra todos os posts em uma tabela navegável. Seleciona um, tecla t para tags ou s para resumo. Um spinner aparece enquanto o LLM trabalha. Quando a resposta chega, as sugestões aparecem com checkboxes — desmarca o que não quer, marca o que quer, aplica. O frontmatter é atualizado, o arquivo é salvo, e você continua para o próximo post.\nNenhum token é gasto até você pedir. Nenhuma alteração é feita até você aprovar.\nStack Para quem se interessa pelo lado técnico: Python 3.11+, Textual para a TUI, Click para o CLI, httpx para as chamadas assíncronas ao LLM, python-frontmatter para ler e escrever YAML. Sem banco de dados, sem daemon, sem dependências pesadas.\nCódigo O Hugin é open source e está disponível no GitHub. Se você mantém um blog Hugo e está cansado de classificar posts manualmente, talvez ele resolva o seu problema também.\nRepositório: github.com/janiosarmento/hugin\n","date":"11/04/2026","lang":"pt","tags":["hugo","blog","tags"],"title":"Hugin: tags e resumos para Hugo com IA","url":"https://devops.sarmento.org/posts/hugin-tags-e-resumos-para-hugo-com-ia/"},{"categories":["Sites Estáticos"],"content":"Quem trabalha com WordPress por tempo suficiente desenvolve uma intuição sobre o que um tema é e o que ele faz. Essa intuição funciona bem dentro do ecossistema — ela guia decisões sobre estrutura de arquivos, sobre onde colocar lógica e sobre como estender funcionalidades. O problema aparece quando você migra para o Hugo e tenta aplicar esse mesmo modelo mental.\nAs palavras são parecidas — templates, layouts, partials — mas o que elas significam na prática é radicalmente diferente. Um tema no WordPress é, em essência, uma aplicação PHP completa que consulta banco de dados, toma decisões de lógica e renderiza HTML, tudo no mesmo lugar. Um tema no Hugo é uma coleção de templates que recebe dados já processados e se limita a apresentá-los. Parece uma diferença sutil até você sentar para construir o seu primeiro layout e perceber que quase tudo que você sabia precisa ser reaprendido.\nEste post não tenta substituir a documentação do Hugo nem ser um tutorial de criação de temas — se você procura um guia prático para montar um blog com Hugo, Pages CMS e Cloudflare, veja Como criei este blog sem gastar um centavo. O objetivo aqui é mapear as diferenças conceituais entre os dois mundos — o modelo mental do WordPress e o do Hugo — para que a transição seja menos frustrante e mais produtiva.\nO Modelo Mental do WordPress O Tema como Aplicação No WordPress, o tema não é apenas uma camada visual. Ele é, na prática, a aplicação que roda o site. Um tema decide quais posts buscar, como filtrar resultados, quais campos personalizar, que scripts carregar e como montar cada página. Ele tem acesso direto ao banco de dados através da API do WordPress, pode registrar custom post types, criar endpoints REST, manipular queries e até alterar o comportamento do admin. A fronteira entre \u0026ldquo;tema\u0026rdquo; e \u0026ldquo;plugin\u0026rdquo; é tênue — e na prática muitos temas cruzam essa linha sem cerimônia.\nIsso cria um modelo mental específico: quando um desenvolvedor WordPress pensa em tema, ele pensa em um pacote completo. Estrutura de dados, lógica de apresentação e markup vivem juntos, frequentemente no mesmo arquivo PHP. O tema é o centro gravitacional do projeto — quase tudo passa por ele ou depende dele.\nO Loop, o Banco de Dados e o functions.php O coração de um tema WordPress é o Loop. Antes de qualquer template ser renderizado, o WordPress já faz uma query ao banco de dados baseada na URL. O tema recebe essa query pronta e itera sobre os resultados com while ( have_posts() ). Parece simples, mas a implicação é profunda: o tema opera em tempo de execução, respondendo a cada requisição do visitante com uma consulta ao MySQL.\nO arquivo functions.php reforça esse modelo. Ele funciona como o bootstrap do tema — é onde você registra menus, sidebars, tamanhos de imagem, enfileira scripts e folhas de estilo com wp_enqueue_script, adiciona suporte a funcionalidades do core e, inevitavelmente, escreve lógica que deveria estar em um plugin. O functions.php é executado a cada carregamento de página, o que significa que qualquer código ali tem acesso a todo o estado do WordPress naquele momento: usuário logado, query atual, opções do banco, hooks disponíveis. É poder e responsabilidade no mesmo arquivo — e a razão pela qual temas WordPress podem se tornar tão complexos quanto a aplicação que eles supostamente apenas \u0026ldquo;vestem\u0026rdquo;.\nO Modelo Mental do Hugo O Tema como Camada de Templates No Hugo, o tema é exatamente o que o nome sugere: uma camada de apresentação. Ele não consulta banco de dados porque não existe nenhum. Ele não executa lógica em tempo de requisição porque não existe tempo de requisição — tudo acontece no momento do build, antes de o site ser publicado. O resultado é um conjunto de arquivos HTML estáticos que podem ser servidos por qualquer servidor web sem nenhuma dependência de runtime.\nUm tema Hugo é uma coleção de arquivos de template escritos na linguagem de templates do Go. Estes definem como o conteúdo será apresentado, mas não definem qual conteúdo existe nem como ele é estruturado. Essa responsabilidade pertence aos arquivos Markdown e à configuração do site. O tema recebe tudo já mastigado — taxonomias resolvidas, páginas ordenadas, parâmetros disponíveis — e se limita a transformar esses dados em HTML. Se no WordPress o tema é o maestro que rege a orquestra, no Hugo ele é a partitura: define a forma, mas não toca os instrumentos.\nConteúdo Mora no Markdown, Lógica Mora no Build No WordPress, conteúdo vive no banco de dados e só é acessível através da API. No Hugo, o conteúdo são arquivos de texto. Cada post é um arquivo Markdown com um bloco de front matter — metadados em YAML, TOML ou JSON no topo do arquivo — seguido do corpo do texto. Não existe intermediário: o que está no diretório content/ é o que o Hugo processa.\nEssa separação tem consequências práticas que pegam quem vem do WordPress desprevenido. No WordPress, para adicionar um campo personalizado a um post, você usa a API de meta fields ou um plugin como o ACF e acessa o valor com get_post_meta() dentro do tema. No Hugo, você simplesmente adiciona uma chave no front matter do arquivo Markdown e acessa com .Params.nomedachave no template. Não há camada intermediária, não há plugin, não há banco — só texto e templates. A lógica que existe nos templates do Hugo é limitada a condicionais, loops e funções de formatação. Ela serve para decidir como apresentar os dados, nunca para buscá-los ou transformá-los de forma complexa. Qualquer coisa mais elaborada acontece no pipeline de build do Hugo, fora do alcance do tema.\nO Que Pega de Surpresa Hierarquia de Templates: Convenção vs Lookup Order No WordPress, a hierarquia de templates é uma cascata baseada em nomes de arquivo. Se o visitante acessa uma página de categoria, o WordPress procura category-slug.php, depois category-id.php, depois category.php, depois archive.php e finalmente index.php. O desenvolvedor aprende essa sequência uma vez e aplica para o resto da vida. É previsível, bem documentada e raramente gera confusão.\nNo Hugo, o sistema equivalente é o lookup order — e ele é consideravelmente mais complexo. O template usado para renderizar uma página depende da combinação de tipo de conteúdo, layout, seção, formato de saída e idioma. Uma página do tipo post na seção blog com layout single em português vai disparar uma busca por dezenas de caminhos possíveis antes de encontrar um template que sirva. A documentação do Hugo tem uma tabela enorme para cada tipo de página detalhando essa ordem. Na prática, o desenvolvedor que vem do WordPress tenta criar um arquivo com o nome \u0026ldquo;óbvio\u0026rdquo; e se frustra quando o Hugo ignora solenemente o template — porque o nome não corresponde a nenhuma posição válida no lookup order. A curva de aprendizado aqui é real, e a solução é consultar a documentação até internalizar o sistema.\nTema Filho vs. Overrides no Hugo No WordPress, o mecanismo de tema filho é uma peça central do ecossistema. Você cria um diretório com um style.css que referencia o tema pai, adiciona um functions.php próprio e, a partir daí, pode sobrescrever qualquer template do tema original colocando um arquivo com o mesmo nome no diretório do tema filho. Isso permite customizar sem tocar no código do tema pai — proteção essencial para sobreviver a atualizações.\nNo Hugo, esse conceito existe de forma mais simples e, ao mesmo tempo, mais direta. Qualquer arquivo de template colocado no diretório layouts/ da raiz do projeto tem precedência sobre o arquivo equivalente dentro do tema. Não há necessidade de declarar um \u0026ldquo;tema filho\u0026rdquo; nem de criar um arquivo de configuração especial. Se o tema define layouts/_default/single.html e você cria o mesmo caminho na raiz do seu projeto, o Hugo usa a sua versão. O modelo é transparente e funciona bem, mas exige disciplina: não há um mecanismo que avise quais templates você sobrescreveu, e após uma atualização do tema o desenvolvedor precisa verificar manualmente se os overrides ainda são compatíveis.\nSem Plugins — E Agora? O ecossistema de plugins é uma das maiores forças do WordPress e uma das maiores fontes de complexidade. Precisa de um formulário de contato? Plugin. SEO? Plugin. Cache? Plugin. Galeria de imagens? Plugin. O tema frequentemente é construído já pressupondo a existência de determinados plugins, e a remoção de um deles pode quebrar o site de formas imprevisíveis.\nHugo não tem sistema de plugins. Essa é provavelmente a diferença que mais desorienta quem chega do WordPress. A funcionalidade que no WordPress viria de um plugin no Hugo é resolvida de outras maneiras: shortcodes customizados para componentes reutilizáveis dentro do conteúdo, partials para fragmentos de template, módulos Hugo para importar funcionalidade de repositórios externos e processamento de dados com arquivos JSON, YAML ou CSV no diretório data/. Nenhuma dessas soluções tem a conveniência do \u0026ldquo;instale e ative\u0026rdquo; do WordPress — todas exigem que o desenvolvedor entenda o que está fazendo e escreva ou adapte código. Em compensação, não existe a fragilidade de depender de código de terceiros executando em produção a cada requisição.\nPara tarefas de manutenção como manter tags consistentes e gerar meta descriptions para SEO, ferramentas externas ao Hugo preenchem a lacuna. O Hugin, por exemplo, usa IA para sugerir tags e resumos diretamente nos arquivos Markdown, sem depender de plugins em tempo de execução.\nAssets: De enqueue a Hugo Pipes No WordPress, a forma correta de incluir CSS e JavaScript é através do sistema de enfileiramento: wp_enqueue_style() e wp_enqueue_script(). Essas funções registram dependências, controlam a ordem de carregamento, permitem condicionar scripts a páginas específicas e evitam duplicação. É um sistema robusto, mas que opera em tempo de execução — cada página monta sua lista de assets dinamicamente.\nHugo Pipes resolve o mesmo problema de forma radicalmente diferente. Assets são processados durante o build: SCSS é compilado, JavaScript é agrupado e minificado, fingerprinting é aplicado para cache busting, e o resultado são arquivos estáticos com caminhos definitivos. Tudo isso é declarado nos templates com funções como resources.ToCSS, resources.Minify e resources.Fingerprint, encadeadas em um pipeline. Não há preocupação com a ordem de carregamento em runtime porque não existe runtime. O template declara o que precisa, o Hugo processa durante o build e o HTML final sai com os caminhos corretos para arquivos já otimizados. Para quem passou anos lutando com conflitos de jQuery e scripts que carregavam fora de ordem, a previsibilidade do Hugo Pipes é um alívio.\nO Que Muda na Sua Cabeça A transição de WordPress para Hugo não é apenas técnica — é uma mudança de postura. No WordPress, o desenvolvedor de temas opera com a mentalidade de quem constrói uma aplicação: ele pensa em estado, em sessões, em queries, em hooks que disparam em momentos específicos do ciclo de vida da requisição. No Hugo, essa complexidade simplesmente não existe. O site inteiro é gerado de uma vez, em segundos, e o resultado é um diretório de arquivos HTML que não precisam de nada para funcionar além de um servidor web capaz de servir arquivos estáticos.\nEssa simplicidade cobra um preço de entrada. O desenvolvedor perde a flexibilidade de tomar decisões em tempo de requisição — não há como mostrar conteúdo diferente para um usuário logado, não há como processar um formulário no servidor, nem como fazer uma busca dinâmica sem recorrer a serviços externos. Quem vem do WordPress precisa aceitar que essas limitações não são defeitos, são consequências de uma escolha arquitetural que prioriza velocidade, segurança e previsibilidade. Um site Hugo não tem superfície de ataque em runtime porque não há runtime. Não há banco de dados para ser invadido, não há PHP para ser explorado, nem plugins com vulnerabilidades esperando para serem descobertas.\nO ganho mais significativo, porém, é cognitivo. Quando o tema é apenas uma camada de apresentação e o conteúdo são arquivos de texto versionados em Git, o desenvolvedor consegue manter o modelo completo do site na cabeça. Não existem surpresas escondidas no banco de dados, não há lógica implícita em hooks que alguém adicionou há três anos, não há estado misterioso que muda entre uma requisição e outra. O que você vê nos arquivos é o que existe. Essa transparência muda a forma de trabalhar — debugar um problema em Hugo é ler templates e verificar front matter, não é vasculhar tabelas no MySQL tentando entender por que um post aparece de um jeito na home e de outro na página de categoria.\nConsiderações Finais A questão não é qual ferramenta é melhor. WordPress continua sendo a escolha certa para muitos projetos — especialmente os que precisam de conteúdo dinâmico, gerenciamento multiusuário ou integração com um ecossistema vasto de plugins. O ponto é que migrar de WordPress para Hugo sem recalibrar o modelo mental é receita para frustração. Os conceitos não se traduzem diretamente, e tentar forçar um dentro do outro só produz temas Hugo que parecem gambiarras de quem ainda está pensando em PHP e MySQL. O investimento real da transição não é aprender a sintaxe de templates do Go — isso leva dias. É desaprender vinte anos de reflexos condicionados sobre o que um tema deve ser e o que ele deve fazer.\n","date":"04/04/2026","lang":"pt","tags":["hugo","wordpress","templates","segurança","devops","self-hosted","automatização"],"title":"De WordPress a Hugo: Temas Não São o Que Você Pensa","url":"https://devops.sarmento.org/posts/de-wordpress-a-hugo-temas-nao-sao-o-que-voce-pensa/"},{"categories":["macOS"],"content":"Minha caixa de entrada vive cheia de notificações com assunto no formato [Ticket ID: 12345] Atualização do chamado. Elas são úteis por algumas horas e depois viram ruído. Não são e-mails que precisam ser arquivados, respondidos ou revisitados, então acabam só ocupando espaço mental.\nApagar manualmente é aquele tipo de tarefa pequena que nunca vira prioridade, mas que cobra juros em distração. Então resolvi tratar como qualquer outro problema recorrente: automatizar localmente, sem depender de serviço externo, sem webhook e sem integrações. A ideia é rodar periodicamente um script que mova para o lixo as mensagens cujo assunto contenha um padrão específico e que tenham mais de 48 horas.\nPor que não usar uma regra do Mail O primeiro impulso é usar as regras nativas do Mail, em Ajustes \u0026gt; Regras. Elas funcionam bem para agir no momento em que a mensagem chega, mas esse é justamente o problema: elas só rodam no evento de recebimento.\nO Mail não oferece um critério do tipo “aplique esta regra apenas se a mensagem tiver mais de 48 horas”, porque não existe reavaliação por idade. Com AppleScript, por outro lado, dá para acessar os objetos internos do Mail.app, incluindo date received. Isso permite comparar datas e tomar decisões baseadas em tempo.\nO script O núcleo da automação é este:\nproperty subjectPattern : \u0026#34;[Ticket ID: \u0026#34; property maxAgeHours : 48 on run set cutoffDate to (current date) - (maxAgeHours * 60 * 60) set totalDeleted to 0 tell application \u0026#34;Mail\u0026#34; repeat with acct in accounts try set inboxMailbox to mailbox \u0026#34;INBOX\u0026#34; of acct set matchingMessages to (messages of inboxMailbox whose subject contains subjectPattern and date received \u0026lt; cutoffDate) set msgCount to count of matchingMessages if msgCount \u0026gt; 0 then repeat with i from msgCount to 1 by -1 try delete item i of matchingMessages set totalDeleted to totalDeleted + 1 on error errMsg log \u0026#34;Erro ao apagar mensagem: \u0026#34; \u0026amp; errMsg end try end repeat end if on error errMsg log \u0026#34;Erro ao acessar conta: \u0026#34; \u0026amp; errMsg end try end repeat if totalDeleted \u0026gt; 0 then try check for new mail on error errMsg log \u0026#34;Aviso: não foi possível sincronizar: \u0026#34; \u0026amp; errMsg end try end if log \u0026#34;Automação concluída: \u0026#34; \u0026amp; totalDeleted \u0026amp; \u0026#34; mensagem(ns) movida(s) para o lixo.\u0026#34; end tell end run Ele percorre todas as contas configuradas no Mail, acessa a INBOX de cada uma e seleciona apenas as mensagens cujo assunto contenha o padrão definido e cuja data de recebimento seja anterior ao limite calculado.\nNada é apagado permanentemente. O comando delete no Apple Mail apenas move para o Lixo da respectiva conta, o que dá margem para recuperar algo se necessário.\nO detalhe que evita uma enxurrada de erros A primeira versão que escrevi iterava mensagem por mensagem, mais ou menos assim:\n-- ❌ Abordagem problemática repeat with msg in inboxMessages set msgSubject to subject of msg ... end repeat Na prática, isso vira uma sequência de erros do tipo:\nMail got an error: Can't get message id 154604 of mailbox \u0026quot;INBOX\u0026quot; of account id \u0026quot;...\u0026quot;\nO motivo costuma aparecer em contas IMAP: o AppleScript mantém referências internas por índice ou ID, e essas referências podem ficar inválidas quando há sincronização em andamento, mudanças de índice ou mensagens movidas pelo servidor durante a iteração.\nA solução foi delegar a filtragem ao próprio Mail com whose:\n-- ✅ Abordagem correta set matchingMessages to (messages of inboxMailbox whose subject contains subjectPattern and date received \u0026lt; cutoffDate) Nesse formato, a consulta é resolvida internamente pelo Mail, que retorna apenas referências que ele consegue materializar. Na prática, isso derruba quase todos os erros intermitentes.\nDeletando de trás para frente Ao remover itens de uma coleção, a ordem importa. Se você percorre do primeiro para o último, os índices mudam a cada exclusão e você pode acabar pulando mensagens.\nPor isso o loop reverso:\nrepeat with i from msgCount to 1 by -1 delete item i of matchingMessages end repeat Isso evita inconsistência e mantém o comportamento previsível.\ncontains em vez de regex AppleScript não tem suporte nativo a expressões regulares. O operador contains já resolve bem quando o padrão é uma substring fixa como [Ticket ID: , e ainda evita chamar processos externos.\nDaria para usar do shell script com grep, mas isso geralmente significa abrir um processo externo por mensagem, o que é mais lento e desnecessário para esse caso.\nAtualização visual da interface Depois que as mensagens vão para o Lixo, a interface do Mail nem sempre atualiza imediatamente. O comando check for new mail força uma sincronização que costuma atualizar a lista na prática.\nOutra alternativa seria alternar a mailbox selecionada via AppleScript, mas isso tende a quebrar o contexto de visualizações compostas, que eu uso, como “Todas as Caixas de Entrada”, e nem sempre volta ao estado anterior do jeito certo.\nAgendando com launchd Para rodar periodicamente, o agendamento fica com launchd, não com cron— que o Macos até tem, mas cujo uso é desencorajado. O .plist vai em ~/Library/LaunchAgents/:\n\u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34;?\u0026gt; \u0026lt;!DOCTYPE plist PUBLIC \u0026#34;-//Apple//DTD PLIST 1.0//EN\u0026#34; \u0026#34;http://www.apple.com/DTDs/PropertyList-1.0.dtd\u0026#34;\u0026gt; \u0026lt;plist version=\u0026#34;1.0\u0026#34;\u0026gt; \u0026lt;dict\u0026gt; \u0026lt;key\u0026gt;Label\u0026lt;/key\u0026gt; \u0026lt;string\u0026gt;com.user.delete-ticket-emails\u0026lt;/string\u0026gt; \u0026lt;key\u0026gt;ProgramArguments\u0026lt;/key\u0026gt; \u0026lt;array\u0026gt; \u0026lt;string\u0026gt;/usr/bin/osascript\u0026lt;/string\u0026gt; \u0026lt;string\u0026gt;/Users/SEU_USUARIO/Scripts/delete_old_ticket_emails.scpt\u0026lt;/string\u0026gt; \u0026lt;/array\u0026gt; \u0026lt;key\u0026gt;StartInterval\u0026lt;/key\u0026gt; \u0026lt;integer\u0026gt;3600\u0026lt;/integer\u0026gt; \u0026lt;key\u0026gt;RunAtLoad\u0026lt;/key\u0026gt; \u0026lt;true/\u0026gt; \u0026lt;key\u0026gt;StandardOutPath\u0026lt;/key\u0026gt; \u0026lt;string\u0026gt;/tmp/delete-ticket-emails.log\u0026lt;/string\u0026gt; \u0026lt;key\u0026gt;StandardErrorPath\u0026lt;/key\u0026gt; \u0026lt;string\u0026gt;/tmp/delete-ticket-emails-error.log\u0026lt;/string\u0026gt; \u0026lt;/dict\u0026gt; \u0026lt;/plist\u0026gt; StartInterval em 3600 significa execução a cada hora. Se o Mac estiver ligado, o script roda; se não estiver, ele roda na próxima vez que a sessão carregar.\nObserve que o caminho do script inclui SEU_USUARIO, que você deve substituir pelo seu próprio nome de usuário no Macos. Se precisar descobrir o seu, rode o comando whoami no Terminal.\nPara ativar:\ncp com.user.delete-ticket-emails.plist ~/Library/LaunchAgents/ launchctl load ~/Library/LaunchAgents/com.user.delete-ticket-emails.plist Na primeira execução, o macOS deve pedir permissão para que osascript controle o Mail. Essa autorização fica em Ajustes do Sistema \u0026gt; Privacidade e Segurança \u0026gt; Automação. Sem isso, o script falha e o erro nem sempre é muito explicativo.\nPersonalizando Para adaptar a outros cenários, basta alterar duas linhas no início do script:\nproperty subjectPattern : \u0026#34;[Ticket ID: \u0026#34; property maxAgeHours : 48 Você pode usar qualquer substring no assunto e qualquer janela de tempo em horas. O resto do fluxo continua o mesmo.\nNo fim, não se trata de uma automação para apagar e-mails — não só. Estamos falando em reduzir fricção cognitiva com uma solução simples, local e controlada. O Mail continua sendo o Mail, mas você define que algumas mensagens têm prazo de validade e que, depois disso, elas saem de cena sozinhas.\n","date":"30/03/2026","lang":"pt","tags":["automatização","segurança","mail","macos","launchd","apple-script","devops","self-hosted"],"title":"Apagando e-mails automaticamente no Apple Mail com AppleScript + launchd","url":"https://devops.sarmento.org/posts/apagando-e-mails-automaticamente-no-apple-mail-com-applescript-launchd/"},{"categories":["Self-Hosting"],"content":"Quem mantém um homelab — ou mesmo um único Raspberry Pi rodando serviços — eventualmente esbarra no mesmo obstáculo: como acessar esses dispositivos de fora da rede local? A resposta clássica envolve abrir portas no roteador, configurar port forwarding, lidar com IP dinâmico via DDNS e torcer para que nenhum bot descubra aquela porta SSH exposta na internet. Funciona, mas a superfície de ataque cresce a cada porta aberta, e a manutenção vira uma dor de cabeça silenciosa que só aparece quando algo quebra.\nVPNs tradicionais como OpenVPN e WireGuard puro resolvem parte do problema, mas trazem suas próprias complicações. É preciso manter um servidor VPN acessível publicamente — o que nos devolve ao ponto de partida de expor pelo menos uma porta — além de gerenciar chaves, certificados e configurações de roteamento em cada dispositivo. Para quem administra infraestrutura profissionalmente durante o dia, a última coisa que se quer é replicar essa complexidade no homelab à noite.\nO cenário ideal seria algo que conectasse todos os dispositivos como se estivessem na mesma rede local, independentemente de onde estejam fisicamente, sem exigir nenhuma porta aberta no roteador, sem depender de IP fixo e sem precisar de um servidor central exposto à internet. É exatamente isso que o Tailscale faz — e o plano gratuito cobre praticamente tudo que um homelab precisa.\nO que é Tailscale e como funciona O Tailscale é uma VPN mesh construída sobre o WireGuard — o protocolo que se tornou referência em VPNs modernas por ser rápido, leve e com uma base de código pequena o suficiente para ser auditável. A diferença é que o Tailscale elimina toda a configuração manual que o WireGuard normalmente exige: troca de chaves, definição de endpoints, regras de roteamento e NAT traversal ficam por conta da plataforma.\nA arquitetura tem três componentes principais. O primeiro é o servidor de coordenação, mantido pela Tailscale, que funciona como um ponto de encontro onde os dispositivos trocam chaves públicas e descobrem como se alcançar. O segundo são os clientes instalados em cada dispositivo, que estabelecem túneis WireGuard diretos entre si. O terceiro é o mecanismo de NAT traversal, que permite que dois dispositivos atrás de roteadores diferentes — cada um com seu próprio NAT — consigam estabelecer uma conexão ponto a ponto sem precisar de portas abertas. Quando a conexão direta não é possível, o tráfego passa por relays DERP mantidos pela Tailscale, mas isso é exceção e não regra.\nUm detalhe que vale reforçar: o servidor de coordenação nunca vê o tráfego entre os dispositivos. Ele apenas facilita a troca de metadados para que os nós se encontrem — todo o tráfego real é criptografado ponta a ponta pelo WireGuard, diretamente entre os dispositivos. A documentação oficial detalha a arquitetura para quem quiser se aprofundar.\nO resultado prático é que cada dispositivo na rede recebe um IP fixo na faixa 100.x.x.x (o CGNAT range reservado para esse tipo de uso), acessível de qualquer outro dispositivo na mesma tailnet — o nome que o Tailscale dá à sua rede privada. Não importa se o laptop está no café, o servidor no datacenter e o Raspberry Pi atrás de um roteador doméstico: para todos os efeitos, estão na mesma LAN.\ngraph TD CS[\"🔑 Servidor de coordenação\\nTroca de chaves públicas e endpoints\"] subgraph HOME[\"🏠 Rede doméstica — NAT\"] MAC[\"💻 Mac\"] RPI[\"🍓 Raspberry Pi\"] LXC[\"📦 Container LXC\"] end subgraph DC[\"🏢 Datacenter — NAT\"] VPS[\"🖥️ VPS\"] SRV[\"🖥️ Servidor\"] end DERP[\"☁️ Relay DERP\\nFallback quando P2P falha\"] CS -. \"metadados\" .-\u003e HOME CS -. \"metadados\" .-\u003e DC CS -. \"metadados\" .-\u003e DERP MAC \u003c== \"túnel WireGuard\\nponto a ponto\" ==\u003e VPS LXC \u003c== \"túnel WireGuard\\nponto a ponto\" ==\u003e SRV RPI \u003c== \"túnel WireGuard\\nponto a ponto\" ==\u003e VPS MAC \u003c-. \"via relay\" .-\u003e DERP DERP \u003c-. \"via relay\" .-\u003e SRV Instalação e configuração O Tailscale tem pacotes para praticamente tudo que roda Linux, além de macOS, Windows, iOS e Android. A página de download lista todas as opções. Aqui vamos cobrir os três cenários mais comuns em homelab: Linux (Debian/Ubuntu), macOS e containers LXC no Proxmox.\nCriando a conta Antes de instalar qualquer cliente, crie uma conta em login.tailscale.com. O Tailscale não tem cadastro próprio — a autenticação é feita via Google, Microsoft, GitHub ou Apple. Escolha o provedor que preferir e a tailnet é criada automaticamente. Não é necessário cartão de crédito nem escolher plano — o Personal (gratuito) já vem ativo com suporte a 3 usuários e 100 dispositivos.\nLinux (Debian/Ubuntu) A instalação segue o padrão de adicionar o repositório oficial e instalar via apt. O script de conveniência do Tailscale resolve tudo em uma linha:\ncurl -fsSL https://tailscale.com/install.sh | sh Depois de instalado, habilite e inicie o daemon:\nsudo systemctl enable --now tailscaled Em seguida, autentique o dispositivo:\nsudo tailscale up O comando imprime uma URL no terminal. Abra-a no navegador, faça login com a mesma conta criada anteriormente e o dispositivo aparece na tailnet. A partir desse momento ele já tem um IP 100.x.x.x acessível por qualquer outro nó da rede.\nPara confirmar o status:\ntailscale status macOS Há duas opções: o app da App Store e o pacote CLI via Homebrew. O app da App Store é a opção mais prática — instala, faz login e o Mac aparece na tailnet. Para quem prefere a linha de comando:\nbrew install tailscale Inicie o daemon:\nsudo brew services start tailscale A autenticação segue o mesmo fluxo:\ntailscale up O comando imprime uma URL no terminal. Abra-a no navegador, faça login com a mesma conta criada anteriormente e o dispositivo aparece na tailnet.\nContainers LXC (Proxmox) Containers LXC não privilegiados no Proxmox não têm acesso ao dispositivo /dev/net/tun por padrão, e o Tailscale precisa desse dispositivo para criar o túnel de rede. Há duas formas de resolver.\nPela interface web (Proxmox 8+): acesse a aba Resources do container, clique em Add, selecione Device Passthrough e informe dev/net/tun no campo Device Path.\nPela linha de comando, no host Proxmox, edite o arquivo de configuração do container (substituindo \u0026lt;CTID\u0026gt; pelo ID real):\nnano /etc/pve/lxc/\u0026lt;CTID\u0026gt;.conf Adicione as duas linhas ao final:\nlxc.cgroup2.devices.allow: c 10:200 rwm lxc.mount.entry: /dev/net/tun dev/net/tun none bind,create=file Reinicie o container para que a mudança tenha efeito. Dentro dele, a instalação segue o processo padrão do Linux — curl -fsSL https://tailscale.com/install.sh | sh, depois sudo systemctl enable --now tailscaled e tailscale up.\nO Tailscale também oferece um userspace networking mode que dispensa o acesso ao /dev/net/tun e não exige nenhuma alteração na configuração do Proxmox. A contrapartida é que nesse modo o Tailscale não cria uma interface de rede real — ele opera como um proxy SOCKS5/HTTP local, e cada aplicação que precise alcançar a tailnet precisa ser configurada para rotear pelo proxy. Para um container que roda um único serviço já preparado para usar proxy, pode ser aceitável. Mas em cenários de homelab, onde o objetivo é justamente que todos os serviços enxerguem os IPs 100.x.x.x de forma transparente — como se estivessem na mesma LAN —, o TUN device é a opção mais prática: duas linhas no arquivo de configuração do container e o acesso à tailnet funciona para qualquer processo, sem ajustes adicionais.\nTestando a conectividade Com pelo menos dois dispositivos na tailnet, o próximo passo é confirmar que eles se enxergam. O comando tailscale status lista todos os nós da rede com seus respectivos IPs e estado:\ntailscale status A saída mostra algo como:\n100.116.82.20 mac-janio janio@ macOS - 100.85.47.3 fuqu janio@ linux - 100.97.12.58 rpi janio@ linux - Para testar a conectividade entre dois nós, use o tailscale ping em vez do ping convencional:\ntailscale ping 100.116.82.20 O tailscale ping opera sobre o protocolo do próprio Tailscale e, além de confirmar que a conexão funciona, mostra se ela é direta (peer-to-peer) ou está passando por um relay DERP. Uma resposta como pong from mac-janio (100.116.82.20) via 100.116.82.20:41641 in 12ms indica conexão direta — o cenário ideal. Se aparecer via DERP(...), o tráfego está passando por um servidor intermediário, o que funciona mas com latência maior.\nO ping tradicional (ICMP) também funciona entre dispositivos que não sejam containers não privilegiados. Dentro de um container LXC no Proxmox, o ping falha com Operation not permitted porque o container não tem a capability CAP_NET_RAW necessária para criar raw sockets — isso não indica problema de rede. A conectividade TCP e UDP funciona normalmente, e o tailscale ping é a forma confiável de validar a comunicação em qualquer cenário.\nCasos de uso práticos A tailnet funciona como uma LAN virtual que acompanha você para qualquer lugar. Isso abre possibilidades que vão além do simples \u0026ldquo;acessar um servidor de fora\u0026rdquo;. Alguns cenários concretos para quem mantém um homelab:\nSSH sem IP público O caso mais imediato. Com o Tailscale instalado no servidor e no laptop, o acesso SSH funciona pelo IP 100.x.x.x independentemente de onde você esteja — sem abrir a porta 22 no roteador, sem DDNS, sem expor nada à internet. Para quem precisa apenas de SSH sem instalar um cliente VPN em cada máquina, o SSH-J.com é uma alternativa mais leve que usa apenas o OpenSSH. O servidor pode estar atrás de CGNAT, trocar de IP a cada reinício do roteador, e nada muda. O comando continua sendo ssh usuario@100.x.x.x, como se o servidor estivesse na mesa ao lado. O Tailscale também oferece Tailscale SSH, que permite autenticar via identidade do Tailscale sem gerenciar chaves SSH manualmente — útil para quem quer simplificar ainda mais.\nAcessar o LM Studio remotamente Quem roda modelos locais no LM Studio sabe que o servidor de inferência escuta por padrão em localhost:1234. Com o Tailscale no Mac que roda o LM Studio e no container ou VPS que precisa consumir a API, basta configurar o LM Studio para escutar em 0.0.0.0:1234 e apontar as requisições para o IP Tailscale do Mac — por exemplo, http://100.116.82.20:1234/v1/chat/completions. A conexão é criptografada ponta a ponto pelo WireGuard, sem precisar expor a porta do LM Studio à rede local ou à internet.\nCompartilhar serviços internos Um Raspberry Pi rodando AdGuard Home, um container com Immich para fotos, um painel de monitoramento com Grafana — todos esses serviços passam a ser acessíveis de qualquer dispositivo na tailnet pelo IP 100.x.x.x e a porta do serviço. Sem reverse proxy, sem certificados, sem configuração de DNS externo. Para quem quer ir um passo além, o Tailscale permite compartilhar dispositivos com outros usuários sem que eles precisem estar na mesma conta, o que facilita dar acesso a familiares ou colegas a serviços específicos.\nSubnet router — acessar toda a rede local Em vez de instalar o Tailscale em cada dispositivo da rede doméstica, é possível configurar um único nó como subnet router. Esse nó funciona como gateway entre a tailnet e a rede local: quando você está fora de casa e tenta acessar um IP da rede doméstica — uma impressora, um NAS, uma câmera —, o tráfego vai pelo túnel WireGuard até o subnet router, que encaminha o pacote para o dispositivo de destino e devolve a resposta pelo mesmo caminho. O dispositivo final nem precisa saber que o Tailscale existe.\nO pré-requisito é que o nó escolhido como subnet router esteja sempre ligado, o que torna um Raspberry Pi ou um container LXC leve mais adequados para essa função do que um desktop ou laptop. A configuração exige um único comando nesse nó:\nsudo tailscale up --advertise-routes=192.168.3.0/24 Depois, a rota precisa ser aprovada no painel de administração do Tailscale para entrar em vigor. A partir desse ponto, qualquer dispositivo na tailnet pode alcançar IPs da rede 192.168.3.0/24 como se estivesse conectado ao roteador de casa.\nMagicDNS Decorar IPs 100.x.x.x não é prático quando a tailnet começa a crescer. O MagicDNS resolve isso atribuindo nomes legíveis a cada dispositivo automaticamente — o hostname da máquina vira um registro DNS acessível por qualquer outro nó da tailnet. Em vez de ssh janio@100.85.47.3, o comando passa a ser ssh janio@fuqu, usando o hostname do container diretamente.\nO MagicDNS vem habilitado por padrão em tailnets novas. Para verificar ou ativar manualmente, acesse a página de DNS no painel de administração e confirme que a opção está ligada. Cada dispositivo recebe um nome no formato hostname.tailnet-name.ts.net — o sufixo completo funciona de qualquer lugar, e o hostname curto funciona quando o search domain da tailnet está configurado no sistema operacional, o que o Tailscale faz automaticamente na maioria dos casos.\nNa prática, isso significa que URLs de serviços internos ficam estáveis e memoráveis. O LM Studio no Mac passa a ser acessível em http://mac-janio:1234, o AdGuard Home no Raspberry Pi em http://rpi:3000, e assim por diante. Se um dispositivo trocar de IP na tailnet (o que raramente acontece, mas pode ocorrer), o nome continua apontando para o lugar certo sem ajuste manual.\nPara cenários onde os nomes padrão não são suficientes, o painel de DNS também permite configurar split DNS — encaminhar consultas de domínios específicos para resolvers internos, útil quando a tailnet coexiste com infraestrutura DNS já existente.\nQuando o Tailscale não é a melhor opção O Tailscale resolve muito bem o problema de conectar dispositivos distribuídos sem configuração complexa, mas existem cenários onde ele não se encaixa — ou onde outra ferramenta faz mais sentido.\nTráfego de alta vazão entre servidores. O Tailscale adiciona overhead de encapsulamento WireGuard e, dependendo do caminho, o tráfego pode passar por relay. Para replicação de banco de dados entre datacenters ou transferências massivas de arquivos entre servidores que já estão na mesma rede privada do provedor, uma conexão direta (VPC peering, rede privada do provedor) sempre vai ser mais rápida e eficiente.\nExposição de serviços ao público. O Tailscale cria redes privadas — ele conecta dispositivos autenticados, não serve como substituto para um reverse proxy ou CDN. Se o objetivo é publicar um site ou API para a internet, ferramentas como Cloudflare Tunnel, Caddy ou Nginx com certificado Let\u0026rsquo;s Encrypt continuam sendo o caminho.\nAmbientes com requisito de auditoria total do plano de controle. O servidor de coordenação do Tailscale é proprietário e roda na infraestrutura deles. O tráfego entre dispositivos é criptografado ponta a ponta e nunca passa pelos servidores da Tailscale (exceto quando usa relay DERP), mas os metadados de coordenação — quais dispositivos existem, quais chaves públicas, quais IPs foram atribuídos — ficam sob custódia deles. Para organizações com requisitos regulatórios que exigem soberania total sobre esse tipo de dado, o Headscale (abordado na próxima seção) é a alternativa.\nProtocolos que dependem de multicast. A tailnet não suporta multicast UDP — protocolos como mDNS, SSDP e DLNA não funcionam entre dispositivos conectados apenas pelo Tailscale. Descoberta automática de impressoras, Chromecast e serviços Bonjour não vai acontecer pela tailnet. O acesso direto a esses dispositivos ainda funciona pelo IP, mas a descoberta automática depende da rede local.\nRedes com mais de 3 usuários no plano gratuito. O plano Personal suporta até 3 usuários e 100 dispositivos. Para equipes maiores sem orçamento para planos pagos, o Headscale com autenticação própria remove essa limitação.\nHeadscale — a alternativa self-hosted O Headscale é uma reimplementação open source do servidor de coordenação do Tailscale. Ele substitui apenas o plano de controle — a parte que gerencia chaves públicas, atribui IPs e define as fronteiras da rede. Os clientes continuam sendo os oficiais do Tailscale, o que significa que a experiência no dispositivo final é idêntica: mesmos comandos, mesma interface, mesmo protocolo WireGuard por baixo.\nA diferença é onde o plano de controle roda. Em vez de depender da infraestrutura da Tailscale, o Headscale funciona em qualquer servidor Linux — um VPS barato, um container no homelab, uma máquina dedicada. Toda a coordenação da rede fica sob seu controle, sem limitação de usuários e sem dependência de um serviço externo.\nA instalação é simples — é um binário único em Go, sem dependências pesadas. A documentação oficial cobre a configuração inicial, criação de usuários e registro de dispositivos. O fluxo de autenticação muda: em vez de fazer login com Google ou GitHub no site do Tailscale, os dispositivos se autenticam diretamente contra o seu servidor Headscale, usando chaves pré-geradas ou OIDC se você tiver um provedor de identidade configurado.\nPara quem já usa o Tailscale e quer migrar, a transição é relativamente suave — basta apontar os clientes para o novo servidor de coordenação com a flag --login-server. A própria Tailscale mantém compatibilidade com o Headscale e trabalha junto com os mantenedores do projeto quando faz mudanças nos clientes que possam afetar o funcionamento do servidor alternativo.\nA contrapartida é a manutenção. O Tailscale como serviço é \u0026ldquo;instala e esquece\u0026rdquo; — atualizações do plano de controle, disponibilidade, backups e monitoramento ficam por conta deles. Com o Headscale, tudo isso é responsabilidade sua. Para um homelab pessoal onde o plano gratuito do Tailscale já cobre tudo, o Headscale adiciona complexidade sem ganho prático. Ele faz mais sentido quando a motivação é soberania sobre os dados de coordenação, quando o limite de 3 usuários do plano gratuito se torna um problema, ou quando a filosofia de não depender de SaaS é uma prioridade.\nPlano gratuito vs. pago — o que muda O plano Personal do Tailscale é gratuito por tempo indeterminado, sem cartão de crédito e sem pegadinhas de trial. O que ele inclui cobre a grande maioria dos cenários de homelab: até 3 usuários, 100 dispositivos, subnet routers ilimitados, MagicDNS, ACLs básicas (baseadas em autogroups admin e member), e criptografia ponta a ponto — as mesmas fundações dos planos pagos.\nO Personal Plus custa US$ 5/mês e aumenta o limite para 6 usuários e 100 dispositivos. A diferença prática em relação ao plano gratuito é apenas o número de usuários — útil para quem quer dar acesso a familiares sem compartilhar a mesma conta.\nOs planos comerciais (Starter a US$ 6/usuário/mês, Premium a US$ 18/usuário/mês e Enterprise com preço sob consulta) entram em território diferente: ACLs granulares com grupos e usuários nomeados, integração com provedores de identidade como Okta e Azure AD, audit logging, gravação de sessões SSH e suporte prioritário. São funcionalidades voltadas a equipes e organizações — se a tailnet é pessoal ou de um homelab pequeno, dificilmente justificam o custo.\nNa prática, a pergunta relevante para quem está montando um homelab é simples: quantas pessoas precisam de acesso? Se a resposta é \u0026ldquo;só eu\u0026rdquo; ou \u0026ldquo;eu e mais uma ou duas pessoas\u0026rdquo;, o plano gratuito não tem limitação funcional que atrapalhe. Os 100 dispositivos são mais do que suficientes mesmo para homelabs ambiciosos, e as funcionalidades de rede — túneis diretos, subnet routing, MagicDNS, exit nodes — estão todas disponíveis. O Tailscale como produto foi desenhado para que o plano gratuito seja genuinamente utilizável, não uma amostra grátis capenga que empurra para o upgrade.\n","date":"29/03/2026","lang":"pt","tags":["tailscale","homelab","segurança","acesso-remoto","vpn","self-hosted","alternativas"],"title":"Tailscale no Homelab — Acesso Remoto sem Abrir Portas","url":"https://devops.sarmento.org/posts/tailscale-no-homelab-acesso-remoto-sem-abrir-portas/"},{"categories":["Sites Estaticos","Self-Hosting"],"content":"Um site estático não tem backend. Não tem banco de dados, não tem servidor de aplicação processando requisições — e é exatamente isso que o torna rápido, barato e resiliente. Mas essa simplicidade cobra um preço quando você quer algo que dependa de estado persistente, e comentários são o caso mais óbvio. No WordPress ou Ghost, o sistema de comentários faz parte da aplicação. Num site gerado por Hugo, Jekyll ou Eleventy, essa camada simplesmente não existe.\nA solução que dominou por mais de uma década foi o Disqus: um snippet de JavaScript, um iframe com a caixa de comentários, e pronto. O preço real dessa conveniência aparece nas entrelinhas — scripts de rastreamento, cookies de terceiros, publicidade injetada no seu site e todos os dados armazenados em servidores que você não controla. Existem alternativas baseadas em GitHub Issues (Utterances, Giscus), que funcionam bem para blogs técnicos mas exigem que o leitor tenha conta no GitHub. E existe uma categoria que resolve o problema da forma mais direta: um servidor de comentários leve que você mesmo hospeda, com seus próprios dados, no seu próprio domínio. O Isso se encaixa nessa categoria.\nO que é o Isso O Isso é um servidor de comentários open-source escrito em Python, criado em 2012 como alternativa self-hosted ao Disqus. O nome vem do alemão Ich schrei sonst — algo como \u0026ldquo;senão eu grito\u0026rdquo;. A arquitetura é propositalmente simples: o servidor é uma aplicação Python que expõe uma API REST e armazena tudo em um único arquivo SQLite. O cliente é um script JavaScript de aproximadamente 20 KB (gzipado) que você embarca nas suas páginas. Não há painel administrativo elaborado, não há sistema de contas, não há integração com redes sociais. Os visitantes escrevem comentários informando nome e email — opcionalmente anônimos — e podem editar ou apagar o que escreveram dentro de uma janela de tempo configurável.\nOs comentários suportam Markdown, threading funciona nativamente com respostas aninhadas, e a moderação pode ser habilitada para que comentários só apareçam após aprovação. O Isso também inclui uma ferramenta de importação que lê dumps XML do Disqus e do WordPress.\nA escolha do SQLite como backend é uma decisão de design, não uma limitação. Comentários em um blog são dados pequenos, com escrita esporádica e proporção de leitura gigantesca. O SQLite lida com esse perfil sem esforço, e o backup do histórico inteiro se resume a copiar um arquivo.\nArquitetura O setup que vamos montar funciona assim: os blogs continuam hospedados no Cloudflare Pages (ou qualquer outro serviço de sites estáticos), e o Isso roda num VPS separado, acessível via um subdomínio como isso.seudominio.org. Quando um visitante abre um post, o JavaScript do Isso carrega a partir do VPS, busca os comentários daquela página e renderiza a seção de comentários. Se o VPS sair do ar por qualquer motivo, o blog continua funcionando normalmente — o visitante só não vê os comentários até o serviço voltar. A falha é graceful.\nO Isso escuta numa porta local e não implementa TLS. Quem cuida do HTTPS é um proxy reverso — Nginx, Caddy, ou o que você já tiver no servidor. Sem HTTPS no endpoint do Isso, os comentários não funcionam em nenhum site servido via HTTPS, porque o navegador recusa conexões mixed-content silenciosamente.\nPreparando o servidor Este tutorial usa um VPS com Ubuntu 24.04 e Nginx gerenciado pelo WordOps. Se você usa outro setup para o Nginx, adapte a parte do proxy reverso. O restante é idêntico.\nInstale as dependências:\napt update apt install python3-dev python3-venv build-essential Crie a estrutura de diretórios. Neste exemplo, tudo fica em /var/www/isso.seudominio.org — configuração, banco de dados e virtualenv no mesmo lugar, o que simplifica backups:\nmkdir -p /var/www/isso.seudominio.org/{config,db} Crie o virtualenv e instale o Isso e o gunicorn:\npython3 -m venv /var/www/isso.seudominio.org/venv /var/www/isso.seudominio.org/venv/bin/pip install isso gunicorn Ajuste as permissões para o usuário do servidor web (no WordOps e em muitos setups Nginx, é o www-data):\nchown -R www-data:www-data /var/www/isso.seudominio.org Configuração O Isso usa arquivos de configuração no formato INI. Se você vai servir comentários para um único site, basta um arquivo. Para servir múltiplos sites a partir da mesma instância — que é o nosso caso, com dois blogs — a abordagem muda: cada site precisa do seu próprio arquivo de configuração, com um campo name que identifica o site, e o Isso roda via gunicorn com o módulo isso.dispatch em vez do comando isso run direto.\nA documentação oficial é explícita sobre esse ponto: o parâmetro host numa única config aceita múltiplas URLs, mas apenas variantes do mesmo site (por exemplo, versões com e sem www, ou HTTP e HTTPS). Sites diferentes exigem configs separadas.\nCrie o arquivo para o primeiro site, /var/www/isso.seudominio.org/config/blog1.cfg:\n[general] name = blog1 dbpath = /var/www/isso.seudominio.org/db/blog1.db host = https://blog1.seudominio.org max-age = 15m notify = stdout [moderation] enabled = true [server] listen = http://127.0.0.1:8000 [guard] enabled = true ratelimit = 2 direct-reply = 3 reply-to-self = false require-author = true require-email = true [admin] enabled = true password = TROQUE_POR_UMA_SENHA_FORTE E o arquivo para o segundo site, /var/www/isso.seudominio.org/config/blog2.cfg:\n[general] name = blog2 dbpath = /var/www/isso.seudominio.org/db/blog2.db host = https://blog2.seudominio.org max-age = 15m notify = stdout [moderation] enabled = true [server] listen = http://127.0.0.1:8000 [guard] enabled = true ratelimit = 2 direct-reply = 3 reply-to-self = false require-author = true require-email = true [admin] enabled = true password = TROQUE_POR_UMA_SENHA_FORTE Os pontos que merecem atenção:\nname — identificador único do site. É esse valor que define o subpath na URL da API (/blog1/, /blog2/). dbpath — caminho absoluto. Cada site tem seu próprio banco de dados SQLite. host — a URL do seu blog, não do Isso. É usada para validação de CORS. moderation enabled = true — todo comentário passa por aprovação antes de aparecer. Desative depois de ganhar confiança no guard e nos seus leitores. require-author e require-email = true — reduz spam anônimo significativamente. admin enabled = true — habilita o painel de moderação, acessível em https://isso.seudominio.org/blog1/admin. Se você serve apenas um site, pode usar um único arquivo e rodar com isso -c /caminho/config.cfg run diretamente. Nesse caso, o campo name não é necessário e os endpoints da API ficam na raiz (sem subpath).\nExecutando com gunicorn e systemd Para multi-site, o Isso usa o módulo isso.dispatch junto com o gunicorn. O gunicorn precisa saber quais arquivos de configuração carregar através da variável de ambiente ISSO_SETTINGS, com os caminhos separados por ponto-e-vírgula.\nTeste manualmente antes de criar o serviço:\nsudo -u www-data \\ ISSO_SETTINGS=\u0026#34;/var/www/isso.seudominio.org/config/blog1.cfg;/var/www/isso.seudominio.org/config/blog2.cfg\u0026#34; \\ /var/www/isso.seudominio.org/venv/bin/gunicorn isso.dispatch -b 127.0.0.1:8000 Você deve ver nos logs que o gunicorn iniciou e que ambos os sites foram conectados. Em outro terminal, confirme:\ncurl http://127.0.0.1:8000/blog1/info curl http://127.0.0.1:8000/blog2/info Cada um deve retornar um JSON com a versão do Isso, a origem configurada e o status de moderação. Se os dois respondem, Ctrl+C no gunicorn e crie o serviço systemd.\nCrie /etc/systemd/system/isso.service:\n[Unit] Description=Isso Commenting Server After=network.target [Service] Type=simple User=www-data Group=www-data Environment=ISSO_SETTINGS=/var/www/isso.seudominio.org/config/blog1.cfg;/var/www/isso.seudominio.org/config/blog2.cfg ExecStart=/var/www/isso.seudominio.org/venv/bin/gunicorn isso.dispatch -b 127.0.0.1:8000 WorkingDirectory=/var/www/isso.seudominio.org Restart=on-failure RestartSec=5 [Install] WantedBy=multi-user.target Ative e inicie:\nsystemctl daemon-reload systemctl enable --now isso systemctl status isso A partir daqui o Isso sobe automaticamente com o servidor.\nProxy reverso com Nginx O Isso precisa estar acessível via HTTPS para que os navegadores dos visitantes consigam se comunicar com a API. O processo escuta em 127.0.0.1:8000 e o Nginx faz o proxy.\nSe você usa WordOps, o comando é direto:\nwo site create isso.seudominio.org --proxy=127.0.0.1:8000 -le Isso cria o vhost, configura o proxy reverso e provisiona o certificado Let\u0026rsquo;s Encrypt automaticamente.\nSe não usa WordOps, crie o vhost manualmente. Um exemplo mínimo para /etc/nginx/sites-available/isso.seudominio.org:\nserver { listen 80; listen [::]:80; server_name isso.seudominio.org; location / { proxy_pass http://127.0.0.1:8000; proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } } Ative o site e obtenha o certificado SSL:\nln -s /etc/nginx/sites-available/isso.seudominio.org /etc/nginx/sites-enabled/ nginx -t \u0026amp;\u0026amp; systemctl reload nginx certbot --nginx -d isso.seudominio.org Teste externamente:\ncurl https://isso.seudominio.org/blog1/info curl https://isso.seudominio.org/blog2/info Se retornar os JSONs com a versão e a origem de cada site, o servidor está pronto.\nPor que não Docker A imagem oficial do Isso (ghcr.io/isso-comments/isso:release) traz o gunicorn embutido com a porta 8080 hardcoded no entrypoint. A diretiva listen do arquivo de configuração e a variável de ambiente GUNICORN_CMD_ARGS são ignoradas pela imagem — o gunicorn sempre tenta fazer bind na porta 8080, independente do que você configurar.\nCom network_mode: host, se a porta 8080 já estiver ocupada no host por outro serviço (um painel de controle, por exemplo), o container entra em loop de erro e nunca inicia. Com bridge networking e port mapping (-p 127.0.0.1:OUTRA_PORTA:8080), o Docker proxy deveria traduzir as portas, mas em alguns ambientes o NAT não funciona corretamente — a conexão estabelece e é imediatamente resetada, mesmo com o Isso respondendo normalmente de dentro do container.\nA instalação nativa com virtualenv, gunicorn e systemd elimina todas essas camadas intermediárias. Você controla a porta diretamente, sem depender do entrypoint da imagem, do Docker proxy ou do iptables/nftables do Docker. Para uma aplicação Python leve com um banco SQLite, a complexidade do Docker não se justifica.\nIntegração com Hugo Com o servidor rodando e acessível via HTTPS, o lado do Hugo se resume a duas coisas: incluir o script do Isso no template de posts e — se você usa um CMS como o Pages CMS — adicionar o campo correspondente na configuração do CMS para poder habilitar ou desabilitar comentários por post.\nO partial de comentários Crie o arquivo layouts/partials/comments.html no repositório do seu blog:\n\u0026lt;section id=\u0026#34;isso-comments\u0026#34;\u0026gt; \u0026lt;script data-isso=\u0026#34;https://isso.seudominio.org/blog1/\u0026#34; data-isso-css=\u0026#34;true\u0026#34; data-isso-lang=\u0026#34;pt_BR\u0026#34; data-isso-reply-to-self=\u0026#34;false\u0026#34; data-isso-require-author=\u0026#34;true\u0026#34; data-isso-require-email=\u0026#34;true\u0026#34; data-isso-avatar=\u0026#34;true\u0026#34; data-isso-avatar-bg=\u0026#34;#f0f0f0\u0026#34; data-isso-vote=\u0026#34;true\u0026#34; src=\u0026#34;https://isso.seudominio.org/blog1/js/embed.min.js\u0026#34; \u0026gt;\u0026lt;/script\u0026gt; \u0026lt;noscript\u0026gt;Ative o JavaScript para ver os comentários.\u0026lt;/noscript\u0026gt; \u0026lt;section id=\u0026#34;isso-thread\u0026#34;\u0026gt;\u0026lt;/section\u0026gt; \u0026lt;/section\u0026gt; O valor de data-isso é a URL base da instância do Isso, incluindo o subpath do site (/blog1/). O src aponta para o script servido pela própria instância. Os atributos data-isso-* controlam o comportamento do client — a referência completa está na documentação oficial.\nIncluindo no template A forma de incluir o partial depende do tema. Alguns temas Hugo já têm suporte nativo ao Isso e expõem um campo no front matter para ativá-lo — consulte a documentação do seu tema para saber se esse é o caso e qual é o nome do campo esperado (pode ser comments, isso, disableComments, entre outros). Nesses casos, basta configurar os parâmetros no hugo.toml e o tema cuida do resto.\nSe o tema não tem suporte nativo, edite o template de posts (layouts/_default/single.html ou o equivalente no seu tema) e adicione a chamada ao partial no ponto onde os comentários devem aparecer — normalmente depois do conteúdo e antes do footer:\n{{ if ne .Params.comments false }} {{ partial \u0026#34;comments.html\u0026#34; . }} {{ end }} Com essa lógica, os comentários aparecem em todos os posts por padrão. Para desabilitar em um post específico, adicione comments: false no front matter.\nConfiguração do Pages CMS Se você gerencia o conteúdo pelo Pages CMS, adicione o campo de comentários no .pages.yml para que ele apareça na interface de edição. O nome do campo precisa corresponder ao que o template espera — no exemplo acima, é comments:\n- name: comments label: Comentários type: boolean default: true Isso permite habilitar ou desabilitar comentários por post diretamente pela interface do CMS, sem editar o Markdown manualmente.\nModeração e notificações Com moderation = true na configuração, todo comentário novo entra em fila e fica invisível até que você aprove. A aprovação pode ser feita pelo painel de administração (acessível em https://isso.seudominio.org/blog1/admin) ou por links assinados enviados por email.\nPara receber notificações por email, troque notify = stdout por notify = smtp e configure a seção [smtp]:\n[general] notify = smtp [smtp] username = seu@email.com password = sua-senha-ou-app-password host = smtp.seuprovedor.com port = 587 security = starttls to = seu@email.com from = isso@seudominio.org timeout = 10 Um detalhe que merece atenção: o Isso envia os emails via SMTP a partir do servidor onde está rodando. Se o domínio do remetente não tiver registros SPF, DKIM e rDNS configurados corretamente, esses emails têm grande chance de cair em spam. Se você não quer lidar com reputação de IP e configuração de email, aponte o SMTP para um serviço de relay como Mailgun, Brevo ou o próprio Gmail com uma app password.\nBackup O banco de dados é um único arquivo SQLite por site. Se esse arquivo corromper ou for apagado, você perde todo o histórico de comentários. A solução é um cron job que copie os arquivos periodicamente:\nsqlite3 /var/www/isso.seudominio.org/db/blog1.db \u0026#34;.backup /backups/isso-blog1-$(date +%Y%m%d).db\u0026#34; sqlite3 /var/www/isso.seudominio.org/db/blog2.db \u0026#34;.backup /backups/isso-blog2-$(date +%Y%m%d).db\u0026#34; O comando .backup do SQLite faz cópia a quente, sem inconsistências mesmo que o Isso esteja gravando no momento. O arquivo raramente passa de alguns megabytes, então copiar para um storage externo com rsync ou rclone é trivial.\nSe você organizou tudo dentro de /var/www/isso.seudominio.org/ como fizemos aqui, e seu servidor já tem um script de backup que varre /var/www/, os bancos de dados já estão cobertos sem configuração adicional.\nMigração do Disqus ou WordPress Se você já tem comentários em outro sistema, o Isso inclui uma ferramenta de importação:\n/var/www/isso.seudominio.org/venv/bin/isso -c /var/www/isso.seudominio.org/config/blog1.cfg import -t disqus disqus-export.xml /var/www/isso.seudominio.org/venv/bin/isso -c /var/www/isso.seudominio.org/config/blog1.cfg import -t wordpress wordpress-export.xml Os comentários ficam associados aos URIs das páginas originais. Se a estrutura de URLs mudou na migração para Hugo, os comentários importados vão apontar para os caminhos antigos. A correção é direta no SQLite:\nsqlite3 /var/www/isso.seudominio.org/db/blog1.db \\ \u0026#34;UPDATE threads SET uri = \u0026#39;/novo/caminho/\u0026#39; WHERE uri = \u0026#39;/caminho/antigo/\u0026#39;;\u0026#34; Conclusão O Isso faz exatamente o que promete — comentários self-hosted, leves e sem rastreamento — sem tentar fazer mais do que deveria. A instalação nativa com virtualenv e gunicorn é simples, previsível e consome recursos mínimos. O banco de dados é um arquivo que você pode abrir com sqlite3, os comentários são texto puro com Markdown, e se um dia o projeto for abandonado, seus dados continuam legíveis sem ferramentas especiais.\nPara quem já administra um servidor e quer manter controle total sobre os dados dos seus visitantes, é a escolha que faz mais sentido.\n","date":"28/03/2026","lang":"pt","tags":["segurança","sem-servidor","hugo","self-hosted","cloudflare","blog","comentários","isso","devops"],"title":"Comentários em sites estáticos com Isso — leve, self-hosted e sem rastreamento","url":"https://devops.sarmento.org/posts/comentarios-em-sites-estaticos-com-isso-leve-self-hosted-e-sem-rastreamento/"},{"categories":["Linux","Self-Hosting"],"content":"No post anterior, mostrei como o SSH-J.com resolve um problema específico: acessar via SSH uma máquina que está atrás de NAT, sem abrir portas no roteador e sem depender de IP público. O túnel reverso funciona bem para sessões interativas e transferência de arquivos, e o SSH-J.com como jump host torna tudo trivial de configurar. Para SSH, continua sendo a solução mais simples que conheço.\nMas SSH é só uma peça do quebra-cabeça. Quem mantém um homelab — mesmo que seja só um mini PC embaixo da mesa ou um Raspberry Pi no canto da sala — inevitavelmente acaba rodando serviços web: um leitor de RSS, um dashboard de monitoramento, um Gitea, um Jellyfin, um Immich. Esses serviços escutam em portas HTTP locais e funcionam perfeitamente enquanto você está na mesma rede. O problema aparece quando você quer acessá-los de fora — do escritório, do celular no ônibus, de qualquer lugar que não seja a sua rede local.\nAs opções tradicionais são as mesmas de sempre: port forwarding no roteador (que esbarra no CGNAT e expõe portas para a internet), DDNS para lidar com IP dinâmico (que resolve apenas metade do problema), ou uma VPN como WireGuard ou Tailscale (que funciona mas exige um cliente instalado em cada dispositivo). O Cloudflare Tunnel oferece uma alternativa que não requer nenhuma dessas coisas: o serviço local faz uma conexão de saída para a rede da Cloudflare, que por sua vez publica o serviço em um subdomínio do seu domínio, com HTTPS configurado automaticamente. Nenhuma porta aberta, nenhum IP público, nenhum certificado para gerenciar. De fora, o acesso é um URL normal no browser.\nNeste post, mostro como configurei o meu próprio setup: um leitor de RSS rodando em um container LXC no Proxmox, publicado em rss.sarmento.org através de um Cloudflare Tunnel gerenciado localmente via CLI e mantido por systemd.\nComo o Cloudflare Tunnel funciona O princípio é o mesmo do túnel reverso do SSH-J.com, só que aplicado a tráfego HTTP em vez de sessões SSH. Um daemon chamado cloudflared roda na sua máquina local e abre conexões de saída para a rede de edge da Cloudflare. Como a conexão parte de dentro da sua rede, o NAT não é problema — o roteador trata como qualquer outra conexão de saída. A Cloudflare recebe as requisições HTTP destinadas ao seu subdomínio e as encaminha pelo túnel até o cloudflared, que por sua vez as repassa para o serviço local.\nA diferença em relação ao SSH-J.com é que aqui não existe relay de terceiro operando com infraestrutura mínima — é a rede da Cloudflare, com mais de 300 pontos de presença no mundo, servindo de proxy reverso para o seu serviço. O HTTPS é provido pela Cloudflare usando certificados gerados automaticamente para o seu domínio. O tráfego entre o browser do visitante e a Cloudflare é criptografado normalmente com TLS. O tráfego entre a Cloudflare e o cloudflared na sua máquina viaja pela conexão de túnel, que por sua vez usa QUIC ou HTTP/2 com TLS. E entre o cloudflared e o serviço local, como ambos estão na mesma máquina, a comunicação acontece sobre localhost sem criptografia — o que é perfeitamente aceitável quando tudo roda no mesmo host.\nExistem duas formas de gerenciar um Cloudflare Tunnel: pelo dashboard web (Zero Trust → Tunnels) ou pela CLI. O dashboard é mais visual e permite configurar tudo pelo browser, mas o resultado é um túnel \u0026ldquo;remotamente gerenciado\u0026rdquo; — a configuração fica na Cloudflare e o cloudflared local só precisa de um token para conectar. A CLI cria um túnel \u0026ldquo;localmente gerenciado\u0026rdquo; onde a configuração fica em um arquivo YAML na máquina, o que dá mais controle e visibilidade sobre o que está rodando. Neste post uso a CLI porque é o caminho que faz mais sentido para quem já está confortável com terminal e quer entender o que cada peça faz.\nPré-requisitos Antes de começar, você precisa de três coisas.\nUm domínio próprio com o DNS gerenciado pela Cloudflare. Se o seu domínio já está na Cloudflare (por exemplo, porque você usa Cloudflare Pages para hospedar um site estático), esse requisito já está atendido. Se não está, o processo é adicionar o domínio na Cloudflare e apontar os nameservers do seu registrador para os que ela te fornece. A documentação oficial cobre isso em detalhes.\nUma conta gratuita na Cloudflare. O Cloudflare Tunnel faz parte do plano gratuito — não é necessário plano pago para uso pessoal.\nUma máquina Linux rodando o serviço que você quer expor. No meu caso é um container LXC com Debian 13 no Proxmox, mas pode ser qualquer distribuição com systemd. O serviço precisa estar escutando em uma porta local — no meu exemplo, um web app em http://127.0.0.1:8080.\nInstalando o cloudflared O cloudflared é o daemon que estabelece e mantém o túnel. No Debian, a instalação pode ser feita pelo repositório oficial da Cloudflare ou baixando o .deb diretamente. Optei por baixar o pacote:\nwget https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64.deb sudo dpkg -i cloudflared-linux-amd64.deb Confirme que a instalação funcionou:\ncloudflared --version A saída deve mostrar a versão instalada. Se você estiver em ARM (Raspberry Pi, por exemplo), troque amd64 por arm64 no URL do download.\nAutenticando na Cloudflare O próximo passo é vincular o cloudflared à sua conta Cloudflare. Execute:\ncloudflared tunnel login O comando vai gerar um URL e pedir que você o abra no browser. Na página que abrir, faça login na sua conta Cloudflare e selecione o domínio que será usado para o túnel. Após a autorização, o cloudflared salva um certificado em ~/.cloudflared/cert.pem que será usado para criar e gerenciar túneis.\nSe a máquina onde você está instalando o cloudflared não tem browser (que é o caso comum em servidores), copie o URL exibido no terminal e abra-o em qualquer outro computador onde você esteja logado na Cloudflare. O fluxo de autorização acontece no browser, não na máquina local.\nCriando o túnel Com a autenticação feita, crie o túnel:\ncloudflared tunnel create homelab O nome homelab é um identificador que você escolhe — pode ser qualquer coisa descritiva. O comando cria o túnel na sua conta Cloudflare e gera um arquivo de credenciais em ~/.cloudflared/\u0026lt;UUID\u0026gt;.json, onde \u0026lt;UUID\u0026gt; é o identificador único do túnel. Anote esse UUID — você vai precisar dele na configuração.\nPara confirmar que o túnel foi criado:\ncloudflared tunnel list A saída mostra o nome, o UUID e o status de cada túnel associado à sua conta.\nConfigurando o roteamento O túnel existe, mas ainda não sabe para onde encaminhar o tráfego. Essa configuração é feita em um arquivo YAML. Crie ~/.cloudflared/config.yml:\ntunnel: \u0026lt;UUID\u0026gt; credentials-file: /root/.cloudflared/\u0026lt;UUID\u0026gt;.json ingress: - hostname: rss.sarmento.org service: http://127.0.0.1:8080 - service: http_status:404 Troque \u0026lt;UUID\u0026gt; pelo UUID real do seu túnel e ajuste o hostname e a porta para o seu caso. O bloco ingress define as regras de roteamento: requisições para rss.sarmento.org são encaminhadas para o serviço local na porta 8080, e qualquer outra requisição recebe um 404. A última regra com service: http_status:404 é obrigatória — o cloudflared exige uma regra catch-all no final do ingress.\nSe você quiser expor mais de um serviço pelo mesmo túnel, basta adicionar entradas ao ingress antes da regra catch-all:\ningress: - hostname: rss.sarmento.org service: http://127.0.0.1:8080 - hostname: git.sarmento.org service: http://127.0.0.1:3000 - service: http_status:404 Cada hostname precisa de um registro DNS na Cloudflare, que é o próximo passo.\nCriando o registro DNS O cloudflared tem um comando que cria automaticamente o registro CNAME apontando o subdomínio para o túnel:\ncloudflared tunnel route dns homelab rss.sarmento.org Esse comando cria um registro CNAME no DNS da Cloudflare apontando rss.sarmento.org para \u0026lt;UUID\u0026gt;.cfargotunnel.com. A partir desse momento, qualquer requisição para rss.sarmento.org vai bater na rede da Cloudflare, que vai procurar um túnel ativo com aquele UUID para encaminhá-la.\nVocê pode verificar o registro no dashboard da Cloudflare, em DNS → Records do seu domínio. O registro CNAME deve aparecer lá com o status de proxy ativo (nuvem laranja).\nTestando manualmente Antes de criar o serviço systemd, teste o túnel manualmente para confirmar que tudo funciona:\ncloudflared tunnel run homelab O cloudflared deve conectar à rede da Cloudflare e começar a mostrar logs no terminal. Abra https://rss.sarmento.org no browser — o serviço local deve aparecer, servido com HTTPS, sem nenhuma configuração adicional de certificado. O terminal vai mostrar as requisições sendo encaminhadas. Quando estiver satisfeito que tudo funciona, interrompa com Ctrl+C.\nRodando como serviço com systemd O cloudflared tem um comando integrado que cria e configura o serviço systemd automaticamente:\nsudo cloudflared service install Esse comando faz três coisas: copia a configuração de ~/.cloudflared/config.yml para /etc/cloudflared/config.yml, copia o arquivo de credenciais para /etc/cloudflared/, e cria a unit file em /etc/systemd/system/cloudflared.service com o conteúdo adequado. A unit file gerada se parece com isto:\n[Unit] Description=cloudflared After=network.target [Service] TimeoutStartSec=0 Type=notify ExecStart=/usr/bin/cloudflared --no-autoupdate --config /etc/cloudflared/config.yml tunnel run Restart=on-failure RestartSec=5s [Install] WantedBy=multi-user.target O Type=notify indica que o cloudflared avisa o systemd quando está pronto para receber tráfego — uma integração mais sofisticada do que o Restart=always que usamos no post do SSH-J.com. O --no-autoupdate desabilita o mecanismo de atualização automática do cloudflared, que é o comportamento correto quando você gerencia pacotes pelo sistema operacional.\nAtive e inicie o serviço:\nsudo systemctl enable --now cloudflared Confirme que está rodando:\nsudo systemctl status cloudflared A saída deve mostrar active (running) e os logs de conexão. A partir de agora o túnel inicia automaticamente no boot e reconecta em caso de falha.\nDiferenças em relação ao SSH-J.com Vale colocar as duas soluções lado a lado para entender quando usar cada uma.\nO SSH-J.com é ideal para acesso SSH a máquinas atrás de NAT. Não precisa de conta, não precisa de domínio, e a configuração inteira é um comando SSH. A contrapartida é que o tráfego passa por um servidor de terceiros (embora criptografado ponta a ponta) e o serviço se limita a encaminhar conexões TCP — não serve para expor aplicações web com HTTPS.\nO Cloudflare Tunnel serve para expor serviços HTTP e HTTPS na internet com um hostname próprio. O HTTPS vem de graça, o DNS é gerenciado pela Cloudflare, e o tráfego passa pela rede de edge deles. Em compensação, exige um domínio com DNS na Cloudflare, instalação do cloudflared na máquina, e uma configuração um pouco mais envolvida. É possível usar o Cloudflare Tunnel para SSH também (a Cloudflare tem documentação específica para isso), mas a complexidade é consideravelmente maior do que simplesmente usar o SSH-J.com.\nNa prática, as duas soluções coexistem sem conflito. Uso o SSH-J.com para acessar máquinas via SSH quando estou fora de casa, e o Cloudflare Tunnel para publicar web apps do homelab com domínio e HTTPS. Cada ferramenta resolve o problema para o qual foi desenhada.\nQuando algo dá errado O túnel conecta mas o site não carrega O problema mais comum é um descompasso entre o hostname configurado no ingress e o registro DNS criado na Cloudflare. Se o CNAME aponta para um UUID diferente do que está no config.yml, o tráfego não chega ao túnel certo. Verifique com:\ncloudflared tunnel info homelab Compare o UUID exibido com o que está no config.yml e no registro CNAME do dashboard.\nOutro cenário frequente é o serviço local não estar rodando ou estar escutando em um endereço ou porta diferente do configurado. Se o ingress aponta para http://127.0.0.1:8080 mas o serviço está na porta 3000, o cloudflared vai receber um connection refused e retornar um erro 502 para o browser. Teste o serviço localmente antes de culpar o túnel:\ncurl http://127.0.0.1:8080 Se o curl funciona mas o browser não, o problema está entre o cloudflared e a Cloudflare, não entre o cloudflared e o serviço local.\nO serviço systemd fica reiniciando Se o systemctl status cloudflared mostra ciclos de start → failed → auto-restart, o problema quase certamente está na configuração. Olhe os logs:\njournalctl -u cloudflared -n 50 --no-pager Os erros mais comuns são: arquivo de credenciais não encontrado (o cloudflared service install não copiou corretamente, ou o path no config.yml está errado), YAML inválido no config.yml (indentação errada, campo faltando), ou falta da regra catch-all no final do ingress.\nCertificado SSL inválido no browser Se o browser mostra erro de certificado ao acessar o subdomínio, verifique no dashboard da Cloudflare se o modo SSL/TLS está configurado como \u0026ldquo;Full\u0026rdquo; ou \u0026ldquo;Flexible\u0026rdquo; — não \u0026ldquo;Off\u0026rdquo;. A Cloudflare gera o certificado automaticamente para o seu domínio, mas o modo SSL precisa estar ativo para que ele seja servido. Isso normalmente já é o padrão para domínios com proxy ativo.\n","date":"27/03/2026","lang":"pt","tags":["segurança","devops","self-hosted","cloudflare","homelab","ssh","nat","túneis-reversos","jump-host","automatização","privacidade","armazenamento","open-source"],"title":"Expondo serviços do homelab na internet com Cloudflare Tunnel","url":"https://devops.sarmento.org/posts/expondo-servicos-do-homelab-na-internet-com-cloudflare-tunnel/"},{"categories":["Linux","macOS"],"content":"Os dois posts anteriores montaram a infraestrutura de monitoramento — WatchPaths no macOS, systemd path units e inotifywait no Linux — e prometeram que os scripts viriam depois. O gatilho está pronto: o launchd ou o systemd detecta quando algo muda num diretório e dispara um comando. Falta o comando.\nEste post entrega o script de conversão de imagens que aqueles gatilhos vão disparar. O objetivo é simples: PNGs e JPGs entram numa pasta, WEBP ou AVIF saem. Os originais são apagados ou movidos, dependendo da configuração. O script detecta quais encoders estão disponíveis na máquina e escolhe o melhor entre os instalados, com uma cadeia de fallback que garante funcionamento mesmo quando a ferramenta ideal não está presente. Se nenhum encoder compatível for encontrado, o script avisa o que instalar e em qual gerenciador de pacotes.\nO resultado é um script que pode ser usado manualmente (./optimize-images.sh) ou plugado diretamente nos plists e path units dos posts anteriores sem nenhuma adaptação. A ideia é que ele funcione tanto no Mac de quem escreve blog posts quanto no servidor Linux que processa uploads — a mesma lógica, os mesmos fallbacks, os mesmos cuidados.\nO problema com converter \u0026ldquo;depois\u0026rdquo; A conversão de imagens para formatos modernos é uma daquelas tarefas que todo mundo sabe que deveria fazer e quase ninguém faz de forma consistente. As razões são conhecidas: um PNG de 3 MB vira um AVIF de 300 KB com qualidade visual indistinguível; um JPG de câmera com 6 MB cai para menos de 800 KB em WEBP. A diferença não é marginal — é uma ordem de grandeza. Para blogs, portfólios, documentação, e-commerce, qualquer contexto onde imagens são servidas por HTTP, o impacto no tempo de carregamento e no consumo de banda é direto e mensurável.\nO problema nunca foi técnico. Ferramentas de conversão existem há anos, tanto gráficas quanto de linha de comando. O Squoosh do Google converte no navegador. O ImageMagick converte qualquer coisa para qualquer coisa. O cwebp e o avifenc são rápidos, gratuitos e instaláveis em uma linha. A barreira é comportamental: cada imagem que passa pelo fluxo de trabalho sem ser convertida é uma decisão que alguém não tomou. Salvar o PNG, abrir o conversor, escolher o formato, ajustar a qualidade, salvar o resultado, apagar o original — são seis passos que competem com tudo mais que a pessoa está fazendo naquele momento. Na segunda semana, o conversor já não é aberto. Na terceira, o blog está servindo PNGs de 4 MB sem que ninguém perceba.\nA solução é eliminar a decisão. O script deste post transforma a conversão num processo que acontece sem intervenção: a imagem é salva num diretório, e na próxima vez que o usuário olhar, ela já está em WEBP ou AVIF. Combinado com o monitoramento do launchd ou do systemd, o intervalo entre salvar e converter cai para segundos. O humano cuida do conteúdo; a máquina cuida do formato.\nO script Configuração O bloco de configuração fica no topo do script e concentra todas as decisões que o usuário precisa tomar. São cinco variáveis:\nWATCH_DIR=\u0026#34;$HOME/Pictures/optimize\u0026#34; OUTPUT_FORMAT=\u0026#34;avif\u0026#34; QUALITY=80 ORIGINAL_ACTION=\u0026#34;delete\u0026#34; ORIGINALS_DIR=\u0026#34;$WATCH_DIR/originals\u0026#34; OUTPUT_FORMAT aceita avif ou webp. O script valida o valor antes de fazer qualquer coisa e encerra com erro se for algo diferente.\nQUALITY é o parâmetro de qualidade passado ao encoder, numa escala de 0 a 100. O valor 80 é um bom ponto de partida para imagens fotográficas — compressão significativa sem artefatos visíveis. Para capturas de tela com texto e bordas definidas, valores entre 85 e 95 preservam melhor a nitidez. O parâmetro é repassado diretamente ao encoder escolhido, e embora a escala seja nominalmente a mesma entre ferramentas, o resultado visual para o mesmo número pode variar ligeiramente entre cwebp, avifenc e ImageMagick. Na prática, a diferença é pequena o suficiente para não justificar tabelas de conversão entre encoders.\nORIGINAL_ACTION controla o que acontece com o arquivo de origem após uma conversão bem-sucedida. Com delete, o original é apagado. Com move, ele é movido para o subdiretório definido em ORIGINALS_DIR — útil para quem quer uma rede de segurança antes de confiar plenamente na qualidade da conversão automática. O diretório de originais é criado automaticamente se não existir.\nDetecção de encoders Antes de processar qualquer imagem, o script precisa descobrir o que está instalado na máquina. A abordagem é direta: testar a presença de cada encoder com command -v, que retorna sucesso se o binário existe no PATH e falha silenciosamente se não existe.\ndetect_encoder() { local format format=\u0026#34;$1\u0026#34; case \u0026#34;$format\u0026#34; in avif) if command -v avifenc \u0026gt;/dev/null 2\u0026gt;\u0026amp;1; then echo \u0026#34;avifenc\u0026#34; elif command -v magick \u0026gt;/dev/null 2\u0026gt;\u0026amp;1; then echo \u0026#34;magick\u0026#34; elif command -v convert \u0026gt;/dev/null 2\u0026gt;\u0026amp;1 \u0026amp;\u0026amp; \\ convert -list format 2\u0026gt;/dev/null | grep -qi \u0026#34;avif\u0026#34;; then echo \u0026#34;convert\u0026#34; elif command -v ffmpeg \u0026gt;/dev/null 2\u0026gt;\u0026amp;1 \u0026amp;\u0026amp; \\ ffmpeg -encoders 2\u0026gt;/dev/null | grep -q \u0026#34;libaom-av1\u0026#34;; then echo \u0026#34;ffmpeg\u0026#34; else echo \u0026#34;\u0026#34; fi ;; webp) if command -v cwebp \u0026gt;/dev/null 2\u0026gt;\u0026amp;1; then echo \u0026#34;cwebp\u0026#34; elif command -v magick \u0026gt;/dev/null 2\u0026gt;\u0026amp;1; then echo \u0026#34;magick\u0026#34; elif command -v convert \u0026gt;/dev/null 2\u0026gt;\u0026amp;1 \u0026amp;\u0026amp; \\ convert -list format 2\u0026gt;/dev/null | grep -qi \u0026#34;webp\u0026#34;; then echo \u0026#34;convert\u0026#34; elif command -v ffmpeg \u0026gt;/dev/null 2\u0026gt;\u0026amp;1 \u0026amp;\u0026amp; \\ ffmpeg -encoders 2\u0026gt;/dev/null | grep -q \u0026#34;libwebp\u0026#34;; then echo \u0026#34;ffmpeg\u0026#34; else echo \u0026#34;\u0026#34; fi ;; esac } A função retorna o nome do encoder escolhido como string, ou uma string vazia se nenhum encoder for encontrado. O chamador verifica o resultado e age de acordo — continua para a conversão ou encerra com uma mensagem de ajuda.\nPara o ImageMagick, a detecção tem uma sutileza. Versões mais recentes (7.x) usam o binário magick como ponto de entrada unificado; versões anteriores (6.x) usam convert diretamente. O script testa magick primeiro e cai para convert se necessário. Mas a presença do binário não garante suporte ao formato — uma instalação do ImageMagick compilada sem os delegates certos pode não saber ler ou escrever AVIF ou WEBP. Por isso o teste com convert -list format antes de aceitar o ImageMagick como encoder válido: se o formato não aparece na lista de formatos suportados, o encoder é descartado e a detecção continua para o próximo candidato.\nO ffmpeg é o último recurso em ambas as cadeias. É uma ferramenta de vídeo que também converte imagens, mas não é a melhor escolha para o trabalho — os parâmetros são menos intuitivos, a documentação é voltada para fluxos de vídeo, e o controle de qualidade para imagens estáticas é menos refinado. Funciona, mas se o ffmpeg é o único encoder disponível, provavelmente vale a pena instalar a ferramenta dedicada.\nA hierarquia de fallback A ordem de preferência não é arbitrária. Para cada formato, o encoder dedicado vem primeiro porque é o que oferece melhor controle sobre os parâmetros de compressão e o que produz os melhores resultados para o mesmo nível de qualidade.\nPara AVIF, a cadeia é: avifenc → ImageMagick → ffmpeg. O avifenc (do pacote libavif) é a referência — desenvolvido pela Alliance for Open Media, a mesma organização responsável pelo formato. Aceita parâmetros granulares como speed (velocidade de codificação vs. eficiência de compressão) e suporta profundidade de cor de 10 e 12 bits. O ImageMagick delega para o libavif ou para o libaom internamente, então o resultado é comparável, mas os parâmetros de ajuste fino ficam escondidos atrás da interface genérica do convert. O ffmpeg usa o libaom-av1 e funciona, mas a sintaxe para imagens estáticas é desconfortável — o conceito de \u0026ldquo;um frame de vídeo\u0026rdquo; como imagem é um encaixe forçado.\nPara WEBP, a cadeia é: cwebp → ImageMagick → ffmpeg. O cwebp (do pacote webp do Google) é o encoder de referência, com controle preciso de qualidade, suporte a perfis de compressão lossy e lossless, e saída otimizada para imagens fotográficas. O ImageMagick usa o libwebp internamente e produz resultados equivalentes. O ffmpeg com libwebp funciona mas, novamente, é a opção menos ergonômica.\nA função de conversão recebe o nome do encoder e despacha para a sintaxe correta:\nconvert_image() { local input output encoder quality input=\u0026#34;$1\u0026#34; output=\u0026#34;$2\u0026#34; encoder=\u0026#34;$3\u0026#34; quality=\u0026#34;$4\u0026#34; case \u0026#34;$encoder\u0026#34; in avifenc) avifenc --min 0 --max 63 -a end-usage=q \\ -a cq-level=$((63 - quality * 63 / 100)) \\ --speed 6 \u0026#34;$input\u0026#34; \u0026#34;$output\u0026#34; ;; cwebp) cwebp -q \u0026#34;$quality\u0026#34; \u0026#34;$input\u0026#34; -o \u0026#34;$output\u0026#34; ;; magick) magick \u0026#34;$input\u0026#34; -quality \u0026#34;$quality\u0026#34; \u0026#34;$output\u0026#34; ;; convert) convert \u0026#34;$input\u0026#34; -quality \u0026#34;$quality\u0026#34; \u0026#34;$output\u0026#34; ;; ffmpeg) if [[ \u0026#34;$output\u0026#34; == *.avif ]]; then ffmpeg -y -i \u0026#34;$input\u0026#34; \\ -c:v libaom-av1 -crf $((63 - quality * 63 / 100)) \\ -still-picture 1 \u0026#34;$output\u0026#34; 2\u0026gt;/dev/null else ffmpeg -y -i \u0026#34;$input\u0026#34; \\ -c:v libwebp -quality \u0026#34;$quality\u0026#34; \\ \u0026#34;$output\u0026#34; 2\u0026gt;/dev/null fi ;; esac } O avifenc tem uma peculiaridade: seu parâmetro de qualidade (cq-level) usa uma escala invertida, onde 0 é a melhor qualidade e 63 é a pior. O script traduz o valor de 0–100 para 63–0 automaticamente, de modo que QUALITY=80 no topo do script significa a mesma coisa independentemente de qual encoder for selecionado. O ffmpeg com libaom-av1 usa o crf na mesma escala invertida e recebe a mesma conversão. Para cwebp, ImageMagick e ffmpeg com libwebp, o valor é passado diretamente — as três ferramentas usam 0–100 onde valores maiores significam melhor qualidade.\nProteção contra arquivos incompletos Quando o script é disparado por um WatchPaths ou PathChanged, a execução começa segundos depois de o arquivo aparecer no diretório. Mas \u0026ldquo;aparecer\u0026rdquo; não significa \u0026ldquo;estar completo\u0026rdquo;. Um navegador salvando uma imagem grande, um aplicativo de edição exportando um PNG de alta resolução, um cp de um volume de rede lento — em todos esses casos, o arquivo é criado (e o gatilho disparado) antes de o conteúdo estar integralmente gravado no disco.\nConverter uma imagem parcialmente escrita produz uma de duas coisas: um arquivo de saída corrompido que parece ter metade da imagem, ou um erro do encoder que encerra o script. Nenhum dos dois é aceitável, especialmente se o ORIGINAL_ACTION for delete — apagar o original de um arquivo que não foi convertido com sucesso é perda de dados.\nA proteção é um loop que compara o tamanho do arquivo em dois momentos separados por um intervalo curto:\nwait_for_stable() { local filepath size_before size_after filepath=\u0026#34;$1\u0026#34; for _ in 1 2 3; do size_before=$(stat -c%s \u0026#34;$filepath\u0026#34; 2\u0026gt;/dev/null || \\ stat -f%z \u0026#34;$filepath\u0026#34; 2\u0026gt;/dev/null || echo \u0026#34;0\u0026#34;) sleep 1 size_after=$(stat -c%s \u0026#34;$filepath\u0026#34; 2\u0026gt;/dev/null || \\ stat -f%z \u0026#34;$filepath\u0026#34; 2\u0026gt;/dev/null || echo \u0026#34;0\u0026#34;) if [[ \u0026#34;$size_before\u0026#34; == \u0026#34;$size_after\u0026#34; \u0026amp;\u0026amp; \u0026#34;$size_after\u0026#34; != \u0026#34;0\u0026#34; ]]; then return 0 fi done return 1 } O stat tem sintaxe diferente no macOS e no Linux — -c%s no GNU coreutils, -f%z no BSD. O script tenta os dois e usa o que funcionar. O loop faz até três tentativas com intervalo de um segundo entre cada comparação. Se após três rodadas o tamanho ainda estiver mudando, a função retorna falha e o script pula aquele arquivo — ele será processado na próxima execução, quando o gatilho disparar novamente após a escrita ser concluída.\nO teste \u0026quot;$size_after\u0026quot; != \u0026quot;0\u0026quot; protege contra um edge case sutil: um arquivo que foi criado mas ainda está vazio (tamanho zero em ambas as medições). Isso pode acontecer quando um aplicativo cria o arquivo, abre o file descriptor, mas ainda não começou a escrever. Sem essa verificação, o script consideraria o arquivo estável (o tamanho não mudou) e tentaria converter um arquivo vazio.\nConversão e limpeza O loop principal varre o diretório, processa cada arquivo elegível e cuida dos originais:\nprocess_images() { local encoder file basename output encoder=$(detect_encoder \u0026#34;$OUTPUT_FORMAT\u0026#34;) if [[ -z \u0026#34;$encoder\u0026#34; ]]; then suggest_install \u0026#34;$OUTPUT_FORMAT\u0026#34; exit 1 fi echo \u0026#34;$(date \u0026#39;+%Y-%m-%d %H:%M:%S\u0026#39;) Encoder: $encoder (format: $OUTPUT_FORMAT)\u0026#34; find \u0026#34;$WATCH_DIR\u0026#34; -maxdepth 1 -type f \\ \\( -iname \u0026#39;*.png\u0026#39; -o -iname \u0026#39;*.jpg\u0026#39; -o -iname \u0026#39;*.jpeg\u0026#39; \\) | while IFS= read -r file; do basename=\u0026#34;${file%.*}\u0026#34; output=\u0026#34;${basename}.${OUTPUT_FORMAT}\u0026#34; if ! wait_for_stable \u0026#34;$file\u0026#34;; then echo \u0026#34;$(date \u0026#39;+%Y-%m-%d %H:%M:%S\u0026#39;) Skipped (still writing): $file\u0026#34; continue fi if convert_image \u0026#34;$file\u0026#34; \u0026#34;$output\u0026#34; \u0026#34;$encoder\u0026#34; \u0026#34;$QUALITY\u0026#34;; then echo \u0026#34;$(date \u0026#39;+%Y-%m-%d %H:%M:%S\u0026#39;) Converted: $file -\u0026gt; $output\u0026#34; handle_original \u0026#34;$file\u0026#34; else echo \u0026#34;$(date \u0026#39;+%Y-%m-%d %H:%M:%S\u0026#39;) Failed: $file\u0026#34; rm -f \u0026#34;$output\u0026#34; fi done } O find com -maxdepth 1 evita descer em subdiretórios — inclusive no originals/, o que seria desastroso se os originais movidos fossem reprocessados. O -iname torna a busca case-insensitive, capturando tanto .PNG quanto .png e .Jpg. O while IFS= read -r em vez de for file in $(find ...) trata corretamente nomes de arquivo com espaços.\nQuando a conversão falha, o rm -f \u0026quot;$output\u0026quot; apaga qualquer arquivo de saída parcial que o encoder tenha criado antes de abortar. Deixar um AVIF corrompido no diretório causaria confusão — pareceria que a conversão foi bem-sucedida, mas a imagem estaria quebrada.\nA função handle_original encapsula a decisão configurada em ORIGINAL_ACTION:\nhandle_original() { local file file=\u0026#34;$1\u0026#34; case \u0026#34;$ORIGINAL_ACTION\u0026#34; in delete) rm -f \u0026#34;$file\u0026#34; ;; move) mkdir -p \u0026#34;$ORIGINALS_DIR\u0026#34; mv \u0026#34;$file\u0026#34; \u0026#34;$ORIGINALS_DIR/\u0026#34; ;; esac } A última peça é a função que sugere a instalação quando nenhum encoder é encontrado:\nsuggest_install() { local format format=\u0026#34;$1\u0026#34; echo \u0026#34;Error: no encoder found for $format.\u0026#34; if [[ \u0026#34;$(uname)\u0026#34; == \u0026#34;Darwin\u0026#34; ]]; then case \u0026#34;$format\u0026#34; in avif) echo \u0026#34;Install with: brew install libavif\u0026#34; ;; webp) echo \u0026#34;Install with: brew install webp\u0026#34; ;; esac else case \u0026#34;$format\u0026#34; in avif) echo \u0026#34;Install with: sudo apt install libavif-bin\u0026#34; ;; webp) echo \u0026#34;Install with: sudo apt install webp\u0026#34; ;; esac fi } A detecção de sistema usa uname — Darwin para macOS, qualquer outra coisa assume Linux com apt. É uma simplificação deliberada: o script não tenta detectar se o sistema usa dnf, pacman ou apk. Quem usa Fedora ou Arch sabe encontrar o pacote equivalente; quem usa Debian, Ubuntu ou qualquer derivado — que é a grande maioria dos servidores Linux em produção — recebe a sugestão correta.\nO script completo Cada função foi explicada isoladamente nas seções anteriores. Aqui está o script montado, pronto para salvar em ~/bin/optimize-images.sh e tornar executável com chmod +x:\n#!/usr/bin/env bash set -euo pipefail # -- Config ------------------------------------------------------------------ WATCH_DIR=\u0026#34;$HOME/Pictures/optimize\u0026#34; OUTPUT_FORMAT=\u0026#34;avif\u0026#34; QUALITY=80 ORIGINAL_ACTION=\u0026#34;delete\u0026#34; ORIGINALS_DIR=\u0026#34;$WATCH_DIR/originals\u0026#34; # -- Functions --------------------------------------------------------------- detect_encoder() { local format format=\u0026#34;$1\u0026#34; case \u0026#34;$format\u0026#34; in avif) if command -v avifenc \u0026gt;/dev/null 2\u0026gt;\u0026amp;1; then echo \u0026#34;avifenc\u0026#34; elif command -v magick \u0026gt;/dev/null 2\u0026gt;\u0026amp;1; then echo \u0026#34;magick\u0026#34; elif command -v convert \u0026gt;/dev/null 2\u0026gt;\u0026amp;1 \u0026amp;\u0026amp; \\ convert -list format 2\u0026gt;/dev/null | grep -qi \u0026#34;avif\u0026#34;; then echo \u0026#34;convert\u0026#34; elif command -v ffmpeg \u0026gt;/dev/null 2\u0026gt;\u0026amp;1 \u0026amp;\u0026amp; \\ ffmpeg -encoders 2\u0026gt;/dev/null | grep -q \u0026#34;libaom-av1\u0026#34;; then echo \u0026#34;ffmpeg\u0026#34; else echo \u0026#34;\u0026#34; fi ;; webp) if command -v cwebp \u0026gt;/dev/null 2\u0026gt;\u0026amp;1; then echo \u0026#34;cwebp\u0026#34; elif command -v magick \u0026gt;/dev/null 2\u0026gt;\u0026amp;1; then echo \u0026#34;magick\u0026#34; elif command -v convert \u0026gt;/dev/null 2\u0026gt;\u0026amp;1 \u0026amp;\u0026amp; \\ convert -list format 2\u0026gt;/dev/null | grep -qi \u0026#34;webp\u0026#34;; then echo \u0026#34;convert\u0026#34; elif command -v ffmpeg \u0026gt;/dev/null 2\u0026gt;\u0026amp;1 \u0026amp;\u0026amp; \\ ffmpeg -encoders 2\u0026gt;/dev/null | grep -q \u0026#34;libwebp\u0026#34;; then echo \u0026#34;ffmpeg\u0026#34; else echo \u0026#34;\u0026#34; fi ;; esac } suggest_install() { local format format=\u0026#34;$1\u0026#34; echo \u0026#34;Error: no encoder found for $format.\u0026#34; if [[ \u0026#34;$(uname)\u0026#34; == \u0026#34;Darwin\u0026#34; ]]; then case \u0026#34;$format\u0026#34; in avif) echo \u0026#34;Install with: brew install libavif\u0026#34; ;; webp) echo \u0026#34;Install with: brew install webp\u0026#34; ;; esac else case \u0026#34;$format\u0026#34; in avif) echo \u0026#34;Install with: sudo apt install libavif-bin\u0026#34; ;; webp) echo \u0026#34;Install with: sudo apt install webp\u0026#34; ;; esac fi } wait_for_stable() { local filepath size_before size_after filepath=\u0026#34;$1\u0026#34; for _ in 1 2 3; do size_before=$(stat -c%s \u0026#34;$filepath\u0026#34; 2\u0026gt;/dev/null || \\ stat -f%z \u0026#34;$filepath\u0026#34; 2\u0026gt;/dev/null || echo \u0026#34;0\u0026#34;) sleep 1 size_after=$(stat -c%s \u0026#34;$filepath\u0026#34; 2\u0026gt;/dev/null || \\ stat -f%z \u0026#34;$filepath\u0026#34; 2\u0026gt;/dev/null || echo \u0026#34;0\u0026#34;) if [[ \u0026#34;$size_before\u0026#34; == \u0026#34;$size_after\u0026#34; \u0026amp;\u0026amp; \u0026#34;$size_after\u0026#34; != \u0026#34;0\u0026#34; ]]; then return 0 fi done return 1 } convert_image() { local input output encoder quality input=\u0026#34;$1\u0026#34; output=\u0026#34;$2\u0026#34; encoder=\u0026#34;$3\u0026#34; quality=\u0026#34;$4\u0026#34; case \u0026#34;$encoder\u0026#34; in avifenc) avifenc --min 0 --max 63 -a end-usage=q \\ -a cq-level=$((63 - quality * 63 / 100)) \\ --speed 6 \u0026#34;$input\u0026#34; \u0026#34;$output\u0026#34; ;; cwebp) cwebp -q \u0026#34;$quality\u0026#34; \u0026#34;$input\u0026#34; -o \u0026#34;$output\u0026#34; ;; magick) magick \u0026#34;$input\u0026#34; -quality \u0026#34;$quality\u0026#34; \u0026#34;$output\u0026#34; ;; convert) convert \u0026#34;$input\u0026#34; -quality \u0026#34;$quality\u0026#34; \u0026#34;$output\u0026#34; ;; ffmpeg) if [[ \u0026#34;$output\u0026#34; == *.avif ]]; then ffmpeg -y -i \u0026#34;$input\u0026#34; \\ -c:v libaom-av1 -crf $((63 - quality * 63 / 100)) \\ -still-picture 1 \u0026#34;$output\u0026#34; 2\u0026gt;/dev/null else ffmpeg -y -i \u0026#34;$input\u0026#34; \\ -c:v libwebp -quality \u0026#34;$quality\u0026#34; \\ \u0026#34;$output\u0026#34; 2\u0026gt;/dev/null fi ;; esac } handle_original() { local file file=\u0026#34;$1\u0026#34; case \u0026#34;$ORIGINAL_ACTION\u0026#34; in delete) rm -f \u0026#34;$file\u0026#34; ;; move) mkdir -p \u0026#34;$ORIGINALS_DIR\u0026#34; mv \u0026#34;$file\u0026#34; \u0026#34;$ORIGINALS_DIR/\u0026#34; ;; esac } process_images() { local encoder file basename output encoder=$(detect_encoder \u0026#34;$OUTPUT_FORMAT\u0026#34;) if [[ -z \u0026#34;$encoder\u0026#34; ]]; then suggest_install \u0026#34;$OUTPUT_FORMAT\u0026#34; exit 1 fi echo \u0026#34;$(date \u0026#39;+%Y-%m-%d %H:%M:%S\u0026#39;) Encoder: $encoder (format: $OUTPUT_FORMAT)\u0026#34; find \u0026#34;$WATCH_DIR\u0026#34; -maxdepth 1 -type f \\ \\( -iname \u0026#39;*.png\u0026#39; -o -iname \u0026#39;*.jpg\u0026#39; -o -iname \u0026#39;*.jpeg\u0026#39; \\) | while IFS= read -r file; do basename=\u0026#34;${file%.*}\u0026#34; output=\u0026#34;${basename}.${OUTPUT_FORMAT}\u0026#34; if ! wait_for_stable \u0026#34;$file\u0026#34;; then echo \u0026#34;$(date \u0026#39;+%Y-%m-%d %H:%M:%S\u0026#39;) Skipped (still writing): $file\u0026#34; continue fi if convert_image \u0026#34;$file\u0026#34; \u0026#34;$output\u0026#34; \u0026#34;$encoder\u0026#34; \u0026#34;$QUALITY\u0026#34;; then echo \u0026#34;$(date \u0026#39;+%Y-%m-%d %H:%M:%S\u0026#39;) Converted: $file -\u0026gt; $output\u0026#34; handle_original \u0026#34;$file\u0026#34; else echo \u0026#34;$(date \u0026#39;+%Y-%m-%d %H:%M:%S\u0026#39;) Failed: $file\u0026#34; rm -f \u0026#34;$output\u0026#34; fi done } # -- Validation -------------------------------------------------------------- if [[ \u0026#34;$OUTPUT_FORMAT\u0026#34; != \u0026#34;avif\u0026#34; \u0026amp;\u0026amp; \u0026#34;$OUTPUT_FORMAT\u0026#34; != \u0026#34;webp\u0026#34; ]]; then echo \u0026#34;Error: OUTPUT_FORMAT must be \u0026#39;avif\u0026#39; or \u0026#39;webp\u0026#39;, got \u0026#39;$OUTPUT_FORMAT\u0026#39;\u0026#34; exit 1 fi if [[ \u0026#34;$ORIGINAL_ACTION\u0026#34; != \u0026#34;delete\u0026#34; \u0026amp;\u0026amp; \u0026#34;$ORIGINAL_ACTION\u0026#34; != \u0026#34;move\u0026#34; ]]; then echo \u0026#34;Error: ORIGINAL_ACTION must be \u0026#39;delete\u0026#39; or \u0026#39;move\u0026#39;, got \u0026#39;$ORIGINAL_ACTION\u0026#39;\u0026#34; exit 1 fi mkdir -p \u0026#34;$WATCH_DIR\u0026#34; # -- Main -------------------------------------------------------------------- process_images Salvar, tornar executável e testar manualmente antes de plugar no launchd ou no systemd:\nchmod +x ~/bin/optimize-images.sh cp alguma-foto.jpg ~/Pictures/optimize/ ~/bin/optimize-images.sh ls -lh ~/Pictures/optimize/ O ls -lh confirma que o AVIF (ou WEBP) foi criado e mostra o tamanho comparado. Se tudo funcionou, o arquivo original sumiu (ou foi movido para originals/, dependendo da configuração). A partir daqui, basta ativar o gatilho no sistema operacional — que é o assunto da próxima seção.\nInstalando os encoders O script funciona com qualquer encoder da cadeia de fallback, mas o resultado ideal vem das ferramentas dedicadas — avifenc para AVIF e cwebp para WEBP. São as que oferecem melhor controle de qualidade, melhor compressão para o mesmo nível visual, e os parâmetros mais previsíveis. O ImageMagick e o ffmpeg funcionam como rede de segurança, não como primeira escolha.\nmacOS (Homebrew) Para AVIF:\nbrew install libavif O pacote instala o avifenc (encoder) e o avifdec (decoder). O encoder é compilado com suporte a libaom, que é a implementação de referência do AV1 — a mesma que o ffmpeg usaria, mas com uma interface otimizada para imagens estáticas em vez de vídeo.\nPara WEBP:\nbrew install webp O pacote instala o cwebp (encoder), o dwebp (decoder) e o webpinfo (inspetor de metadados). Os três são do projeto oficial do Google.\nPara quem quer instalar tudo de uma vez e cobrir os dois formatos:\nbrew install libavif webp O ImageMagick (brew install imagemagick) e o ffmpeg (brew install ffmpeg) provavelmente já estão instalados em máquinas de quem trabalha com mídia ou desenvolvimento. Se estiverem, o script já os detecta como fallback sem nenhuma ação adicional. Instalá-los exclusivamente para conversão de imagens seria desproporcional — são pacotes grandes com dezenas de dependências.\nLinux (apt) Para AVIF:\nsudo apt install libavif-bin O pacote instala o avifenc e o avifdec. Em distribuições baseadas em Debian 12 (Bookworm) e Ubuntu 22.04 ou mais recentes, o pacote está disponível nos repositórios padrão. Em versões anteriores, pode não existir ou conter uma versão muito antiga do libavif — nesse caso, o ImageMagick com suporte a AVIF (se disponível) ou o ffmpeg com libaom-av1 assumem via fallback.\nPara WEBP:\nsudo apt install webp O pacote instala o cwebp, o dwebp e ferramentas auxiliares. Está disponível em praticamente qualquer versão do Debian e Ubuntu ainda em suporte — é um pacote estável e presente nos repositórios há anos.\nOs dois juntos:\nsudo apt install libavif-bin webp Uma diferença em relação ao macOS: em servidores Linux, é comum o ImageMagick já estar instalado como dependência de algum framework web (PHP, Rails, Django) mas compilado sem suporte a AVIF. O convert -list format | grep -i avif que o script usa na detecção verifica exatamente isso — a presença do binário não basta, o formato precisa estar na lista de delegates compilados. Se o grep não encontrar AVIF, o ImageMagick é descartado e a detecção segue para o ffmpeg. É por isso que instalar o libavif-bin diretamente é mais confiável do que depender do ImageMagick para AVIF em ambientes de servidor.\nIntegrando com launchd e systemd Os posts anteriores explicaram em detalhe como o WatchPaths e os systemd path units funcionam. Esta seção é apenas referência rápida — o plist e o par .path + .service prontos para copiar, sem repetir a teoria.\nO plist (macOS) Salvar em ~/Library/LaunchAgents/com.janio.image-optimizer.plist:\n\u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34;?\u0026gt; \u0026lt;!DOCTYPE plist PUBLIC \u0026#34;-//Apple//DTD PLIST 1.0//EN\u0026#34; \u0026#34;http://www.apple.com/DTDs/PropertyList-1.0.dtd\u0026#34;\u0026gt; \u0026lt;plist version=\u0026#34;1.0\u0026#34;\u0026gt; \u0026lt;dict\u0026gt; \u0026lt;key\u0026gt;Label\u0026lt;/key\u0026gt; \u0026lt;string\u0026gt;com.janio.image-optimizer\u0026lt;/string\u0026gt; \u0026lt;key\u0026gt;ProgramArguments\u0026lt;/key\u0026gt; \u0026lt;array\u0026gt; \u0026lt;string\u0026gt;/bin/bash\u0026lt;/string\u0026gt; \u0026lt;string\u0026gt;/Users/janio/bin/optimize-images.sh\u0026lt;/string\u0026gt; \u0026lt;/array\u0026gt; \u0026lt;key\u0026gt;WatchPaths\u0026lt;/key\u0026gt; \u0026lt;array\u0026gt; \u0026lt;string\u0026gt;/Users/janio/Pictures/optimize\u0026lt;/string\u0026gt; \u0026lt;/array\u0026gt; \u0026lt;key\u0026gt;StandardOutPath\u0026lt;/key\u0026gt; \u0026lt;string\u0026gt;/Users/janio/.local/log/image-optimizer.log\u0026lt;/string\u0026gt; \u0026lt;key\u0026gt;StandardErrorPath\u0026lt;/key\u0026gt; \u0026lt;string\u0026gt;/Users/janio/.local/log/image-optimizer.log\u0026lt;/string\u0026gt; \u0026lt;key\u0026gt;EnvironmentVariables\u0026lt;/key\u0026gt; \u0026lt;dict\u0026gt; \u0026lt;key\u0026gt;PATH\u0026lt;/key\u0026gt; \u0026lt;string\u0026gt;/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin\u0026lt;/string\u0026gt; \u0026lt;/dict\u0026gt; \u0026lt;/dict\u0026gt; \u0026lt;/plist\u0026gt; Carregar e ativar:\nmkdir -p ~/.local/log launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.janio.image-optimizer.plist Para verificar que está rodando:\nlaunchctl print gui/$(id -u)/com.janio.image-optimizer Para descarregar se precisar editar o plist:\nlaunchctl bootout gui/$(id -u)/com.janio.image-optimizer O /opt/homebrew/bin no PATH é onde o avifenc e o cwebp moram após instalação via Homebrew em Macs com Apple Silicon. Em Macs Intel, o caminho seria /usr/local/bin, que já está na lista. O diretório de log precisa existir antes da primeira execução — o launchd não o cria automaticamente.\nO .path + .service (Linux) Salvar em ~/.config/systemd/user/image-optimizer.path:\n[Path] PathChanged=/home/janio/Pictures/optimize [Install] WantedBy=default.target Salvar em ~/.config/systemd/user/image-optimizer.service:\n[Service] Type=oneshot ExecStart=/home/janio/bin/optimize-images.sh Environment=PATH=/usr/local/bin:/usr/bin:/bin Ativar:\nsystemctl --user daemon-reload systemctl --user enable --now image-optimizer.path Para verificar o estado do monitoramento:\nsystemctl --user status image-optimizer.path Para ver a saída das últimas execuções:\njournalctl --user -u image-optimizer.service -n 30 O daemon-reload é necessário quando os arquivos são criados ou editados — o systemd não detecta units novas automaticamente. O logging vai para o journal sem nenhuma configuração adicional, ao contrário do launchd onde o caminho do arquivo de log precisa ser declarado no plist.\nEm ambos os sistemas, o fluxo a partir daqui é o mesmo: salvar uma imagem PNG ou JPG em ~/Pictures/optimize/, esperar alguns segundos, e verificar que o AVIF ou WEBP apareceu no lugar do original. Se algo não funcionar, o log (arquivo no macOS, journal no Linux) mostra exatamente onde o processo parou.\nTestando antes de automatizar Plugar o script no launchd ou no systemd sem antes confirmar que ele funciona isoladamente é pedir para debugar dois problemas ao mesmo tempo — o script e o gatilho — sem saber qual dos dois está falhando. O teste manual é rápido e elimina uma camada inteira de incerteza.\nPrimeiro, criar o diretório e copiar algumas imagens de teste:\nmkdir -p ~/Pictures/optimize cp alguma-foto.jpg ~/Pictures/optimize/ cp captura-de-tela.png ~/Pictures/optimize/ Rodar o script diretamente:\n~/bin/optimize-images.sh A saída deve mostrar o encoder detectado e o resultado de cada conversão:\n2026-03-26 14:32:01 Encoder: avifenc (format: avif) 2026-03-26 14:32:04 Converted: /Users/janio/Pictures/optimize/alguma-foto.jpg -\u0026gt; /Users/janio/Pictures/optimize/alguma-foto.avif 2026-03-26 14:32:06 Converted: /Users/janio/Pictures/optimize/captura-de-tela.png -\u0026gt; /Users/janio/Pictures/optimize/captura-de-tela.avif Se aparecer Error: no encoder found, a mensagem já diz o que instalar. Se o encoder for detectado mas a conversão falhar, o problema está nos parâmetros ou na imagem de entrada — testar com o encoder diretamente (avifenc input.png output.avif) isola o script da ferramenta.\nComparar os tamanhos confirma que a conversão está produzindo resultados razoáveis:\nls -lh ~/Pictures/optimize/ Um JPG de 3 MB que virou um AVIF de 300 KB está dentro do esperado. Um AVIF maior que o original sugere que o nível de qualidade está alto demais para aquela imagem, ou que o original já estava bastante comprimido. Ajustar o QUALITY no topo do script e rodar de novo é questão de segundos.\nVerificar também o que aconteceu com os originais. Se ORIGINAL_ACTION é delete, os PNGs e JPGs devem ter sumido. Se é move, devem estar em ~/Pictures/optimize/originals/. Se ainda estão no lugar, algo falhou na conversão e o script não tocou nos originais — que é o comportamento correto quando a conversão dá erro.\nPara testar o fallback, desinstalar temporariamente o encoder principal e rodar de novo:\nbrew uninstall libavif # macOS ~/bin/optimize-images.sh brew install libavif # reinstala depois O script deve cair para o ImageMagick ou ffmpeg e continuar funcionando, com a linha de log mostrando o encoder substituto. Se nenhum encoder estiver instalado, a mensagem de erro com a sugestão de instalação aparece e o script encerra sem tocar em nenhum arquivo.\nSó depois de confirmar que o script funciona em todos esses cenários — conversão normal, fallback, nenhum encoder, diretório vazio, ORIGINAL_ACTION em ambos os modos — vale a pena ativar o gatilho no sistema operacional. A partir daí, o único teste que resta é salvar uma imagem no diretório monitorado e verificar o log para confirmar que o launchd ou o systemd disparou o script corretamente. Se o script já foi validado isoladamente, qualquer problema nessa etapa é do gatilho, não do script — e o diagnóstico fica trivial.\n","date":"26/03/2026","lang":"pt","tags":["segurança","automatização","conversão-de-imagens","fallback","webp","avif"],"title":"Convertendo imagens automaticamente para WEBP e AVIF","url":"https://devops.sarmento.org/posts/convertendo-imagens-automaticamente-para-webp-e-avif/"},{"categories":["Linux"],"content":"No post anterior, o launchd do macOS monitorava arquivos e diretórios com WatchPaths para disparar scripts automaticamente quando algo mudava. O modelo é reativo — em vez de rodar um backup a cada hora ou uma conversão a cada cinco minutos, o sistema observa o caminho no disco e só executa o job quando detecta uma modificação real. Sem polling, sem desperdício, sem janela de vulnerabilidade entre a mudança e a ação.\nO Linux tem a mesma capacidade, mas implementada de forma diferente e com mais opções. O systemd oferece path units — arquivos .path que monitoram caminhos no sistema de arquivos e ativam automaticamente um service associado quando a condição é satisfeita. É o equivalente direto do WatchPaths do launchd, com a mesma filosofia declarativa: você descreve o que vigiar num arquivo de configuração, o sistema cuida do resto. Para quem trabalha em servidores ou desktops com systemd, que a essa altura é praticamente qualquer distribuição mainstream, os path units são a ferramenta certa.\nMas nem todo mundo tem systemd à disposição. Containers mínimos, distribuições como Alpine ou Void Linux, ambientes compartilhados onde o usuário não tem controle sobre os serviços do sistema, máquinas legadas — existem cenários legítimos onde o systemd não está disponível ou onde o usuário simplesmente não pode criar units. Para esses casos, o inotifywait do pacote inotify-tools resolve o problema diretamente no shell, usando a mesma infraestrutura de notificação do kernel (inotify) que o systemd usa por baixo, mas sem precisar de daemon, de root, nem de arquivo de configuração.\nEste post aplica os mesmos dois cenários do post anterior — backup reativo de um banco SQLite e otimização automática de imagens — em ambiente Linux, primeiro com systemd path units e depois com inotifywait. O script de conversão de imagens já está publicado; o foco aqui é na mecânica dos gatilhos e na construção dos arquivos de configuração.\nsystemd path units: a versão Linux do WatchPaths Quem leu o post sobre systemd timers já conhece o padrão: o systemd divide responsabilidades entre units de tipos diferentes, e cada tipo cuida de um aspecto do job. Um timer (.timer) define quando um service (.service) deve rodar. Um path (.path) faz a mesma coisa, mas o gatilho não é o relógio — é o sistema de arquivos. O .path observa, o .service executa. São dois arquivos que trabalham juntos, da mesma forma que o timer e o service trabalham juntos para agendamento por horário.\nA separação pode parecer burocrática comparada com o launchd, onde um único plist contém tanto o gatilho (WatchPaths) quanto o comando a executar (ProgramArguments). Na prática, a divisão traz a mesma vantagem que já apareceu nos timers: o service pode ser testado independentemente do path. Você roda systemctl start meu-backup.service para verificar que o script funciona, e só depois ativa o meu-backup.path para colocar o monitoramento em produção. Se o service falhar, o problema está no script, não no gatilho. Se o path não disparar, o problema está no monitoramento, não no script. Cada peça pode ser diagnosticada isoladamente, o que em sistemas que rodam sem supervisão direta — servidores headless, VPSs, containers — faz diferença real quando algo quebra às três da manhã.\nA detecção de mudanças no Linux usa o subsistema inotify do kernel, que é o equivalente do kqueue no macOS. O inotify existe desde o kernel 2.6.13 (2005, coincidentemente o mesmo ano em que o launchd apareceu no macOS) e é a infraestrutura padrão para notificação de eventos do sistema de arquivos no Linux. Ferramentas como tail -f, o hot-reload do Webpack, o watchdog do Python e o próprio systemd usam inotify por baixo. Não há polling envolvido — o kernel notifica o processo observador quando o evento acontece, com latência negligível e consumo de recursos próximo de zero enquanto nada muda.\nComo funciona um .path + .service PathChanged, PathModified e PathExists O launchd tem uma única chave — WatchPaths — que dispara em qualquer modificação. O systemd é mais granular e oferece três diretivas diferentes para monitoramento de caminhos, cada uma com semântica própria.\nPathModified dispara quando o conteúdo do arquivo é alterado — ou seja, quando uma escrita efetivamente muda dados no arquivo. É o mais próximo do WatchPaths do launchd para monitoramento de arquivos individuais. Se o que importa é saber que o banco SQLite recebeu novas transações ou que um arquivo de configuração foi editado, PathModified é a diretiva certa.\nPathChanged dispara quando o arquivo é fechado após ter sido modificado. A diferença em relação ao PathModified é temporal: enquanto PathModified pode disparar durante a escrita (a cada flush do buffer, por exemplo), PathChanged espera o arquivo ser fechado para disparar. Para scripts de backup, essa diferença importa — disparar o backup enquanto o arquivo ainda está sendo escrito pode resultar numa cópia inconsistente. Na prática, PathChanged é a escolha mais segura para a maioria dos casos porque garante que a escrita terminou antes de ativar o service.\nPathExists dispara quando o caminho especificado passa a existir. Não monitora modificações — apenas a criação. Se o arquivo já existir quando o path unit for ativado, o service dispara imediatamente. É útil para cenários de tipo semáforo: um processo cria um arquivo sinalizador quando termina seu trabalho, e o path unit detecta a presença desse arquivo para iniciar a próxima etapa. Para os cenários deste post, PathExists não é a ferramenta certa.\nExiste ainda o PathExistsGlob, que funciona como PathExists mas aceita padrões glob — *.csv, backup-*.sql, etc. Dispara quando qualquer arquivo que corresponda ao padrão passa a existir no diretório. Parece tentador para o cenário de otimização de imagens (monitorar *.png e *.jpg), mas na prática tem a mesma limitação do PathExists: só detecta criação, não modificação subsequente. Se o arquivo for criado vazio e preenchido depois — como alguns editores fazem — o service pode disparar antes de o conteúdo estar pronto.\nAs três diretivas podem monitorar tanto arquivos quanto diretórios. Quando o caminho é um diretório, PathModified e PathChanged disparam quando a estrutura do diretório muda — criação, remoção ou renomeação de arquivos dentro dele. O comportamento é análogo ao do WatchPaths do launchd com diretórios, incluindo a mesma limitação: o monitoramento não é recursivo. Subpastas não são observadas automaticamente; cada caminho relevante precisa de sua própria diretiva.\nMúltiplas diretivas podem coexistir no mesmo .path. Um único path unit pode ter um PathChanged para um arquivo e um PathModified para outro, e o service associado dispara quando qualquer uma das condições for satisfeita. Essa flexibilidade não existe no launchd, onde cada plist tem um único WatchPaths — embora o array do WatchPaths aceite múltiplos caminhos, o tipo de evento monitorado é sempre o mesmo para todos eles.\nA relação entre o .path e o .service O vínculo entre o .path e o .service é por convenção de nome: um path unit chamado meu-backup.path ativa automaticamente o service meu-backup.service, sem precisar de nenhuma diretiva explícita para conectar os dois. Se por alguma razão o service tiver um nome diferente, a diretiva Unit= na seção [Path] permite especificar qual service ativar, mas na prática manter o mesmo nome é mais simples e mais legível.\nQuando o path unit detecta uma mudança, ele ativa o service associado — que roda, executa o script, e termina. O path unit continua observando. Na próxima mudança, o service é ativado de novo. O ciclo se repete indefinidamente enquanto o path unit estiver habilitado. É exatamente o mesmo modelo do launchd: o gatilho observa, o script executa e termina, o gatilho continua observando.\nUm detalhe que difere do launchd: se o service ainda estiver rodando quando uma nova mudança for detectada, o systemd não enfileira uma segunda execução. A mudança é registrada, mas o service não é ativado novamente até que a execução atual termine. Na prática, isso significa que se o script de backup demorar dois minutos e o banco for modificado três vezes durante esse período, o service roda uma vez quando terminar a execução atual — não três vezes. Para scripts que varrem o diretório inteiro a cada execução (como o otimizador de imagens), esse comportamento é desejável. Para scripts que processam um único evento por execução, significa que modificações intermediárias podem ser \u0026ldquo;agrupadas\u0026rdquo; numa única ativação.\nA forma mínima dos dois arquivos juntos é a seguinte:\n~/.config/systemd/user/meu-backup.path:\n[Path] PathChanged=/home/janio/dados/arquivo.db [Install] WantedBy=default.target ~/.config/systemd/user/meu-backup.service:\n[Service] ExecStart=/home/janio/bin/meu-script.sh Dois arquivos, quatro linhas de configuração no total (sem contar a seção [Install]). O .path diz o que vigiar; o .service diz o que rodar. A ativação é feita com dois comandos:\nsystemctl --user enable --now meu-backup.path O --user é o detalhe que faz tudo funcionar sem root, e merece uma seção própria.\nUser units: sem root, sem problema Todas as units mostradas neste post vivem em ~/.config/systemd/user/ e são gerenciadas com systemctl --user. Não precisam de root para criar, não precisam de root para ativar, e rodam com as permissões do usuário logado. É o equivalente direto dos LaunchAgents em ~/Library/LaunchAgents/ no macOS — automação pessoal, sem escalar privilégios, sem tocar na configuração do sistema.\nAs user units têm, por padrão, uma limitação importante: elas só rodam enquanto o usuário tem uma sessão ativa. Se o usuário fizer logout, as units param. Em desktops com login gráfico permanente isso raramente é um problema, mas em servidores acessados por SSH faz diferença. A solução é o loginctl enable-linger, que permite que as units do usuário continuem rodando mesmo sem sessão ativa. No macOS, esse problema não existe — LaunchAgents pessoais permanecem ativos enquanto o usuário estiver logado na sessão gráfica, e o conceito de \u0026ldquo;logout\u0026rdquo; num Mac pessoal é raro o suficiente para não ser uma preocupação prática.\nPara os cenários deste post, user units são a escolha certa. O banco SQLite é um arquivo do usuário, as imagens são do usuário, os scripts rodam no contexto do usuário, e o backup vai para um remote configurado no rclone do usuário. Nada aqui precisa de acesso privilegiado, e rodar como root seria um exagero desnecessário — além de um risco, já que qualquer bug no script teria permissão para danificar o sistema inteiro em vez de apenas os arquivos do usuário.\nCenário 1: backup reativo de um banco SQLite O .path ~/.config/systemd/user/fuqu-backup.path:\n[Path] PathChanged=/home/janio/.local/share/fuqu/fuqu.db [Install] WantedBy=default.target Uma diretiva, um caminho. O PathChanged em vez de PathModified é deliberado: como o SQLite no modo WAL faz múltiplas escritas em sequência (o WAL cresce, o checkpoint consolida), o PathModified poderia disparar o service várias vezes durante uma única operação de escrita. O PathChanged espera o arquivo ser fechado, o que na prática significa que o service só é ativado depois que a transação terminou e o banco está num estado consistente.\nNo post anterior sobre launchd, o WatchPaths apontava para o arquivo fuqu.db em vez do diretório, porque monitorar o diretório poderia não capturar escritas feitas diretamente no arquivo pelo SQLite. O mesmo raciocínio se aplica aqui: o PathChanged aponta para o banco em si, não para ~/.local/share/fuqu/.\nO .service ~/.config/systemd/user/fuqu-backup.service:\n[Service] Type=oneshot ExecStart=/home/janio/bin/fuqu-backup.sh Environment=PATH=/usr/local/bin:/usr/bin:/bin O Type=oneshot diz ao systemd que o service executa uma tarefa e termina — não é um daemon que fica rodando. Sem essa diretiva, o systemd assume Type=simple e espera que o processo permaneça ativo; quando o script terminar, o systemd interpretaria a saída como uma falha. O oneshot é o tipo correto para scripts de backup, conversões, sincronizações e qualquer job que roda, faz seu trabalho e sai.\nO Environment=PATH=... resolve o mesmo problema que o EnvironmentVariables no plist do launchd: o systemd não herda o PATH do shell do usuário. Se o rclone foi instalado em /usr/local/bin ou via snap em /snap/bin, o caminho precisa estar explícito. Uma alternativa é usar o caminho absoluto do rclone diretamente no script, mas definir o PATH no service mantém o script portável entre máquinas onde o binário pode estar em locais diferentes.\nO logging, diferente do launchd onde precisávamos declarar StandardOutPath e StandardErrorPath no plist, vem de graça. O systemd captura stdout e stderr automaticamente e direciona para o journal. Para ver a saída do último backup:\njournalctl --user -u fuqu-backup.service -n 50 Para acompanhar em tempo real enquanto testa:\njournalctl --user -u fuqu-backup.service -f Sem arquivo de log para rotacionar, sem disco enchendo silenciosamente com meses de saída acumulada. O journal cuida da retenção e do espaço automaticamente — uma das vantagens concretas do systemd sobre o modelo de log manual do launchd.\nA ativação dos dois arquivos é um único comando:\nsystemctl --user enable --now fuqu-backup.path O enable registra o path unit para iniciar automaticamente no login. O --now ativa imediatamente sem esperar o próximo login. A partir desse momento, qualquer modificação no fuqu.db dispara o fuqu-backup.service. Para verificar que o monitoramento está ativo:\nsystemctl --user status fuqu-backup.path Throttle: por que o script ainda precisa se proteger O systemd não tem um throttle automático equivalente ao intervalo de 10 segundos do launchd. Se o banco for modificado, o service roda. Se o banco for modificado de novo um segundo depois e o service já tiver terminado, ele roda de novo. Não há intervalo mínimo forçado entre execuções.\nIsso torna o throttle no script ainda mais necessário do que no macOS. O mecanismo é o mesmo descrito no post anterior: o script grava um timestamp num arquivo de controle após cada backup, e na próxima execução verifica se o intervalo mínimo já passou antes de fazer qualquer trabalho. Se não passou, encerra com código 0 e o systemd registra a execução como bem-sucedida.\nO systemd tem uma diretiva RateLimitIntervalSec combinada com RateLimitBurst na seção [Unit] que poderia, em tese, limitar a frequência de ativação. Mas essas diretivas controlam o rate limit do próprio systemd para ativações do service — quando o limite é atingido, o systemd desabilita o path unit temporariamente, o que significa que modificações durante esse período são silenciosamente ignoradas. Não é um throttle com garantia de execução eventual; é um circuit breaker que descarta eventos. Para um backup onde nenhuma modificação deve ser perdida, o throttle no script é a abordagem correta: a execução acontece, verifica que é cedo demais, e sai limpa — mas o path unit continua ativo e pronto para disparar na próxima mudança.\nCenário 2: otimização automática de imagens O .path ~/.config/systemd/user/image-optimizer.path:\n[Path] PathChanged=/home/janio/Pictures/optimize [Install] WantedBy=default.target Aqui o PathChanged aponta para um diretório, não para um arquivo. O comportamento é o esperado: o systemd dispara o service quando a estrutura do diretório muda — um arquivo criado, removido ou renomeado. Salvar um PNG em ~/Pictures/optimize/ é uma criação de arquivo, que altera o diretório, que dispara o path unit.\nA tentação de usar PathExistsGlob com padrões como *.png e *.jpg aparece naturalmente aqui, mas não resolve o problema. O PathExistsGlob dispara quando um arquivo correspondente ao padrão passa a existir, e dispara uma única vez — depois que o service roda, o path unit não reage a novos arquivos que correspondam ao mesmo glob até ser reiniciado. É uma diretiva pensada para cenários de semáforo (\u0026ldquo;espere até que este arquivo apareça\u0026rdquo;), não para monitoramento contínuo. O PathChanged no diretório é a escolha correta para um fluxo onde imagens podem chegar a qualquer momento e em qualquer quantidade.\nO .service ~/.config/systemd/user/image-optimizer.service:\n[Service] Type=oneshot ExecStart=/home/janio/bin/optimize-images.sh Environment=PATH=/usr/local/bin:/usr/bin:/bin A estrutura é idêntica ao service de backup. O Type=oneshot porque o script processa as imagens e termina. O PATH inclui /usr/local/bin onde o cwebp e o avifenc podem estar — no Linux, diferente do macOS com Homebrew em /opt/homebrew/bin, esses pacotes tipicamente vêm do gerenciador de pacotes da distribuição e instalam em /usr/bin ou /usr/local/bin. Em distribuições baseadas em Debian, os pacotes são webp e libavif-bin:\nsudo apt install webp libavif-bin O logging segue o mesmo modelo: stdout e stderr vão para o journal automaticamente. Para verificar o resultado de uma conversão:\njournalctl --user -u image-optimizer.service -n 20 Ativação:\nsystemctl --user enable --now image-optimizer.path A partir desse momento, qualquer PNG ou JPG salvo em ~/Pictures/optimize/ dispara o script de conversão.\nO mesmo cuidado com loops O problema de loops descrito no post sobre launchd se aplica igualmente aqui, com a mesma causa e a mesma solução. Se o script converte foto.png para foto.avif dentro do mesmo diretório monitorado, a criação do .avif é uma modificação no diretório, que dispara o path unit, que executa o script de novo. O script precisa filtrar por extensão — processar apenas .png, .jpg e .jpeg, ignorar todo o resto — ou a separação em dois diretórios (entrada e saída) elimina o problema na raiz.\nNo systemd, existe um agravante que o launchd não tem: como não há throttle automático de 10 segundos entre execuções, um loop causado por falta de filtro pode ser mais agressivo. O script cria o .avif, o path unit dispara imediatamente, o script roda de novo, não encontra nada para processar (se o filtro estiver correto) e sai. Mas se o filtro não estiver correto e o script tentar reprocessar o .avif, a sequência se repete sem nenhum freio externo até que o RateLimitBurst padrão do systemd intervenha e desabilite o path unit — o que resolve o loop mas também mata o monitoramento legítimo.\nA proteção correta é dupla: o filtro por extensão no script garante que a execução termine sem efeitos colaterais quando não há trabalho real a fazer, e um exit 0 rápido no início do script quando o find não retorna arquivos elegíveis evita que o systemd sequer registre atividade significativa no journal. O path unit dispara, o script olha o diretório, não encontra PNGs nem JPGs, e encerra em milissegundos. O custo de uma execução vazia é negligível; o custo de um loop sem proteção pode ser um path unit desabilitado e imagens que param de ser convertidas sem aviso.\nPara quem não tem systemd: inotifywait O que é e de onde vem O systemd path units e o WatchPaths do launchd são abstrações declarativas sobre mecanismos do kernel — inotify no Linux, kqueue no macOS. Eles escondem a complexidade atrás de arquivos de configuração e gerenciam o ciclo de vida do processo automaticamente. Mas a abstração exige o sistema de init correspondente. Em máquinas sem systemd — containers Docker mínimos, Alpine Linux, distribuições que usam OpenRC ou runit, servidores compartilhados onde o usuário não controla os serviços — os path units não existem.\nO inotifywait é a forma de acessar o inotify do kernel diretamente a partir do shell, sem intermediários. Faz parte do pacote inotify-tools, disponível nos repositórios de praticamente toda distribuição Linux. Em Debian e Ubuntu:\nsudo apt install inotify-tools Em Alpine:\napk add inotify-tools O inotifywait bloqueia até que um evento ocorra no caminho monitorado e então imprime o evento e sai — ou, com a flag -m (monitor), continua rodando e imprimindo eventos indefinidamente. Não é um daemon, não precisa de arquivo de configuração, não precisa de root. É um comando que observa e reporta, e a lógica de reação fica por conta de quem consome sua saída — tipicamente um while read num script bash.\nA granularidade dos eventos é maior do que qualquer coisa disponível no launchd ou nos systemd path units. O inotify distingue entre create, modify, close_write, delete, moved_to, moved_from, attrib e mais uma dezena de tipos de evento. O inotifywait permite filtrar por qualquer combinação deles com a flag -e. Onde o PathChanged do systemd agrupa vários eventos numa semântica de \u0026ldquo;o arquivo foi fechado após modificação\u0026rdquo;, o inotifywait permite escolher exatamente quais eventos importam — e ignorar o resto.\nCenário 1 com inotifywait Para o backup reativo do banco SQLite, o inotifywait monitora o arquivo fuqu.db e dispara o script de backup quando o arquivo é modificado e fechado:\ninotifywait -m -e close_write /home/janio/.local/share/fuqu/fuqu.db | while read dir event file; do /home/janio/bin/fuqu-backup.sh done O evento close_write é a escolha certa aqui — dispara quando o arquivo é fechado após ter sido aberto para escrita. É o equivalente funcional do PathChanged do systemd: garante que a transação do SQLite terminou antes de iniciar o backup. Usar modify em vez de close_write dispararia o script a cada flush do buffer, potencialmente múltiplas vezes durante uma única operação de escrita.\nO comando bloqueia no terminal. Para rodar em background de forma persistente, as opções são colocá-lo dentro de um script e executar com nohup, dentro de uma sessão tmux ou screen, ou — ironicamente — dentro de um service do systemd ou de um supervisor como o runit ou o s6. Sem um supervisor, se o processo morrer (OOM, kill acidental, crash do terminal), o monitoramento para e ninguém avisa. É a desvantagem fundamental do inotifywait comparado com o launchd e o systemd: ele faz o monitoramento, mas não cuida de si mesmo.\nO throttle funciona da mesma forma que nos cenários anteriores — o script de backup verifica o timestamp do último backup antes de executar. A diferença é que com inotifywait o throttle é ainda mais importante, porque não há nenhum mecanismo externo limitando a frequência de chamadas. Cada close_write no banco dispara uma invocação do script, e em uso ativo do FUQU isso pode significar dezenas de invocações por minuto. Sem o throttle no script, cada uma delas tentaria conectar ao Backblaze B2.\nCenário 2 com inotifywait Para a otimização de imagens, o inotifywait monitora o diretório e reage à criação de novos arquivos:\ninotifywait -m -e close_write --include \u0026#39;\\.(png|jpg|jpeg)$\u0026#39; \\ /home/janio/Pictures/optimize/ | while read dir event file; do /home/janio/bin/optimize-images.sh done O --include com uma expressão regular filtra os eventos antes de chegarem ao while read. Apenas arquivos com extensão .png, .jpg ou .jpeg disparam o script. Um .avif criado pelo próprio script de conversão gera um evento close_write no diretório, mas o inotifywait o ignora porque não corresponde ao filtro. O problema de loop que precisava ser tratado no script tanto no launchd quanto no systemd é resolvido aqui no próprio comando de monitoramento — uma camada antes.\nEssa é uma vantagem concreta do inotifywait sobre as alternativas declarativas: o filtro por padrão de nome acontece na ferramenta de monitoramento, não no script que processa os arquivos. O WatchPaths do launchd não aceita filtros — dispara para qualquer mudança, e o script decide o que fazer. O PathChanged do systemd também não filtra por nome de arquivo. O inotifywait permite ser seletivo antes de o script sequer ser chamado, o que reduz execuções desnecessárias e simplifica a lógica do script.\nO evento close_write em vez de create resolve o problema de arquivos parcialmente escritos que foi discutido no post anterior. O create dispara no instante em que o arquivo aparece no diretório, antes de o conteúdo ser gravado por completo. Um PNG de 10 MB sendo salvo por um navegador dispara create quando o download começa e close_write quando termina. Monitorar close_write garante que o arquivo está completo antes de o script tentar convertê-lo.\nLimitações e quando vale a pena O inotifywait é poderoso como mecanismo de monitoramento, mas frágil como infraestrutura de automação. As limitações são todas relacionadas à ausência de um supervisor:\nO processo precisa estar rodando para monitorar. Se ele morre, o monitoramento para. Não há equivalente do enable do systemd que garante reinício automático no boot ou após um crash. Cabe ao usuário garantir que o inotifywait esteja rodando — via nohup, tmux, crontab com @reboot, ou qualquer outro mecanismo externo de persistência.\nNão há recuperação de eventos perdidos. Se o inotifywait não estava rodando quando o banco SQLite foi modificado, a modificação passa despercebida. O launchd com WatchPaths verifica o estado dos caminhos monitorados quando o agent é carregado e dispara se algo mudou durante o período de inatividade. O systemd faz o mesmo quando o path unit é ativado. O inotifywait não tem essa memória — ele observa a partir do momento em que começa a rodar, e tudo que aconteceu antes não existe.\nNão há logging integrado. A saída vai para stdout, e se ninguém a capturou, desapareceu. Redirecionar para um arquivo de log é trivial (\u0026gt;\u0026gt; /var/log/meu-monitor.log 2\u0026gt;\u0026amp;1), mas a rotação e a retenção ficam por conta do usuário.\nDito isso, o inotifywait vale a pena em contextos específicos: ambientes sem systemd onde instalar um init system alternativo seria desproporcional ao problema, scripts descartáveis que precisam monitorar um diretório por algumas horas durante uma migração ou importação, e situações onde a granularidade dos eventos justifica a complexidade extra — filtrar por tipo de evento e padrão de nome diretamente no comando de monitoramento é algo que nem o launchd nem o systemd oferecem com a mesma precisão. Para automação permanente numa máquina com systemd, os path units são a escolha certa. Para um monitoramento pontual ou num ambiente restrito, o inotifywait faz o trabalho com zero dependências além do kernel.\nlaunchd, systemd e inotifywait: comparação rápida Três posts, três ferramentas, o mesmo objetivo. A tabela abaixo resume as diferenças práticas para quem precisa escolher — ou para quem usa mais de um sistema e quer saber o que muda.\nlaunchd (WatchPaths) systemd (path units) inotifywait Sistema macOS Linux com systemd Qualquer Linux Configuração plist XML .path + .service (INI) Comando no shell Precisa de root Não (LaunchAgent) Não (user unit) Não Mecanismo do kernel kqueue inotify inotify Filtro por tipo de evento Não Parcial (PathChanged vs PathModified) Sim (qualquer evento inotify) Filtro por nome de arquivo Não Não (exceto PathExistsGlob) Sim (\u0026ndash;include / \u0026ndash;exclude) Monitoramento recursivo Não Não Sim (-r) Throttle automático Sim (10s, ajustável para cima) Não (RateLimitBurst desabilita o path) Não Recuperação de eventos perdidos Sim (verifica estado ao carregar) Sim (verifica estado ao ativar) Não Logging Manual (StandardOutPath) Automático (journal) Manual (redirecionamento de stdout) Supervisão do processo Automática (launchd reinicia) Automática (systemd reinicia) Nenhuma (precisa de supervisor externo) Teste independente launchctl start systemctl start .service Executar o script diretamente A coluna do inotifywait é a que mais se destaca nos extremos: maior granularidade de eventos e filtros, mas nenhuma supervisão nem recuperação. É a ferramenta mais poderosa como mecanismo de observação e a mais frágil como infraestrutura de automação. O launchd e o systemd convergem na maioria dos aspectos práticos, com diferenças pontuais — o throttle automático do launchd, o logging integrado do systemd, a granularidade de eventos do systemd — que refletem as filosofias diferentes dos dois sistemas.\nPara os dois cenários deste post e do anterior — backup reativo e otimização de imagens — qualquer uma das três ferramentas resolve o problema. A escolha é ditada pelo sistema operacional e pelo nível de acesso disponível, não pela capacidade técnica da ferramenta. No Mac, launchd. No Linux com systemd, path units. No Linux sem systemd ou sem controle sobre os serviços, inotifywait.\nO que fica para os próximos posts Este post e o anterior cobriram o mesmo território — monitoramento reativo de arquivos e diretórios — nos dois sistemas operacionais que um sysadmin provavelmente usa no dia a dia. Os plists do launchd, os path units do systemd e o inotifywait são três formas de dizer a mesma coisa: \u0026ldquo;quando isso mudar, rode aquilo\u0026rdquo;. A mecânica dos gatilhos está resolvida.\nO que falta são os scripts que os gatilhos disparam. O de backup do SQLite precisa lidar com WAL checkpoint, staging directory, rclone configurado com um remote no Backblaze B2, e o mecanismo de throttle que apareceu como conceito nos dois posts mas ainda não virou código. O script de conversão de imagens já está pronto — com fallback entre encoders, proteção contra arquivos incompletos, e configuração de formato de saída e destino dos originais.\nSão problemas diferentes do monitoramento — menos sobre configuração de sistema e mais sobre lógica de aplicação — e merecem espaço próprio para serem tratados com o detalhe que precisam. Os próximos posts vão entregar o código completo de cada um.\n","date":"26/03/2026","lang":"pt","tags":["segurança","devops","self-hosted","systemd","backup","automatização","linux"],"title":"Monitorando arquivos e pastas no Linux com systemd path units (e inotifywait para quem não tem root)","url":"https://devops.sarmento.org/posts/monitorando-arquivos-e-pastas-no-linux-com-systemd-path-units-e-inotifywait-para-quem-nao-tem-root/"},{"categories":["macOS"],"content":"No post anterior sobre launchd, o agendamento funcionava por horário: o StartCalendarInterval definia \u0026ldquo;todo dia às 7h\u0026rdquo; e o sistema cuidava do resto, incluindo recuperar execuções perdidas quando o Mac estava dormindo. Para tarefas periódicas como enviar um briefing diário ou rodar um script de manutenção, esse modelo resolve perfeitamente — é o equivalente funcional do cron, mas integrado ao ciclo de vida do macOS.\nSó que nem toda automação faz sentido atrelada a um relógio. Tem tarefas que só precisam acontecer quando alguma coisa muda. Um backup que roda de hora em hora está desperdiçando 23 execuções por dia se o banco de dados só foi alterado uma vez. Uma conversão de imagens que roda a cada 5 minutos não tem o que converter na grande maioria das vezes, e quando finalmente tem, já se passaram até 5 minutos desde que o arquivo apareceu. O modelo baseado em tempo funciona, mas é um polling disfarçado de agendamento — e polling é quase sempre a solução menos elegante para qualquer problema de sincronização.\nO launchd oferece uma alternativa que funciona de forma fundamentalmente diferente: em vez de perguntar \u0026ldquo;que horas são?\u0026rdquo;, ele pergunta \u0026ldquo;algo mudou?\u0026rdquo;. A chave WatchPaths aceita uma lista de caminhos — arquivos ou diretórios — e dispara o job quando qualquer um deles é modificado. Não é um intervalo. Não é um cron disfarçado. É um gatilho reativo, baseado em eventos do sistema de arquivos, que transforma o launchd numa espécie de inotifywait do Linux, mas declarativo e integrado ao sistema operacional.\nEste post explora dois cenários práticos para essa capacidade. O primeiro é um backup automático de um banco SQLite que sincroniza com um servidor remoto sempre que o banco é modificado, com um mecanismo de throttle para não martelar o destino a cada salvamento. O segundo é um conversor de imagens que vigia uma pasta e transforma PNGs e JPGs em formatos otimizados assim que eles aparecem. Os dois scripts ficam para posts dedicados — o script de conversão de imagens já está publicado. Aqui o foco é na mecânica do launchd e na construção dos plists.\nDe horários para eventos: o outro lado do launchd O StartCalendarInterval que apareceu no post anterior e o WatchPaths que vamos usar agora são mutuamente exclusivos no mesmo plist. Cada LaunchAgent tem um único gatilho de ativação: ou ele dispara por horário, ou ele dispara por mudança no sistema de arquivos, ou ele dispara por outra condição como StartInterval (um timer simples em segundos) ou KeepAlive (que mantém o processo rodando continuamente e o reinicia se morrer). Misturar StartCalendarInterval com WatchPaths no mesmo plist não gera erro de sintaxe, mas o comportamento resultante é indefinido e a documentação da Apple não garante qual dos dois terá precedência.\nA restrição faz sentido quando você pensa no modelo mental do launchd: cada plist descreve um job, e cada job tem uma razão para existir — uma condição que o traz à vida. Se você precisa de um script que rode às 7h da manhã e quando um arquivo mudar, a solução é criar dois plists apontando para o mesmo script (ou para scripts diferentes que compartilham a mesma lógica). Parece redundante comparado com um timer do systemd que aceita múltiplos OnCalendar e PathChanged na mesma unit, mas na prática a separação mantém os jobs simples e facilita o debug — cada plist faz uma coisa, e quando algo não funciona, o problema está isolado num único arquivo.\nWatchPaths: como funciona A sintaxe do WatchPaths é um array de strings no plist, onde cada string é o caminho absoluto de um arquivo ou diretório. Quando o launchd detecta uma modificação em qualquer um dos caminhos listados, ele executa o job. A detecção usa o kqueue do kernel — o mesmo mecanismo que o fswatch e o FSEvents usam por baixo dos panos — então não há polling envolvido. O overhead é zero enquanto nada muda, e a reação é praticamente instantânea quando algo muda.\nAqui está a forma mínima de um plist com WatchPaths:\n\u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34;?\u0026gt; \u0026lt;!DOCTYPE plist PUBLIC \u0026#34;-//Apple//DTD PLIST 1.0//EN\u0026#34; \u0026#34;http://www.apple.com/DTDs/PropertyList-1.0.dtd\u0026#34;\u0026gt; \u0026lt;plist version=\u0026#34;1.0\u0026#34;\u0026gt; \u0026lt;dict\u0026gt; \u0026lt;key\u0026gt;Label\u0026lt;/key\u0026gt; \u0026lt;string\u0026gt;com.exemplo.watchpaths\u0026lt;/string\u0026gt; \u0026lt;key\u0026gt;ProgramArguments\u0026lt;/key\u0026gt; \u0026lt;array\u0026gt; \u0026lt;string\u0026gt;/bin/bash\u0026lt;/string\u0026gt; \u0026lt;string\u0026gt;/Users/janio/bin/meu-script.sh\u0026lt;/string\u0026gt; \u0026lt;/array\u0026gt; \u0026lt;key\u0026gt;WatchPaths\u0026lt;/key\u0026gt; \u0026lt;array\u0026gt; \u0026lt;string\u0026gt;/Users/janio/dados/arquivo.db\u0026lt;/string\u0026gt; \u0026lt;/array\u0026gt; \u0026lt;/dict\u0026gt; \u0026lt;/plist\u0026gt; Toda vez que /Users/janio/dados/arquivo.db for modificado — seja por uma escrita direta, por um mv que substitui o arquivo, ou por qualquer operação que altere seus metadados — o launchd executa /Users/janio/bin/meu-script.sh. Não precisa de loop, não precisa de while true, não precisa de processo residente consumindo memória. O script roda, faz o que precisa fazer, e termina. Na próxima modificação, o launchd o executa de novo.\nUm ponto que não é óbvio na documentação: quando o caminho monitorado é um diretório, o launchd dispara quando qualquer arquivo dentro dele é criado, removido ou renomeado, mas não necessariamente quando o conteúdo de um arquivo existente é modificado sem alterar a estrutura do diretório. O comportamento exato depende de como o aplicativo que grava o arquivo implementa a escrita — alguns editores criam um arquivo temporário e fazem um rename atômico (o que altera o diretório), enquanto outros escrevem diretamente no arquivo existente (o que pode não disparar o watch no diretório). Para monitorar o conteúdo de um arquivo específico, a prática mais confiável é apontar o WatchPaths diretamente para o arquivo, não para o diretório que o contém.\nTambém vale saber que o WatchPaths não é recursivo. Monitorar /Users/janio/dados/ detecta mudanças no nível imediato desse diretório, mas não em subpastas. Se a estrutura de diretórios importa, cada caminho relevante precisa estar listado explicitamente no array. É uma limitação quando comparado com o inotifywait --recursive do Linux, mas na prática a maioria dos casos de uso envolve vigiar um ou dois caminhos específicos, não uma árvore inteira.\nCenário 1: backup reativo de um banco SQLite O problema Bancos SQLite são arquivos. Essa é simultaneamente a maior qualidade e o maior risco do SQLite: não há servidor intermediário, não há daemon gerenciando conexões, não há replicação embutida. O banco é um arquivo .db no disco, e se esse arquivo se corromper ou o disco falhar, os dados vão junto. Para aplicações pessoais que guardam dados que importam — um gerenciador de tarefas, um leitor de RSS, um inventário doméstico — a ausência de backup automático é uma bomba-relógio silenciosa que só detona no pior momento possível.\nA abordagem tradicional seria agendar um backup periódico com StartCalendarInterval — digamos, a cada hora. Funciona, mas tem dois problemas. Primeiro, a maioria das execuções vai copiar um banco que não mudou desde o último backup, desperdiçando tempo e banda com o destino remoto. Segundo, se você fizer uma série de alterações importantes e o laptop morrer antes da próxima hora cheia, as mudanças se perdem. O intervalo fixo cria uma janela de vulnerabilidade proporcional à sua frequência, e diminuir a frequência para um minuto transforma o backup num polling agressivo que consome recursos sem necessidade.\nCom WatchPaths, o modelo muda: o backup só roda quando o banco é efetivamente modificado. Sem alteração, nenhum processo é executado. Com alteração, o script dispara em segundos. A janela de vulnerabilidade cai de \u0026ldquo;até uma hora\u0026rdquo; para \u0026ldquo;até alguns segundos\u0026rdquo;, e o custo em recursos cai para zero nos períodos de inatividade.\nA lógica do script O script de backup faz quatro coisas, nessa ordem: copiar os arquivos relevantes para um diretório de staging local, forçar um WAL checkpoint no banco SQLite para garantir consistência, sincronizar o staging com o destino remoto via rclone, e registrar o horário da execução para controle de throttle.\nO diretório de staging existe para evitar que o rclone leia o banco diretamente enquanto ele está sendo escrito. SQLite no modo WAL (Write-Ahead Logging) mantém um arquivo -wal ao lado do banco principal com transações ainda não consolidadas. Copiar o .db sem antes rodar um PRAGMA wal_checkpoint(TRUNCATE) pode resultar num backup inconsistente — o arquivo principal sem as últimas transações e o WAL ausente ou truncado. O checkpoint força a consolidação do WAL no banco principal antes da cópia, e o staging garante que o rclone trabalhe com um snapshot estático, não com um arquivo que pode mudar durante o upload.\nO destino remoto neste caso é um bucket no Backblaze B2, mas a lógica é idêntica para qualquer remote que o rclone suporte — S3, Google Drive, SFTP, um NAS local. O rclone sync cuida de transferir apenas os blocos que mudaram, então mesmo um banco de vários megabytes gera tráfego mínimo quando as alterações são pequenas.\nO plist \u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34;?\u0026gt; \u0026lt;!DOCTYPE plist PUBLIC \u0026#34;-//Apple//DTD PLIST 1.0//EN\u0026#34; \u0026#34;http://www.apple.com/DTDs/PropertyList-1.0.dtd\u0026#34;\u0026gt; \u0026lt;plist version=\u0026#34;1.0\u0026#34;\u0026gt; \u0026lt;dict\u0026gt; \u0026lt;key\u0026gt;Label\u0026lt;/key\u0026gt; \u0026lt;string\u0026gt;com.janio.fuqu-backup\u0026lt;/string\u0026gt; \u0026lt;key\u0026gt;ProgramArguments\u0026lt;/key\u0026gt; \u0026lt;array\u0026gt; \u0026lt;string\u0026gt;/bin/bash\u0026lt;/string\u0026gt; \u0026lt;string\u0026gt;/Users/janio/bin/fuqu-backup.sh\u0026lt;/string\u0026gt; \u0026lt;/array\u0026gt; \u0026lt;key\u0026gt;WatchPaths\u0026lt;/key\u0026gt; \u0026lt;array\u0026gt; \u0026lt;string\u0026gt;/Users/janio/.local/share/fuqu/fuqu.db\u0026lt;/string\u0026gt; \u0026lt;/array\u0026gt; \u0026lt;key\u0026gt;StandardOutPath\u0026lt;/key\u0026gt; \u0026lt;string\u0026gt;/Users/janio/.local/share/fuqu/backup.log\u0026lt;/string\u0026gt; \u0026lt;key\u0026gt;StandardErrorPath\u0026lt;/key\u0026gt; \u0026lt;string\u0026gt;/Users/janio/.local/share/fuqu/backup.log\u0026lt;/string\u0026gt; \u0026lt;key\u0026gt;EnvironmentVariables\u0026lt;/key\u0026gt; \u0026lt;dict\u0026gt; \u0026lt;key\u0026gt;PATH\u0026lt;/key\u0026gt; \u0026lt;string\u0026gt;/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin\u0026lt;/string\u0026gt; \u0026lt;/dict\u0026gt; \u0026lt;/dict\u0026gt; \u0026lt;/plist\u0026gt; O WatchPaths aponta diretamente para o arquivo fuqu.db, não para o diretório que o contém. Como mencionado na seção anterior, monitorar o diretório poderia não capturar escritas feitas diretamente no arquivo pelo SQLite — especialmente no modo WAL, onde a escrita inicial vai para o -wal e só depois é consolidada no banco principal. Apontar para o .db garante que o launchd dispare quando o próprio banco for modificado, inclusive após um checkpoint automático do SQLite.\nO PATH no EnvironmentVariables inclui /opt/homebrew/bin porque é onde o Homebrew instala binários em Macs com Apple Silicon, e o rclone vem de lá. Sem essa variável, o script não encontraria o rclone — o mesmo problema de PATH que apareceu no post anterior com o fuqu telegram.\nThrottle: por que não dá para confiar no launchd sozinho Existe um detalhe que a documentação da Apple menciona de passagem mas que tem implicações práticas sérias: o launchd tem um throttle interno de 10 segundos entre execuções de um mesmo job. Se o banco for modificado duas vezes em sequência rápida, a segunda execução é atrasada para respeitar esse intervalo mínimo. O valor pode ser ajustado com a chave ThrottleInterval no plist, mas só para cima — não é possível reduzir abaixo de 10 segundos.\nPara um backup remoto, 10 segundos é até pouco. Uma aplicação que faz muitas escritas pequenas no SQLite — adicionar uma tarefa, marcar outra como concluída, editar uma nota — pode gerar dezenas de modificações no banco em poucos minutos de uso ativo. Disparar um rclone sync para cada uma delas não faz sentido: cada execução tem o overhead de conectar ao servidor remoto, autenticar, comparar checksums e transferir dados. Num bucket B2, cada operação de escrita é uma transação de API que conta para o limite gratuito mensal.\nA solução é implementar o throttle no próprio script, independente do launchd. O mecanismo é simples: o script grava um timestamp em um arquivo de controle após cada backup bem-sucedido. Na próxima execução, antes de fazer qualquer coisa, ele lê esse timestamp e calcula quanto tempo se passou. Se o intervalo for menor que o limite definido — cinco minutos, por exemplo — o script encerra imediatamente com uma mensagem de log e código de saída 0. O launchd vê a execução como bem-sucedida e volta a dormir até a próxima modificação no banco.\nO resultado é um sistema de duas camadas: o launchd garante que o script seja chamado quando o banco mudar, e o script garante que o backup efetivo não aconteça mais do que uma vez a cada cinco minutos. O launchd cuida da reatividade, o script cuida da contenção. Separar as responsabilidades dessa forma mantém o plist simples e a lógica de negócio onde ela pertence — no script, onde pode ser testada, ajustada e versionada independentemente da configuração do sistema operacional.\nCenário 2: otimização automática de imagens O problema Formatos de imagem modernos como WEBP e AVIF oferecem compressão significativamente melhor que PNG e JPG sem perda perceptível de qualidade. Um PNG de 2 MB vira um AVIF de 200 KB com resultado visual indistinguível; um JPG de câmera com 5 MB cai para menos de 1 MB em WEBP. A diferença é grande o suficiente para importar em qualquer contexto onde imagens são servidas — blogs, portfólios, documentação, até anexos de email.\nO problema é que ninguém quer abrir um conversor manualmente toda vez que salva uma imagem. Ferramentas gráficas como o Squoosh existem e funcionam bem para uma ou duas imagens, mas a conversão precisa virar hábito para ter impacto real, e hábitos que dependem de passos manuais morrem na segunda semana. O que funciona é tirar o humano do processo: salvar a imagem num diretório específico, e a conversão acontecer sozinha.\nNo Linux, a solução clássica seria um inotifywait em loop dentro de um script monitorando a pasta, ou um path unit do systemd ativando um service. No macOS, o WatchPaths do launchd faz o mesmo trabalho com menos peças móveis: aponte para o diretório, e o launchd avisa quando algo aparecer.\nA lógica do script O script monitora um diretório — por exemplo, ~/Pictures/optimize/ — e processa qualquer arquivo PNG ou JPG que encontrar nele. Para cada imagem encontrada, ele gera uma versão AVIF (ou WEBP, dependendo da preferência), verifica se a conversão foi bem-sucedida, e remove o original. O diretório funciona como uma inbox: arquivos entram num formato, saem em outro.\nA conversão em si usa ferramentas de linha de comando instaláveis via Homebrew. O cwebp (do pacote webp) converte para WEBP; o avifenc (do pacote libavif) converte para AVIF. Ambos aceitam parâmetros de qualidade que permitem ajustar o balanço entre tamanho e fidelidade visual — um valor de qualidade entre 75 e 85 costuma ser o ponto ideal para imagens fotográficas, produzindo arquivos dramaticamente menores sem artefatos visíveis a olho nu.\nO script precisa tratar um detalhe que parece menor mas causa problemas reais: arquivos que ainda estão sendo escritos. Quando um navegador salva uma imagem grande ou um aplicativo exporta um PNG de alta resolução, o arquivo aparece no diretório antes de estar completo. Se o script disparar nesse momento e tentar converter uma imagem parcialmente escrita, o resultado vai ser um AVIF corrompido e o original deletado. A proteção mais simples é verificar se o arquivo parou de crescer — comparar o tamanho, esperar um ou dois segundos, comparar de novo — antes de iniciar a conversão.\nO plist \u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34;?\u0026gt; \u0026lt;!DOCTYPE plist PUBLIC \u0026#34;-//Apple//DTD PLIST 1.0//EN\u0026#34; \u0026#34;http://www.apple.com/DTDs/PropertyList-1.0.dtd\u0026#34;\u0026gt; \u0026lt;plist version=\u0026#34;1.0\u0026#34;\u0026gt; \u0026lt;dict\u0026gt; \u0026lt;key\u0026gt;Label\u0026lt;/key\u0026gt; \u0026lt;string\u0026gt;com.janio.image-optimizer\u0026lt;/string\u0026gt; \u0026lt;key\u0026gt;ProgramArguments\u0026lt;/key\u0026gt; \u0026lt;array\u0026gt; \u0026lt;string\u0026gt;/bin/bash\u0026lt;/string\u0026gt; \u0026lt;string\u0026gt;/Users/janio/bin/optimize-images.sh\u0026lt;/string\u0026gt; \u0026lt;/array\u0026gt; \u0026lt;key\u0026gt;WatchPaths\u0026lt;/key\u0026gt; \u0026lt;array\u0026gt; \u0026lt;string\u0026gt;/Users/janio/Pictures/optimize\u0026lt;/string\u0026gt; \u0026lt;/array\u0026gt; \u0026lt;key\u0026gt;StandardOutPath\u0026lt;/key\u0026gt; \u0026lt;string\u0026gt;/Users/janio/.local/log/image-optimizer.log\u0026lt;/string\u0026gt; \u0026lt;key\u0026gt;StandardErrorPath\u0026lt;/key\u0026gt; \u0026lt;string\u0026gt;/Users/janio/.local/log/image-optimizer.log\u0026lt;/string\u0026gt; \u0026lt;key\u0026gt;EnvironmentVariables\u0026lt;/key\u0026gt; \u0026lt;dict\u0026gt; \u0026lt;key\u0026gt;PATH\u0026lt;/key\u0026gt; \u0026lt;string\u0026gt;/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin\u0026lt;/string\u0026gt; \u0026lt;/dict\u0026gt; \u0026lt;/dict\u0026gt; \u0026lt;/plist\u0026gt; Diferente do cenário anterior, aqui o WatchPaths aponta para um diretório, não para um arquivo específico. Faz sentido: o objetivo é detectar a chegada de novos arquivos, e a criação de um arquivo é uma modificação no diretório que o contém. O launchd dispara o script quando qualquer coisa muda em ~/Pictures/optimize/, e o script decide o que fazer — processar PNGs e JPGs, ignorar todo o resto.\nO /opt/homebrew/bin no PATH é onde o cwebp e o avifenc moram depois de instalados pelo Homebrew. Sem essa variável, o script não encontraria os conversores.\nCuidados com loops e filtros Existe uma armadilha óbvia nessa configuração que precisa ser tratada no script, não no plist: o próprio script cria arquivos novos no diretório monitorado. Quando ele converte foto.png para foto.avif, o AVIF que aparece em ~/Pictures/optimize/ é uma modificação no diretório — e o launchd dispara o script de novo. Se o script não filtrar os arquivos por extensão antes de processá-los, ele vai tentar converter o AVIF que acabou de criar, falhar ou gerar um resultado absurdo, e potencialmente entrar num loop infinito de execuções.\nA solução mais limpa é o script processar exclusivamente arquivos com extensões .png, .jpg e .jpeg, ignorando qualquer outra coisa. Um find com filtro de extensão no início do script resolve isso em uma linha. Se o find não retornar nada, o script encerra imediatamente — a execução foi disparada por uma mudança que não é da conta dele, como a criação de um .avif ou a remoção de um arquivo já processado.\nUma alternativa mais robusta é usar dois diretórios separados: um de entrada onde as imagens originais são salvas, e um de saída onde os arquivos otimizados são gravados. O WatchPaths monitora apenas o de entrada, e o script nunca escreve nele — só lê e apaga. Essa separação elimina completamente a possibilidade de loop, ao custo de uma pasta extra na estrutura. Qual abordagem usar depende do fluxo de trabalho: se o objetivo é arrastar imagens para uma pasta e encontrá-las otimizadas no mesmo lugar, o filtro por extensão basta. Se o script faz parte de um pipeline maior onde outros processos leem a saída, a separação em dois diretórios é mais segura.\nO throttle do launchd também aparece aqui, mas com implicações diferentes do cenário de backup. Se você arrastar dez imagens para a pasta de uma vez, o launchd dispara o script na primeira mudança e depois aplica o intervalo de 10 segundos antes de permitir uma nova execução. Mas isso não é um problema na prática: o script não processa um único arquivo por execução — ele varre o diretório inteiro com o find e processa tudo que encontrar. As dez imagens que chegaram juntas são todas convertidas na primeira execução. O throttle só afetaria o caso em que novas imagens continuam chegando enquanto a conversão das anteriores ainda está rodando, e mesmo assim a próxima execução pegaria tudo que ficou pendente.\nWatchPaths vs. QueueDirectories O launchd tem uma segunda chave para monitoramento de diretórios que aparece pouco na documentação e menos ainda em tutoriais: QueueDirectories. A sintaxe é idêntica à do WatchPaths — um array de caminhos absolutos — mas a semântica é diferente de uma forma que importa para certos fluxos de trabalho.\nO WatchPaths dispara o job quando detecta qualquer modificação nos caminhos monitorados. Não importa o que mudou nem se o caminho ainda existe depois da mudança — o evento aconteceu, o job roda. Se o script executado apagar o arquivo que disparou o evento, o launchd não se importa. Se o diretório monitorado estiver vazio depois que o script terminar, o launchd não se importa. O trabalho dele é detectar a mudança e executar o comando; o que acontece depois é problema do script.\nO QueueDirectories adiciona uma condição: o job só dispara quando o diretório monitorado não está vazio. E mais — se o diretório ainda não estiver vazio quando o script terminar, o launchd executa o job de novo. O ciclo se repete até que o diretório fique vazio, momento em que o launchd volta a dormir e aguarda a próxima chegada de arquivos. É, literalmente, uma fila: itens entram, o job processa, e só para quando a fila esvazia.\nA diferença parece sutil mas muda a forma como o script precisa ser escrito. Com WatchPaths, o script tipicamente varre o diretório, processa tudo que encontrar e termina. Se algo novo chegar durante a execução, o launchd dispara outra execução depois que a atual terminar (respeitando o throttle). Com QueueDirectories, o script pode se dar ao luxo de processar um único item por execução, porque o launchd vai chamá-lo de novo automaticamente enquanto houver itens pendentes. Esse modelo simplifica o script — nada de loops, nada de find — ao custo de mais invocações do processo.\nPara o cenário de otimização de imagens, QueueDirectories seria uma escolha natural se usarmos a abordagem de dois diretórios. O diretório de entrada funciona como fila: imagens chegam, o script converte uma por vez (ou todas de uma vez, tanto faz), move os resultados para o diretório de saída, e o launchd continua chamando o script até que a entrada esteja vazia. A vantagem sobre WatchPaths nesse caso é que não há risco de perder arquivos que chegaram durante uma conversão demorada — o QueueDirectories garante que o job rode de novo se ainda houver trabalho a fazer.\nPara o cenário de backup do SQLite, QueueDirectories não faz sentido. O banco de dados nunca \u0026ldquo;esvazia\u0026rdquo; — ele é um arquivo que existe permanentemente e muda de conteúdo. A lógica de fila não se aplica a um arquivo que está sempre presente. WatchPaths é a escolha correta quando o que importa é o evento de modificação, não a presença ou ausência de conteúdo num diretório.\nNa prática, WatchPaths é a ferramenta de uso geral e cobre a maioria dos casos. QueueDirectories brilha em pipelines onde o diretório funciona como caixa de entrada descartável — processamento de uploads, conversão de formatos, ingestão de arquivos — e onde a garantia de que nenhum item fique para trás importa mais do que a simplicidade do plist.\nO que fica para os próximos posts Este post mostrou a mecânica do WatchPaths e do QueueDirectories, os plists que fazem tudo funcionar, e as armadilhas que precisam ser tratadas no script — throttle, loops, arquivos parcialmente escritos, a diferença entre monitorar um arquivo e monitorar um diretório. O que ficou deliberadamente de fora foram os scripts em si.\nO script de backup do SQLite envolve decisões que merecem espaço próprio: a ordem correta de operações para garantir consistência do banco, o checkpoint do WAL, a construção do staging directory, e a configuração do rclone com um remote no Backblaze B2. Cada uma dessas peças tem suas pegadinhas, e enfiar tudo numa seção de um post sobre launchd diluiria tanto o launchd quanto o backup.\nO script de otimização de imagens abre um caminho parecido. A escolha entre WEBP e AVIF não é óbvia — AVIF comprime melhor mas codifica mais devagar, e nem todo contexto aceita os dois formatos. Os parâmetros de qualidade do cwebp e do avifenc têm comportamentos diferentes que afetam o resultado final. E a lógica de proteção contra arquivos incompletos, que parece trivial descrita em um parágrafo, tem mais nuance na implementação do que o conceito sugere.\nO script de conversão de imagens já está pronto — código completo com fallback entre encoders, detecção de sistema operacional e proteção contra arquivos incompletos. O launchd é o gatilho; o que ele dispara é onde mora a complexidade real.\nSe você usa Linux, escrevi o equivalente deste post com systemd path units e inotifywait — os mesmos cenários, adaptados para o ecossistema Linux.\n","date":"26/03/2026","lang":"pt","tags":["launchd","watchpaths","backup","automatização","macos","agendamento","devops"],"title":"Monitorando arquivos e pastas com launchd: WatchPaths na prática","url":"https://devops.sarmento.org/posts/monitorando-arquivos-e-pastas-com-launchd-watchpaths-na-pratica/"},{"categories":["Linux"],"content":"Você trabalha remoto, tem um servidor em casa, um Raspberry Pi rodando serviços, ou uma máquina no escritório que precisa acessar de vez em quando. O cenário é comum e a solução óbvia é o SSH — que já está instalado, é seguro e funciona há décadas. O problema é que entre a sua máquina e o resto da internet existe um roteador, um NAT, e possivelmente um provedor que não te dá IP público fixo ou que bloqueia portas de entrada. De repente, o protocolo mais confiável da administração de sistemas se torna inacessível de fora da sua rede local.\nAs soluções tradicionais existem e funcionam: abrir portas no roteador com port forwarding, configurar DDNS para lidar com IP dinâmico, montar uma VPN com WireGuard ou Tailscale, ou alugar um VPS barato para servir de bastion host. Cada uma tem seu lugar, mas todas adicionam infraestrutura, configuração e em alguns casos custo recorrente — tudo isso para resolver o que deveria ser um problema simples: conectar via SSH a uma máquina que está ligada e funcionando, mas que o mundo exterior não consegue alcançar.\nO SSH-J.com oferece uma abordagem diferente: um jump host público e gratuito que funciona como ponte entre você e a sua máquina atrás de NAT. Sem conta, sem cadastro, sem instalação de software adicional. A configuração inteira se resume a dois comandos SSH e um serviço systemd para manter tudo rodando automaticamente. Neste post, mostro como configurar do zero, partindo de uma máquina Debian que acabou de ser instalada até um acesso remoto funcional e persistente.\nO problema: SSH atrás de NAT O NAT — Network Address Translation — é o mecanismo que permite que dezenas de dispositivos em uma rede local compartilhem um único endereço IP público. O roteador mantém uma tabela de tradução que associa conexões internas a portas no IP externo, e tudo funciona de forma transparente para conexões de saída: quando a sua máquina abre uma conexão SSH para um servidor na internet, o roteador sabe para onde encaminhar as respostas porque ele mesmo criou a entrada na tabela.\nO problema aparece na direção contrária. Uma conexão vinda de fora — alguém tentando fazer SSH na sua máquina — chega ao IP público do roteador, que não tem como saber para qual dispositivo interno encaminhá-la. Não existe entrada na tabela de NAT porque ninguém de dentro iniciou essa conexão. O pacote é descartado e, do ponto de vista de quem está tentando conectar, a máquina simplesmente não existe.\nPort forwarding resolve isso de forma direta: você configura o roteador para encaminhar conexões na porta 22 (ou outra) para o IP interno da máquina. Mas nem sempre isso é viável. Em redes corporativas ou de coworking você não tem acesso ao roteador. Muitos provedores residenciais no Brasil usam CGNAT — Carrier-Grade NAT — que adiciona uma camada extra de NAT no lado do provedor, tornando o port forwarding no seu roteador inútil porque o IP \u0026ldquo;público\u0026rdquo; que você vê já é um IP privado compartilhado com outros assinantes. E mesmo quando o port forwarding funciona, expor a porta SSH diretamente para a internet exige atenção redobrada com hardening, fail2ban e monitoramento de tentativas de brute force.\nA ideia por trás do SSH-J.com é inverter a direção da conexão. Em vez de esperar que alguém de fora consiga alcançar a sua máquina, a própria máquina abre uma conexão de saída para o SSH-J.com e publica sua porta SSH através de um túnel reverso. Como a conexão parte de dentro da rede local, o NAT não é obstáculo — o roteador trata como qualquer outra conexão de saída e mantém o caminho aberto. Quando você quer acessar a máquina, conecta ao SSH-J.com como jump host, e ele encaminha o tráfego pelo túnel que já está estabelecido.\nSSH-J.com: um jump host público e gratuito O SSH-J.com é um serviço mantido por ValdikSS, desenvolvedor russo conhecido por projetos de contorno de censura e ferramentas de rede. O servidor roda uma versão modificada do Dropbear — um servidor SSH leve, comum em sistemas embarcados e roteadores — configurada exclusivamente para permitir túneis reversos e conexões via jump host. Não é um serviço comercial, não tem plano pago, não exige cadastro e não armazena dados de usuário. A infraestrutura é mínima por design: o servidor existe para encaminhar conexões, nada mais.\nComo funciona O mecanismo usa dois recursos padrão do protocolo SSH que existem em qualquer cliente OpenSSH moderno: túnel reverso (-R) e jump host (-J).\nNa primeira etapa, a máquina atrás de NAT abre uma conexão SSH para o SSH-J.com e cria um túnel reverso. O comando é uma única linha:\nssh meuusuario@ssh-j.com -N -R minha-maquina:22:localhost:22 O -N diz ao SSH para não abrir um shell remoto — a conexão existe apenas para manter o túnel. O -R cria o túnel reverso propriamente dito: ele instrui o SSH-J.com a escutar conexões destinadas a minha-maquina na porta 22 e encaminhá-las de volta para localhost:22 na máquina que originou a conexão. O nome minha-maquina é um identificador arbitrário que você escolhe — pode ser o hostname, um apelido, qualquer coisa que faça sentido para você.\nNa segunda etapa, quando você quer acessar a máquina de qualquer lugar do mundo, usa o SSH-J.com como jump host:\nssh -J meuusuario@ssh-j.com minha-maquina O -J faz o SSH conectar primeiro ao SSH-J.com e de lá estabelecer uma conexão TCP para minha-maquina:22, que o servidor resolve internamente pelo túnel reverso que está ativo. A autenticação acontece em duas camadas: a conexão com o SSH-J.com (que aceita qualquer usuário sem senha) e a conexão com a máquina de destino (que usa as credenciais reais configuradas nela — senha ou chave SSH, como em qualquer acesso SSH normal).\nO username no SSH-J.com funciona como namespace. Os hosts publicados por um usuário ficam vinculados a ele — outros usernames não conseguem acessá-los. Isso significa que se alguém usar o mesmo nome de máquina que você mas com um username diferente, não há conflito. O limite é de 50 serviços publicados por username, mais do que suficiente para uso pessoal.\nSegurança: o que o SSH-J vê (e o que não vê) A pergunta natural ao rotear tráfego SSH por um servidor de terceiros é: o que esse servidor pode ver? A resposta curta é que ele vê os metadados da conexão, mas não o conteúdo.\nO SSH-J.com funciona como um relay de TCP. Ele sabe que uma conexão foi estabelecida entre o seu cliente e a sua máquina, conhece os IPs de origem e destino, e vê o volume de tráfego. Mas o conteúdo da sessão SSH é criptografado ponta a ponta entre o seu cliente e a máquina de destino — a negociação de chaves e a autenticação acontecem diretamente entre os dois endpoints, e o jump host não tem acesso ao material criptográfico necessário para decifrar o tráfego. Isso é uma propriedade do modo como o -J opera no OpenSSH: ele usa o jump host apenas como transporte TCP, não como proxy SSH que termina e reinicia a conexão.\nDito isso, você está confiando que o servidor é o que diz ser. O código-fonte do fork do Dropbear usado no SSH-J.com está disponível publicamente no Bitbucket do autor, mas você não tem como verificar que aquele binário específico é o que está rodando no servidor. Na prática, o nível de confiança necessário é semelhante ao de usar qualquer VPN ou proxy — e consideravelmente menor do que o exigido por soluções que instalam software proprietário na sua máquina. Para acesso a um servidor pessoal, um Raspberry Pi em casa ou uma máquina de desenvolvimento, o risco é aceitável. Para acesso a infraestrutura de produção com dados sensíveis, uma VPN dedicada ou um bastion host próprio continua sendo a escolha apropriada.\nConfiguração passo a passo Toda a configuração a seguir pode ser feita como usuário comum — não é necessário ser root para criar o túnel nem para conectar. A única etapa que exige privilégios de administrador é a criação do serviço systemd, que fica na próxima seção.\nPublicando sua máquina com um túnel reverso Antes de tudo, a máquina que você quer acessar precisa ter o servidor SSH instalado e rodando. No Debian, se ele ainda não estiver ativo:\nsudo apt install openssh-server Com o SSH local funcionando, o primeiro passo é aceitar o host key do SSH-J.com. Isso precisa ser feito uma vez, interativamente, antes de qualquer automação:\nssh meuusuario@ssh-j.com O SSH vai mostrar a fingerprint do servidor e perguntar se você aceita a conexão. Digite yes. A conexão vai se encerrar imediatamente porque o SSH-J.com não fornece shell interativo — é o comportamento esperado. O que importa é que a entrada foi gravada no ~/.ssh/known_hosts e conexões futuras não vão travar esperando confirmação.\nAgora crie o túnel reverso:\nssh meuusuario@ssh-j.com -N -R minha-maquina:22:localhost:22 O comando não produz nenhuma saída — o cursor fica parado e o terminal fica ocupado. Isso é o esperado: a conexão está aberta e o túnel está ativo. Enquanto esse terminal estiver aberto, sua máquina está acessível via SSH-J.com.\nConectando de qualquer lugar De qualquer outro computador com acesso à internet, basta:\nssh -J meuusuario@ssh-j.com usuario@minha-maquina O meuusuario é o namespace que você escolheu no SSH-J.com — precisa ser o mesmo usado no túnel reverso. O usuario é o login real na máquina de destino, que pode ser diferente. Se o nome de usuário local for o mesmo da máquina remota, a parte usuario@ pode ser omitida:\nssh -J meuusuario@ssh-j.com minha-maquina Na primeira conexão, o SSH vai pedir para aceitar o host key da máquina de destino — não do SSH-J.com, que já foi aceito antes. Isso confirma que a criptografia é ponta a ponta: o seu cliente está verificando a identidade da máquina final, não do intermediário.\nFerramentas que usam SSH como transporte também funcionam normalmente pelo jump host. Para copiar um arquivo com scp:\nscp -J meuusuario@ssh-j.com arquivo.txt usuario@minha-maquina:/tmp/ Para sincronizar um diretório com rsync:\nrsync -avP -e \u0026#34;ssh -J meuusuario@ssh-j.com\u0026#34; ./pasta/ usuario@minha-maquina:/home/usuario/pasta/ Configurando o ~/.ssh/config no cliente Digitar o -J meuusuario@ssh-j.com toda vez funciona, mas cansa. A solução é adicionar a configuração no ~/.ssh/config da máquina de onde você conecta — o seu laptop, o seu Mac, o computador do escritório:\nHost minha-maquina HostName minha-maquina User usuario ProxyJump meuusuario@ssh-j.com Com essa entrada, o acesso se reduz a:\nssh minha-maquina E o scp e rsync também herdam a configuração automaticamente, sem precisar do -J:\nscp arquivo.txt minha-maquina:/tmp/ rsync -avP ./pasta/ minha-maquina:/home/usuario/pasta/ Se você tem mais de uma máquina publicada no SSH-J.com, basta adicionar um bloco Host para cada uma. O namespace no SSH-J.com é o mesmo — o que diferencia é o nome do host no -R de cada túnel.\nAutomatizando com systemd O túnel reverso funciona enquanto a conexão SSH estiver aberta. Se a máquina reiniciar, se a rede cair por alguns segundos ou se o processo SSH morrer por qualquer motivo, o túnel desaparece e a máquina volta a ser inacessível. Para um teste rápido isso é aceitável, mas para acesso remoto confiável o túnel precisa se manter sozinho — levantar no boot e reconectar automaticamente em caso de falha.\nO systemd resolve isso com um serviço simples (se quiser entender o ecossistema de units e dependências do systemd mais a fundo, escrevi um post sobre systemd timers). A partir daqui, os comandos precisam ser executados como root.\nO serviço Crie o arquivo /etc/systemd/system/ssh-tunnel.service:\n[Unit] Description=SSH reverse tunnel via SSH-J.com After=network.target [Service] User=meuusuariolocal ExecStart=/usr/bin/ssh meuusuario@ssh-j.com -N -R minha-maquina:22:localhost:22 Restart=always RestartSec=15 [Install] WantedBy=multi-user.target Alguns pontos sobre essa unit file. O User= define qual usuário do sistema vai executar o processo SSH — e consequentemente qual ~/.ssh/known_hosts e quais chaves SSH serão usadas. Esse precisa ser o mesmo usuário com o qual você aceitou o host key do SSH-J.com na seção anterior. O Restart=always combinado com RestartSec=15 faz o systemd reiniciar o processo sempre que ele morrer, esperando 15 segundos entre tentativas para não sobrecarregar o servidor com reconexões em rajada. O After=network.target garante que o serviço só tenta iniciar depois que a rede estiver configurada — sem isso, o SSH tentaria conectar antes de ter uma rota para a internet e falharia silenciosamente.\nAceitando o host key antes de ativar Esse passo é o que pega quem tenta automatizar o túnel sem ter testado manualmente antes. O SSH precisa que o host key do SSH-J.com esteja no known_hosts do usuário que vai rodar o serviço. Se não estiver, o SSH tenta perguntar interativamente se você aceita a chave — mas como o systemd não tem terminal, a pergunta nunca aparece, o SSH falha com Host key verification failed, e o systemd fica reiniciando o processo indefinidamente a cada 15 segundos sem nenhum resultado visível.\nSe você seguiu a seção anterior e aceitou o host key como o usuário que está no User= do serviço, esse passo já está resolvido. Se não tem certeza, ou se o serviço vai rodar como um usuário diferente do que você usou antes, execute:\nsu - meuusuariolocal -c \u0026#34;ssh meuusuario@ssh-j.com\u0026#34; Aceite a fingerprint com yes. A conexão vai fechar imediatamente — novamente, é o esperado. O que importou aconteceu: o host key foi gravado no ~/.ssh/known_hosts daquele usuário.\nHabilitando e testando Com o host key aceito, ative o serviço:\nsystemctl daemon-reload systemctl enable --now ssh-tunnel O enable faz o serviço iniciar automaticamente no próximo boot. O --now faz ele iniciar também neste momento, sem precisar de um comando separado. Verifique se está rodando:\nsystemctl status ssh-tunnel A saída deve mostrar active (running) e o processo SSH na árvore de processos. Se aparecer activating (auto-restart) alternando com failed, o problema quase certamente é o host key — volte ao passo anterior.\nPara confirmar que o túnel está de fato funcional, vá até outra máquina e tente conectar:\nssh -J meuusuario@ssh-j.com minha-maquina Se o acesso funcionar, o serviço está pronto. A partir de agora a máquina vai manter o túnel ativo permanentemente, reconectando sozinha após reinicializações ou quedas de rede. Você pode verificar o comportamento reiniciando a máquina e tentando conectar novamente depois que ela voltar — o túnel deve se restabelecer automaticamente dentro de alguns segundos após o boot.\nQuando algo dá errado O túnel conecta mas o acesso falha O erro mais comum ao tentar conectar via jump host é:\nchannel 0: open failed: administratively prohibited: stdio forwarding failed Essa mensagem significa que o SSH-J.com recebeu a sua conexão mas não encontrou nenhum túnel reverso ativo com o nome de host que você pediu. Na prática, a máquina de destino não está publicada — ou porque o serviço do túnel não está rodando, ou porque o nome do host no -R não bate com o nome que você está usando no -J.\nO diagnóstico começa pelo lado do servidor. Conecte na máquina (por outro meio, se necessário) e verifique o serviço:\nsystemctl status ssh-tunnel Se estiver em failed ou em loop de auto-restart, o próximo passo é olhar o que o SSH está dizendo. Como o systemd pode não ter journal configurado em containers ou instalações mínimas, rode o comando manualmente com verbose para ver a saída real:\nsu - meuusuariolocal -c \u0026#34;ssh -v meuusuario@ssh-j.com -N -R minha-maquina:22:localhost:22\u0026#34; A saída com -v mostra cada etapa da negociação SSH. Os problemas mais frequentes aparecem nessas linhas:\nHost key verification failed — o known_hosts do usuário não tem a entrada do SSH-J.com. Aceite o host key interativamente como descrito na seção anterior.\nremote forward failure for: listen — o nome de host que você está tentando publicar já está em uso por outra conexão, possivelmente uma sessão anterior que ainda não expirou. Espere alguns minutos para o SSH-J.com liberar o nome, ou escolha um nome diferente no -R.\nConnection refused ao tentar conectar na máquina de destino — o túnel está ativo mas o servidor SSH da máquina não está rodando. Verifique com systemctl status ssh (no Debian, o serviço se chama ssh, não sshd).\nOutro ponto que gera confusão é o username. O nome de usuário usado no SSH-J.com é um namespace — ele vincula os hosts publicados ao seu \u0026ldquo;espaço\u0026rdquo;. Se você criou o túnel com joao@ssh-j.com mas tenta conectar com maria@ssh-j.com, o jump host não vai encontrar a máquina porque ela foi publicada em outro namespace. O username no SSH-J.com precisa ser idêntico nos dois lados: no -R da máquina que publica e no -J do cliente que conecta.\nReconexão após queda de rede O Restart=always do systemd cuida de reiniciar o processo SSH quando ele morre, mas existem situações em que a conexão TCP fica em um estado intermediário — a rede caiu e voltou, mas o processo SSH ainda não percebeu que a conexão foi perdida. O SSH continua achando que o túnel está ativo enquanto o SSH-J.com já descartou a sessão. O resultado é um serviço que aparece como active (running) no systemd mas que na prática não está funcionando.\nO SSH tem mecanismos nativos para detectar conexões mortas. Adicione estas opções ao comando no ExecStart do serviço:\nExecStart=/usr/bin/ssh meuusuario@ssh-j.com -N -R minha-maquina:22:localhost:22 \\ -o ServerAliveInterval=30 \\ -o ServerAliveCountMax=3 \\ -o ExitOnForwardFailure=yes O ServerAliveInterval=30 faz o SSH enviar um pacote de keep-alive a cada 30 segundos. Se o servidor não responder a três pacotes consecutivos (ServerAliveCountMax=3), o SSH encerra a conexão. Isso permite que o systemd detecte a falha e reinicie o processo em no máximo 90 segundos mais o RestartSec configurado.\nO ExitOnForwardFailure=yes faz o SSH encerrar imediatamente se o túnel reverso não puder ser estabelecido — por exemplo, se o nome de host ainda estiver preso de uma sessão anterior. Sem essa opção, o SSH mantém a conexão aberta mesmo que o -R tenha falhado, e você fica com um serviço rodando que não está fazendo nada.\nCom essas três opções, a unit file completa fica:\n[Unit] Description=SSH reverse tunnel via SSH-J.com After=network.target [Service] User=meuusuariolocal ExecStart=/usr/bin/ssh meuusuario@ssh-j.com -N -R minha-maquina:22:localhost:22 \\ -o ServerAliveInterval=30 \\ -o ServerAliveCountMax=3 \\ -o ExitOnForwardFailure=yes Restart=always RestartSec=15 [Install] WantedBy=multi-user.target Após editar, recarregue e reinicie:\nsystemctl daemon-reload systemctl restart ssh-tunnel Essa combinação de keep-alive do SSH com restart automático do systemd cobre a grande maioria dos cenários de falha de rede. Para quem quer uma camada adicional de robustez, o autossh é uma alternativa que monitora a conexão de forma mais agressiva e reconecta proativamente, mas na prática as opções nativas do OpenSSH combinadas com o systemd já oferecem confiabilidade suficiente para uso pessoal.\nSe o que você precisa expor não é SSH mas sim serviços web — um painel, um leitor de RSS, qualquer coisa que escuta em uma porta HTTP — escrevi um post sobre Cloudflare Tunnel que resolve esse cenário com o mesmo princípio de conexão de saída, mas com HTTPS automático e domínio próprio. E se o que você quer é conectar todos os seus dispositivos numa rede mesh privada, o Tailscale cobre SSH, serviços web e tudo mais sem expor nada à internet.\n","date":"25/03/2026","lang":"pt","tags":["ssh","nat","segurança","ssh-j.com","túneis-reversos","jump-host","automatização","systemd"],"title":"SSH atrás de NAT? SSH-J.com resolve.","url":"https://devops.sarmento.org/posts/ssh-atras-de-nat-ssh-jcom-resolve/"},{"categories":["Self-Hosting"],"content":"Existe um momento em que todo mundo para e pensa sobre onde estão as suas fotos. Geralmente acontece quando o Google manda aquele email simpático avisando que o armazenamento gratuito acabou — e que por apenas alguns reais por mês você pode continuar guardando suas memórias no servidor deles. É um empurrãozinho gentil na direção de uma assinatura mensal que, somada ao longo de anos, custa mais do que um HD externo de vários terabytes. Mas o preço em dinheiro é só a parte mais óbvia da equação. Existe um custo mais sutil em deixar todas as suas fotos, vídeos e memórias pessoais nas mãos de uma empresa que lucra com dados — e é sobre esse custo que vale a pena conversar antes de falar de qualquer ferramenta.\nO Immich é uma plataforma open source e auto-hospedada para gerenciar fotos e vídeos que funciona como uma alternativa direta ao Google Photos. Tem app para celular com backup automático, reconhecimento facial, busca inteligente por conteúdo das imagens, linha do tempo, mapa com geolocalização — tudo rodando no seu próprio hardware, sem depender de nenhum serviço externo. Neste post eu quero cobrir não apenas o que o Immich faz, mas principalmente o raciocínio por trás de hospedar suas próprias fotos: o que significa soberania de dados na prática, quais são as opções reais de infraestrutura (home lab ou VPS), e o que esperar quando você decide ser o dono do seu próprio acervo.\nO problema com o \u0026ldquo;gratuito\u0026rdquo; Quando o produto é você O Google Photos nasceu em 2015 oferecendo armazenamento ilimitado e gratuito para fotos em \u0026ldquo;alta qualidade\u0026rdquo;. A proposta era irresistível e funcionou exatamente como planejado: centenas de milhões de pessoas passaram a enviar todas as suas imagens para os servidores do Google sem pensar duas vezes. Em 2021, o ilimitado acabou. Tudo passou a consumir a cota de 15 GB compartilhada entre Gmail, Drive e Photos — e quem já tinha anos de fotos armazenadas se viu diante de uma escolha entre pagar ou perder o acesso prático à própria biblioteca.\nMas o armazenamento nunca foi realmente gratuito. O modelo de negócio do Google depende de entender quem você é, onde você vai, com quem você convive e o que você faz. Fotos são uma mina de ouro nesse sentido. Cada imagem carrega metadados EXIF com data, hora, coordenadas GPS e modelo da câmera. O conteúdo visual em si é processado por modelos de machine learning que identificam rostos, objetos, lugares e contextos. O Google sabe que você esteve naquele restaurante em março, que viajou para o litoral no feriado, que tem um cachorro da raça tal e que frequenta determinados lugares com determinadas pessoas. Nenhuma dessas informações precisa ser digitada — elas são extraídas automaticamente do acervo que você mesmo enviou.\nOs termos de serviço autorizam o Google a usar seu conteúdo para treinar modelos de IA, melhorar produtos e personalizar anúncios. Na prática, suas fotos de família alimentam os mesmos pipelines de dados que vendem espaço publicitário. Não existe conspiração nisso, está tudo descrito nos termos que quase ninguém lê. O ponto é que o serviço \u0026ldquo;gratuito\u0026rdquo; tem um preço — ele só não aparece na fatura do cartão.\nO custo invisível do armazenamento na nuvem Além da questão de privacidade, existe o problema do controle. Quando suas fotos moram no servidor de outra empresa, você depende das decisões dessa empresa para acessá-las. O Google pode mudar políticas, restringir APIs, modificar formatos de exportação ou simplesmente descontinuar um serviço — como já fez dezenas de vezes com outros produtos. Em março de 2025, o Google restringiu os escopos OAuth que ferramentas como o gphotos-sync usavam para baixar fotos, quebrando de uma hora para outra o fluxo de trabalho de quem mantinha backups locais automatizados das próprias imagens. Quem dependia exclusivamente do Google para armazenar seu acervo ficou à mercê de uma decisão unilateral sobre a qual não tinha nenhum poder.\nO mesmo raciocínio vale para iCloud, OneDrive, Amazon Photos ou qualquer outro serviço centralizado. Você não controla o roadmap do produto, não negocia os termos de uso e não tem garantia de que o serviço vai continuar existindo da mesma forma daqui a cinco anos. Enquanto isso, o volume de dados só cresce: celulares modernos gravam em 4K, fotos em modo RAW ocupam dezenas de megabytes cada, e Live Photos somam vídeo a cada clique. O tier gratuito evapora rápido, e a assinatura mensal que parece barata aos vinte e poucos reais se transforma em centenas ou milhares de reais ao longo de uma década — dinheiro suficiente para montar uma infraestrutura própria que ninguém pode tirar de você.\nSoberania de dados não é um conceito abstrato reservado a empresas e governos. Na escala pessoal, significa simplesmente que seus arquivos estão em hardware que você administra, em backups que você controla, acessíveis por ferramentas que não dependem da boa vontade de nenhum terceiro. É a diferença entre alugar um apartamento onde o proprietário pode mudar as regras do condomínio a qualquer momento e ser dono da própria casa. O mesmo raciocínio se aplica a outras áreas — até os comentários de um blog podem ser self-hosted em vez de depender de terceiros.\nO que é o Immich Uma alternativa real ao Google Photos O Immich é uma plataforma open source para gerenciamento de fotos e vídeos que roda inteiramente no seu próprio servidor. O projeto foi criado por Alex Tran, um desenvolvedor que queria uma forma privada e segura de armazenar as fotos do filho recém-nascido sem depender de nenhum serviço de nuvem comercial. O que começou como um projeto pessoal cresceu rapidamente e hoje tem mais de 90 mil estrelas no GitHub, uma equipe de desenvolvimento em tempo integral mantida pela FUTO — uma organização dedicada a software que respeita o usuário — e uma comunidade ativa que contribui com código, traduções e integrações.\nA comparação com o Google Photos não é exagero nem marketing. A interface web do Immich é moderna, responsiva e organizada de um jeito que qualquer pessoa acostumada com o Google Photos vai reconhecer imediatamente: uma linha do tempo cronológica de todas as suas mídias, visualização em grade, álbuns, favoritos e uma barra de busca que entende o conteúdo das imagens. Existe app nativo para Android e iOS que funciona como o app do Google Photos — abre, mostra sua biblioteca e faz backup automático de tudo que a câmera captura, em primeiro ou segundo plano. A diferença é que cada byte sai do celular e vai direto para o servidor que você administra, sem passar por nenhum intermediário.\nO projeto é construído com TypeScript no backend, PostgreSQL como banco de dados, Redis para filas de processamento e um serviço separado de machine learning em Python que roda os modelos de reconhecimento facial e busca semântica. Tudo empacotado em containers Docker, o que significa que a instalação inteira — servidor, banco, fila e ML — sobe com um único docker compose up -d e pode rodar em qualquer máquina Linux com recursos razoáveis.\nRecursos que valem a migração O backup automático pelo celular é o recurso que resolve o problema mais imediato: a garantia de que cada foto tirada vai parar no seu servidor sem nenhuma ação manual. O app detecta novas mídias na galeria e faz o upload em segundo plano, mesmo com a tela desligada. A versão 2.5 do Immich trouxe o recurso \u0026ldquo;Free Up Space\u0026rdquo;, que permite remover do celular as fotos que já foram enviadas para o servidor — exatamente como o Google Photos faz — com uma etapa obrigatória de revisão antes da exclusão e envio para a lixeira nativa do dispositivo, permitindo recuperação caso necessário.\nO reconhecimento facial funciona localmente, usando modelos de machine learning que rodam no próprio servidor. O Immich detecta e agrupa rostos automaticamente em toda a biblioteca, e você pode nomear cada pessoa para depois encontrar todas as fotos dela com um clique. A busca semântica usa o modelo CLIP para indexar o conteúdo visual das imagens, o que permite pesquisar por termos descritivos como \u0026ldquo;praia\u0026rdquo;, \u0026ldquo;cachorro\u0026rdquo;, \u0026ldquo;aniversário\u0026rdquo; ou \u0026ldquo;carro azul\u0026rdquo; sem que nenhuma tag tenha sido adicionada manualmente. Toda essa inteligência roda na sua máquina — nenhum dado é enviado para fora da sua rede.\nA visualização em mapa plota suas fotos geograficamente a partir dos dados EXIF de localização, e diferente de serviços que às vezes modificam ou removem metadados na exportação, o Immich preserva os arquivos originais intactos numa estrutura de pastas padrão. A edição não-destrutiva — corte, rotação e espelhamento — salva as alterações no banco de dados sem tocar no arquivo original, permitindo reverter qualquer modificação a qualquer momento. Suporte a múltiplos usuários permite criar contas separadas para cada membro da família, cada um com sua biblioteca privada e a possibilidade de compartilhar álbuns específicos entre si. Também há suporte completo a Live Photos do iOS e Motion Photos do Android, mantendo tanto a imagem estática quanto o componente de vídeo.\nPara quem tem bibliotecas de fotos já organizadas em disco — anos de pastas com nomes por data, por evento ou por câmera — o Immich suporta bibliotecas externas, importando acervos existentes sem copiar os arquivos, apenas referenciando o caminho original. Isso significa que você pode apontar o Immich para terabytes de fotos já organizadas e tê-las indexadas, pesquisáveis e acessíveis pelo app sem duplicar nenhum dado.\nPrivacidade e soberania de dados Suas fotos nunca saem do seu servidor Quando você envia uma foto para o Google Photos, ela é transmitida para um datacenter que pode estar em qualquer lugar do mundo, processada por pipelines automatizados de indexação, armazenada em infraestrutura compartilhada com bilhões de outros usuários e sujeita à jurisdição legal do país onde o servidor físico se encontra. Você não escolhe onde seus dados ficam, não sabe quantas cópias existem, não controla quem tem acesso administrativo às máquinas e não tem como auditar o que acontece com o conteúdo depois que ele sai do seu celular.\nCom o Immich, a cadeia é curta e visível. O app do celular faz upload direto para o endereço do seu servidor — seja um mini PC na sua sala, um NAS no armário ou uma VPS num datacenter que você escolheu. Os arquivos ficam numa estrutura de pastas no filesystem da máquina, o banco de dados PostgreSQL guarda os metadados e índices, e o serviço de machine learning processa as imagens localmente. Se o servidor está na sua casa, os dados literalmente não saem da sua rede local durante o backup. Se está numa VPS, você sabe exatamente em qual provedor e em qual região geográfica eles residem, e pode escolher jurisdições com leis de proteção de dados que façam sentido para você.\nEssa diferença parece técnica, mas tem consequências práticas reais. Nenhum funcionário de nenhuma empresa pode visualizar suas fotos por acesso administrativo. Nenhum algoritmo de moderação automatizada vai sinalizar ou remover imagens que um modelo de IA julgou inadequadas por engano. Nenhum tribunal de outro país pode intimar um provedor a entregar seu acervo sem que você sequer fique sabendo. O controle é seu porque a infraestrutura é sua.\nIA local: busca inteligente sem alimentar datasets alheios Um dos argumentos mais fortes a favor do Google Photos sempre foi a busca: você digita \u0026ldquo;pôr do sol na praia\u0026rdquo; e ele encontra a foto certa entre milhares. Essa capacidade existe porque o Google treinou modelos de visão computacional massivos usando — entre outras fontes — exatamente o tipo de conteúdo que os usuários enviam. É um ciclo que se retroalimenta: quanto mais fotos os usuários sobem, melhores ficam os modelos, mais útil fica o produto e mais fotos os usuários sobem.\nO Immich replica essa funcionalidade usando o modelo CLIP, que roda inteiramente no seu servidor. O CLIP cria representações vetoriais das imagens e dos termos de busca, permitindo encontrar fotos por descrição textual sem que nenhuma tag manual tenha sido criada. O reconhecimento facial usa modelos de detecção e agrupamento que processam cada rosto localmente, construindo clusters de pessoas que você pode nomear e pesquisar. Todo esse processamento acontece nos ciclos de CPU ou GPU da sua máquina. Os modelos são baixados uma única vez e executados offline — nenhuma imagem é enviada para nenhuma API externa, nenhum dado de treinamento sai da sua rede.\nO desempenho não é idêntico ao do Google, e seria desonesto dizer que é. Os modelos locais são menores, o hardware doméstico é mais limitado e a indexação inicial de uma biblioteca grande pode levar horas ou dias dependendo da máquina. Mas a busca funciona, o reconhecimento facial funciona, e a diferença de velocidade no uso cotidiano é pequena o suficiente para não atrapalhar. O que você ganha em troca é a certeza de que nenhuma foto sua está sendo usada para treinar o próximo modelo de IA generativa de ninguém.\nEXIF, metadados e a integridade dos seus arquivos Toda foto digital carrega uma camada invisível de informação embutida no arquivo: dados EXIF que registram a data e hora exatas do disparo, as coordenadas GPS de onde a foto foi tirada, o modelo da câmera, a lente usada, a abertura, a velocidade do obturador, o ISO. Para quem se preocupa com organização e preservação de longo prazo, esses metadados são tão importantes quanto a imagem em si — eles são o que permite reconstruir a cronologia de um acervo, plotar fotos num mapa e filtrar por equipamento.\nServiços de nuvem comerciais tratam os metadados de formas diferentes e nem sempre transparentes. Alguns recomprimem as imagens no upload, outros removem ou modificam campos EXIF na exportação, e quase todos convertem formatos para otimizar armazenamento interno. Quando você baixa suas fotos de volta pelo Google Takeout, o que recebe nem sempre é idêntico ao que enviou — e descobrir exatamente o que mudou exige comparação manual arquivo por arquivo.\nO Immich armazena os arquivos originais sem modificação. O que você envia é exatamente o que fica gravado no disco do servidor, com todos os metadados intactos. A edição não-destrutiva introduzida na versão 2.5 reforça esse princípio: cortes, rotações e espelhamentos são registrados no banco de dados como instruções, sem alterar o arquivo fonte. Você pode baixar a versão editada quando quiser, mas o original permanece preservado e pode ser restaurado a qualquer momento. Para quem pensa em preservação de acervo a longo prazo — décadas, não meses — essa garantia de integridade faz diferença.\nOnde hospedar Home lab: o servidor debaixo da sua mesa A opção mais alinhada com o espírito de soberania de dados é rodar o Immich em hardware que está fisicamente na sua casa. Não precisa ser nada grandioso. Um mini PC como o Beelink Mini N150 ou um ASRock DeskMini X600 consome menos de 10W em idle, é silencioso, cabe na palma da mão e tem potência de sobra para rodar todos os containers do Immich sem engasgar. Máquinas assim custam entre 300 e 500 dólares, e com um SSD NVMe de alguns terabytes você tem armazenamento suficiente para décadas de fotos em resolução original. Se você já tem um NAS da Synology, QNAP ou Unraid rodando em casa, o Immich pode ser mais um container no seu stack sem nenhum hardware adicional.\nO grande trunfo do home lab é que o backup do celular acontece inteiramente dentro da sua rede local. O app do Immich conecta direto no IP da máquina, os arquivos trafegam pelo seu Wi-Fi e em nenhum momento saem para a internet. A velocidade de upload é a velocidade da sua rede interna — tipicamente gigabit — o que torna o backup de centenas de fotos e vídeos 4K uma questão de minutos, não de horas esperando a banda de upload do seu provedor de internet. O processamento de machine learning para reconhecimento facial e indexação semântica também roda localmente, e se a máquina tiver uma GPU com suporte a CUDA, o processo fica consideravelmente mais rápido.\nPara acessar o Immich de fora de casa — no trabalho, viajando, do celular na rua — existem dois caminhos práticos. O primeiro é o Tailscale, uma VPN mesh que cria um túnel criptografado entre todos os seus dispositivos sem exigir nenhuma configuração de firewall ou port forwarding no roteador. Você instala o Tailscale no servidor e no celular, e o Immich fica acessível por um endereço privado como se estivesse na rede local, de qualquer lugar do mundo. O segundo caminho é configurar um reverse proxy ou um Cloudflare Tunnel com um domínio próprio e certificado HTTPS automático, expondo o Immich na internet pública. Esse segundo caminho exige mais cuidado com segurança, mas dá mais flexibilidade.\nA desvantagem óbvia do home lab é que ele depende da sua infraestrutura residencial. Se acaba a luz, o servidor desliga. Se o roteador trava, o acesso remoto cai. Se o disco falha e não existe backup externo, os dados podem ser perdidos. Nada disso é insuperável — nobreaks são baratos, roteadores reiniciam sozinhos e backups offsite resolvem o problema da falha de disco — mas exige que você assuma o papel de administrador da sua própria infraestrutura. O servidor não vai se manter sozinho.\nVPS: o servidor que não desliga quando acaba a luz Para quem não quer depender da estabilidade da rede elétrica e do provedor de internet residencial, rodar o Immich numa VPS é uma alternativa sólida. Uma VPS é uma máquina virtual que roda num datacenter profissional com energia redundante, links de rede de alta capacidade e hardware monitorado 24 horas. O Immich precisa de pelo menos 4 GB de RAM e 2 núcleos de CPU para funcionar confortavelmente, o que se traduz em planos na faixa de 10 a 30 dólares por mês dependendo do provedor e da quantidade de armazenamento.\nA instalação numa VPS é essencialmente idêntica à do home lab: SSH na máquina, instalar Docker, subir o docker-compose.yml do Immich e configurar um reverse proxy com HTTPS. Provedores como Hetzner, Contabo, Hostinger e várias opções europeias oferecem VPS com volumes de armazenamento generosos a preços razoáveis. Para quem se preocupa com GDPR ou quer que os dados residam numa jurisdição específica, escolher um provedor europeu com datacenters na UE simplifica a questão: você é ao mesmo tempo o controlador e o processador dos dados, e eles nunca saem do servidor que você contratou.\nA grande vantagem da VPS é a disponibilidade. O servidor está sempre ligado, sempre acessível, com um IP fixo e uma conexão de rede cuja velocidade não depende do plano residencial que você contratou da sua operadora de internet. O backup automático do celular funciona de qualquer rede — Wi-Fi de hotel, 4G no ônibus, rede do aeroporto — sem precisar de VPN nem túnel especial, bastando apontar o app para o domínio HTTPS do servidor.\nA desvantagem é que suas fotos passam a residir no datacenter de outra empresa. Você tem acesso root à máquina virtual, controla o sistema operacional e o software instalado, mas o hipervisor por baixo pertence ao provedor de hosting. Em termos de privacidade, é um passo atrás em relação ao home lab — embora ainda esteja a quilômetros de distância de entregar tudo para o Google. A outra desvantagem é o custo de armazenamento. Enquanto no home lab um HD de 8 TB custa uma única vez o equivalente a dois anos de VPS, na nuvem cada terabyte adicional entra na conta mensal. Para acervos muito grandes, a conta da VPS pode ficar salgada.\nComo escolher entre os dois A escolha não é necessariamente excludente, e a pergunta mais útil não é qual opção é melhor em abstrato, mas qual se encaixa na sua situação concreta. Se você já tem um NAS ou mini PC em casa, rede estável, e não se incomoda em manter um serviço rodando, o home lab oferece o máximo de controle com o mínimo de custo recorrente. Se você mora num lugar com quedas de energia frequentes, não quer se preocupar com hardware físico ou precisa de acesso confiável de qualquer lugar sem configurar VPN, a VPS resolve com previsibilidade e pouca manutenção.\nTambém é possível combinar as duas abordagens. Uma configuração que aparece com frequência na comunidade é rodar o Immich no home lab para uso primário e manter uma cópia dos dados numa VPS ou em armazenamento de objetos como S3, Backblaze B2 ou equivalente como backup offsite. O inverso também funciona: rodar o Immich numa VPS como servidor principal e manter um backup criptografado num HD externo em casa. O importante é que exista pelo menos uma cópia dos dados fora do local onde o servidor primário opera — mas isso é assunto para a seção sobre backups.\nO que você vai precisar Hardware e requisitos mínimos O Immich roda em qualquer máquina Linux capaz de sustentar Docker e alguns containers razoavelmente exigentes em memória. A recomendação oficial é de pelo menos 4 GB de RAM e 2 núcleos de CPU, mas na prática 6 GB de RAM e 4 núcleos tornam a experiência mais fluida, especialmente durante a indexação inicial de uma biblioteca grande, quando o serviço de machine learning consome bastante recurso processando cada imagem pela primeira vez. Depois que a biblioteca está indexada, o uso cotidiano é leve — upload de fotos novas, navegação pela interface, buscas — e a máquina fica praticamente ociosa entre uma operação e outra.\nPara armazenamento, a conta depende do tamanho do seu acervo e dos seus hábitos. Fotos de celular em HEIC ou JPEG comprimido ocupam entre 2 e 5 MB cada. Fotos em RAW de câmera dedicada vão de 25 a 80 MB dependendo do sensor. Vídeos em 4K consomem de 300 a 500 MB por minuto. Além dos arquivos originais, o Immich gera thumbnails e versões otimizadas para navegação, o que adiciona entre 10% e 20% ao espaço total. Um acervo de 50 mil fotos de celular acumuladas ao longo de dez anos ocupa algo em torno de 150 a 200 GB com thumbnails incluídas. Se há muitos vídeos 4K no meio, esse número sobe rápido.\nNo home lab, um SSD NVMe para o sistema operacional e o banco de dados combinado com um HD mecânico de alta capacidade para a biblioteca de mídia é uma configuração comum e econômica. O PostgreSQL e o Redis se beneficiam da velocidade do SSD, enquanto o armazenamento das fotos em si não precisa de latência baixa — um HD de 4 ou 8 TB atende bem. Numa VPS, o armazenamento disponível no plano contratado é o que define o limite, e vale dimensionar com folga desde o início porque migrar para um volume maior depois exige downtime e planejamento.\nSe a máquina tiver uma GPU NVIDIA com suporte a CUDA (compute capability 5.2 ou superior), o Immich pode usá-la para acelerar o processamento de machine learning — reconhecimento facial e indexação semântica ficam drasticamente mais rápidos. Não é obrigatório; tudo funciona em CPU, apenas mais devagar. Para quem está montando um home lab novo e sabe que vai indexar uma biblioteca de dezenas de milhares de fotos, considerar uma máquina com GPU integrada ou uma placa dedicada modesta pode economizar muitas horas de processamento inicial.\nDocker Compose e a instalação em cinco minutos O Immich é distribuído como um conjunto de containers Docker orquestrados por um arquivo docker-compose.yml mantido pelo projeto. A instalação consiste em clonar esse arquivo, configurar um .env com o caminho de armazenamento e a senha do banco de dados, e subir tudo com docker compose up -d. Não existe instalação manual de dependências, compilação de código nem configuração de serviços individuais — o Compose resolve a orquestração entre o servidor principal, o banco PostgreSQL, o Redis e o serviço de machine learning.\nO arquivo .env tem poucos parâmetros obrigatórios. O mais importante é o UPLOAD_LOCATION, que define onde os arquivos de mídia serão armazenados no filesystem do host, e o DB_PASSWORD, que deve ser uma string aleatória forte usando apenas caracteres alfanuméricos para evitar problemas de parsing do Docker. O projeto mantém um arquivo de exemplo com valores padrão que funcionam para a maioria dos casos — basta copiar, ajustar os caminhos e a senha, e subir os containers.\nPara quem usa Portainer, Dockge ou outro gerenciador de containers com interface gráfica, o processo é o mesmo: colar o conteúdo do Compose no editor de stacks, configurar as variáveis de ambiente e fazer deploy. Em NAS da Synology ou Unraid, a comunidade mantém guias específicos para cada plataforma que adaptam os caminhos de volume e permissões ao modelo de cada sistema.\nAtualizações seguem o mesmo padrão. O projeto publica novas versões como tags de imagem Docker, e atualizar é questão de puxar as imagens novas e recriar os containers. O processo inteiro leva menos de um minuto em condições normais, mas é prudente fazer um backup do banco de dados antes de qualquer atualização — o Immich está em desenvolvimento ativo e, embora a equipe seja cuidadosa com migrações de schema, surpresas acontecem.\nAcesso remoto: Tailscale, reverse proxy e HTTPS Com o Immich rodando, o próximo passo é garantir que você consiga acessá-lo de fora da rede local. Se o servidor está numa VPS com IP público, basta colocar um reverse proxy na frente — Caddy é a opção mais simples porque obtém e renova certificados HTTPS automaticamente via Let\u0026rsquo;s Encrypt sem nenhuma configuração adicional. Você aponta um domínio para o IP da VPS, configura o Caddy para fazer proxy para a porta 2283 do Immich, e em poucos segundos tem acesso HTTPS funcional. Nginx também serve, mas exige um pouco mais de configuração manual para o certificado.\nSe o servidor está no home lab, a situação é diferente. A maioria das conexões residenciais no Brasil usa CGNAT, o que significa que você não tem um IP público próprio e não pode simplesmente abrir portas no roteador. O Tailscale resolve esse problema criando uma rede mesh criptografada entre seus dispositivos, independente da topologia da rede subjacente. Você instala o Tailscale no servidor e em cada dispositivo que precisa acessar o Immich — celular, laptop, tablet — e todos passam a se enxergar como se estivessem na mesma rede local, com endereços IP estáveis e tráfego criptografado ponta a ponta. O comando tailscale serve expõe o Immich na rede Tailscale com HTTPS automático via MagicDNS, resultando num endereço como https://fotos.seu-tailnet.ts.net acessível de qualquer lugar. Se você prefere que o Immich fique acessível por um domínio público sem instalar nada nos dispositivos clientes, o Cloudflare Tunnel é outra alternativa — publica o serviço com HTTPS automático usando apenas uma conexão de saída, sem abrir portas e sem IP público.\nNo app do Immich para celular, a configuração se resume a informar a URL do servidor — seja o domínio público com HTTPS, seja o endereço Tailscale — e autenticar com seu usuário. A partir daí o backup automático funciona em qualquer rede: Wi-Fi de casa, dados móveis, Wi-Fi de hotel. O app é inteligente o suficiente para respeitar configurações de upload apenas em Wi-Fi, se você preferir economizar dados móveis.\nBackup: a parte que ninguém quer pensar (mas deve) A regra 3-2-1 na prática Auto-hospedar suas fotos resolve o problema da dependência de terceiros, mas cria um novo: você é o único responsável pela integridade dos dados. Não existe mais uma equipe de engenharia do Google replicando seus arquivos em três datacenters diferentes em continentes distintos. Se o disco do seu servidor falha e não existe backup, o acervo desaparece. Fotos de casamento, primeiros passos dos filhos, viagens que não vão se repetir — tudo se perde junto com os setores defeituosos de um HD mecânico ou as células degradadas de um SSD.\nA regra 3-2-1 existe há décadas e continua sendo o framework mais simples e eficaz para proteção de dados: três cópias dos seus arquivos, em dois tipos diferentes de mídia, com pelo menos uma cópia offsite. Na prática, para quem roda o Immich num home lab, isso pode se traduzir em algo como: a cópia primária no SSD ou HD do servidor, uma segunda cópia num HD externo USB plugado na mesma máquina com sincronização diária automatizada, e uma terceira cópia num serviço de armazenamento de objetos remoto. Para quem roda numa VPS, a lógica se inverte: a cópia primária está no datacenter, e a cópia offsite pode ser um HD externo na sua casa que recebe sincronizações periódicas via rsync ou rclone.\nO ponto que muita gente subestima é que o backup do Immich não se resume a copiar a pasta de fotos. O banco de dados PostgreSQL contém todos os metadados, os índices de busca, os agrupamentos de reconhecimento facial, os nomes atribuídos a cada pessoa, a estrutura de álbuns e as informações de cada usuário. Perder a pasta de fotos e manter o banco é ruim; perder o banco e manter as fotos é quase tão ruim, porque toda a organização e o trabalho de curadoria desaparecem. O Immich oferece backup e restauração do banco de dados pela própria interface web desde a versão 2.5, o que facilita bastante, mas o dump do PostgreSQL também deve fazer parte da rotina de backup automatizado.\nBackup local + offsite com S3 ou equivalente O backup local é a primeira linha de defesa e a mais simples de implementar. Um HD externo USB conectado ao servidor com um cronjob rodando rsync uma vez por dia resolve a questão da segunda cópia com zero custo recorrente. O rsync é incremental — na primeira execução ele copia tudo, nas seguintes copia apenas o que mudou — então mesmo bibliotecas de vários terabytes geram transferências diárias pequenas depois da sincronização inicial. O dump do PostgreSQL pode ser adicionado ao mesmo script: um pg_dump antes do rsync garante que o banco de dados acompanhe os arquivos de mídia no backup.\nO backup offsite é a proteção contra desastres que afetam o local físico do servidor: incêndio, inundação, roubo, surto elétrico que frita tudo que está ligado na mesma tomada. Para essa terceira cópia, armazenamento de objetos na nuvem é a opção mais prática e econômica. O Amazon S3 na classe Glacier Deep Archive custa centavos por gigabyte por mês — um acervo de 500 GB sai por volta de vinte centavos de dólar mensais. Backblaze B2 é outra opção popular com preços similares e sem taxas de egresso para volumes pequenos. O rclone é a ferramenta padrão para esse tipo de sincronização: ele fala com praticamente qualquer provedor de armazenamento de objetos, suporta criptografia client-side antes do upload e pode ser agendado com um cronjob semanal ou diário conforme a frequência que fizer sentido para o seu volume de dados.\nQuem prefere não depender de nenhum serviço comercial para o backup offsite pode combinar com um amigo ou familiar que também auto-hospeda: cada um roda um script que envia backups criptografados para o servidor do outro via SSH ou rclone SFTP. É a versão caseira da redundância geográfica — seus dados ficam na casa de alguém de confiança, e os dados dessa pessoa ficam na sua, ambos criptografados de ponta a ponta e ilegíveis sem a chave que só o dono possui. Ferramentas como restic e borg tornam esse fluxo especialmente prático porque fazem backup incremental, comprimido e criptografado nativamente, sem exigir etapas separadas para cada uma dessas funções.\nO mais importante é que o backup exista, funcione e seja testado. Um backup que nunca foi restaurado é uma suposição, não uma garantia. Reserve um momento para verificar que o dump do PostgreSQL restaura corretamente num container limpo e que os arquivos de mídia no backup correspondem ao que está no servidor. Fazer isso uma vez a cada poucos meses é suficiente para dormir tranquilo.\nLimitações e o que esperar Immich não é (ainda) um produto acabado O Immich evolui rápido — a cadência de releases é alta, a equipe é responsiva e o ritmo de commits no repositório impressiona. Mas velocidade de desenvolvimento também significa que o software ainda está em movimento. Quebras entre versões acontecem. Migrações de schema do banco de dados ocasionalmente exigem atenção manual. Features aparecem, mudam de comportamento e às vezes são reescritas entre uma release e outra. A própria documentação do projeto deixa claro que o Immich não deve ser tratado como a única cópia dos seus dados, e essa honestidade é um bom sinal sobre a maturidade da equipe, mas também um lembrete de que estamos falando de software em construção ativa, não de um produto estável e previsível como o Google Photos.\nNa prática, isso significa que manter o Immich saudável exige um mínimo de envolvimento periódico. Verificar os logs depois de uma atualização, acompanhar o changelog antes de puxar uma versão nova, e ter o backup do banco de dados em dia antes de qualquer upgrade são hábitos que precisam entrar na rotina. Nada disso é complexo nem consome muito tempo — estamos falando de cinco a dez minutos por mês em condições normais — mas é tempo que você não gastaria se estivesse simplesmente usando o Google Photos.\nO app mobile para iOS e Android é funcional e melhorou muito nas últimas versões, mas ainda tem arestas. O upload em segundo plano às vezes precisa de um empurrão manual no iOS por conta das limitações que a Apple impõe a processos em background. A interface web é bonita e responsiva, mas quem vem do Google Photos vai notar que algumas interações são menos polidas — arrastar para selecionar múltiplas fotos, transições entre visualizações, velocidade de rolagem em bibliotecas muito grandes. São detalhes, não impedimentos, e a tendência é que melhorem com cada release, mas vale ajustar as expectativas antes de migrar.\nO machine learning local também tem suas particularidades. A indexação inicial de uma biblioteca grande consome horas em CPU e pode levar dias se a máquina for modesta. Durante esse período o servidor fica mais lento para outras tarefas. O reconhecimento facial funciona bem na maioria dos casos, mas a acurácia dos clusters depende da qualidade e variedade das fotos — rostos parcialmente cobertos, iluminação ruim ou ângulos extremos geram agrupamentos incorretos que precisam de correção manual. A busca semântica via CLIP é impressionantemente útil para termos genéricos como \u0026ldquo;montanha\u0026rdquo; ou \u0026ldquo;festa\u0026rdquo;, mas tropeça em consultas muito específicas ou contextuais que o Google, com seus modelos massivos, resolveria melhor.\nQuando auto-hospedar não faz sentido Auto-hospedar não é para todo mundo, e fingir que é seria desonesto. Se você não tem nenhum interesse em administrar infraestrutura, não quer pensar em backups, não se incomoda com os termos de uso do Google e o preço da assinatura do Google One cabe confortavelmente no seu orçamento, o Google Photos continua sendo um produto excelente que funciona sem atrito. Não existe vergonha em preferir conveniência — a maioria esmagadora das pessoas faz exatamente essa escolha, e para muitas delas é a escolha certa.\nO Immich também não é a melhor opção para quem precisa de colaboração pesada entre muitos usuários em tempo real, para ambientes corporativos com requisitos rígidos de compliance que exigem auditoria certificada, ou para quem tem um acervo de centenas de milhares de fotos e vídeos 4K mas só dispõe de um Raspberry Pi com 2 GB de RAM para rodar tudo. A ferramenta escala bem para uso pessoal e familiar, mas tem limites de hardware e de escopo que não fazem sentido ignorar.\nOutra situação em que auto-hospedar pode ser mais dor de cabeça do que benefício é quando a infraestrutura de internet residencial é muito precária. Se a sua conexão cai com frequência, a energia elétrica é instável e não existe orçamento para um nobreak, o servidor doméstico vai passar mais tempo offline do que online. Nesses casos, uma VPS resolve o problema de disponibilidade, mas adiciona custo mensal recorrente — e se o acervo é grande, esse custo pode superar rapidamente o preço de uma assinatura de armazenamento na nuvem comercial. Cada situação é diferente, e a decisão precisa levar em conta a realidade concreta, não o ideal teórico.\nVale a pena? A resposta depende do que você valoriza, e essa não é uma evasiva — é o ponto central de tudo que foi discutido até aqui. Se o que te incomoda é saber que suas fotos de família alimentam modelos de IA treinados para vender publicidade, o Immich elimina esse incômodo por completo. Se o que te preocupa é a possibilidade de um serviço mudar suas políticas da noite para o dia e restringir o acesso ao seu próprio acervo, rodar a infraestrutura no seu hardware remove essa variável da equação. Se o que te motiva é simplesmente a satisfação de abrir o app no celular e saber que cada foto está armazenada num servidor que você configurou, num disco que você escolheu, protegida por backups que você mesmo montou — então sim, vale muito a pena.\nO custo de entrada é menor do que parece. Quem já tem um NAS ou um mini PC em casa pode ter o Immich rodando em menos de uma hora sem gastar nada além do tempo. Quem precisa comprar hardware vai investir algo entre o equivalente a dois e quatro anos de assinatura do Google One — depois disso, o custo recorrente é apenas energia elétrica e, se houver backup offsite na nuvem, alguns centavos por mês de armazenamento de objetos. Quem preferir a VPS terá um custo mensal, mas com a contrapartida de não se preocupar com disponibilidade e hardware físico. Em qualquer cenário, o investimento se paga em prazo razoável se comparado com décadas de assinaturas acumuladas.\nO Immich não é perfeito. É um projeto em desenvolvimento ativo, com arestas que ainda estão sendo aparadas e limitações que não existem nos produtos de empresas com orçamentos bilionários. Mas é também um dos projetos open source mais impressionantes dos últimos anos no espaço de auto-hospedagem, com uma equipe comprometida, uma comunidade enorme e uma velocidade de evolução que faz o software de hoje ser significativamente melhor do que o de seis meses atrás. A cada release, a distância entre o Immich e o Google Photos diminui — e em alguns aspectos, como preservação de metadados e controle granular sobre os dados, o Immich já está à frente.\nSuas fotos são o registro visual da sua vida. São os rostos das pessoas que importam, os lugares onde você esteve, os momentos que não se repetem. Decidir onde esse acervo mora e quem tem acesso a ele é uma das poucas decisões digitais que realmente merece atenção. O Immich te dá a opção de tomar essa decisão de volta.\n","date":"25/03/2026","lang":"pt","tags":["segurança","self-hosted","devops","alternativas","privacidade","backup","armazenamento","open-source","immich","fotos"],"title":"Immich: suas fotos, seu servidor, suas regras","url":"https://devops.sarmento.org/posts/immich-suas-fotos-seu-servidor-suas-regras/"},{"categories":["Sites Estáticos"],"content":"No post anterior, montamos um blog completo com Hugo, GitHub, Cloudflare Pages e Pages CMS sem gastar um centavo. A stack funciona, é rápida, e para a maioria dos blogs pessoais vai continuar funcionando por muito tempo sem pedir nada em troca. Mas \u0026ldquo;grátis\u0026rdquo; não significa \u0026ldquo;sem limites\u0026rdquo;, e entender onde estão as paredes antes de bater nelas é o tipo de coisa que poupa dor de cabeça lá na frente.\nEste post é o complemento prático daquele tutorial. Aqui vamos olhar para os limites reais de cada serviço gratuito da stack, discutir em que cenários eles começam a apertar, e listar alternativas para quando — ou se — isso acontecer. Nada de passo a passo de instalação; a ideia é dar o mapa geral para que você tome decisões informadas.\nO preço do \u0026ldquo;grátis\u0026rdquo; Serviços gratuitos para desenvolvedores existem por razões bem concretas. O GitHub quer que você e seu time usem a plataforma até o ponto em que faz sentido pagar pelo plano Team ou Enterprise. A Cloudflare quer que seu domínio passe pela rede deles, porque mais sites significam mais dados sobre ataques e mais argumentos de venda para clientes corporativos. O Pages CMS é um projeto open source mantido por um único desenvolvedor que precisava da ferramenta e decidiu compartilhá-la.\nNenhuma dessas motivações é ruim — na verdade, o alinhamento de incentivos é o que faz o modelo funcionar. O GitHub não vai tirar seu repositório gratuito amanhã, e a Cloudflare não vai cobrar pelo bandwidth do seu blog de receitas. O ponto é que cada um desses serviços tem limites desenhados para separar o uso pessoal e de pequenos projetos do uso comercial em escala, e conhecer esses limites é parte de usar a stack com confiança em vez de com esperança.\nNas próximas seções, vamos passar por cada peça da stack — GitHub, Cloudflare Pages e Pages CMS — com números concretos e contexto suficiente para você avaliar se algum deles é relevante para o seu caso.\nGitHub Free: o repositório tem paredes O plano gratuito do GitHub é extraordinariamente generoso para o que a maioria das pessoas precisa: repositórios públicos e privados ilimitados, colaboradores ilimitados em repos públicos, e funcionalidades suficientes para rodar projetos sérios sem pagar nada. Mas generoso não é infinito, e os limites que existem estão em lugares que afetam diretamente quem mantém um site estático.\nTamanho do repositório e dos arquivos O GitHub recomenda que repositórios fiquem abaixo de 1 GB e insiste fortemente que não passem de 5 GB. Não existe um hard limit publicado para o tamanho total — o que acontece na prática é que, acima de 5 GB, o suporte entra em contato pedindo que você reduza o tamanho ou mude de abordagem. Arquivos individuais têm um limite real de 100 MB via linha de comando; pelo browser, o teto cai para 25 MB. Qualquer push com um arquivo acima de 100 MB é simplesmente rejeitado.\nPara um blog Hugo, isso raramente é um problema. Markdown pesa quase nada, e o repositório inteiro de um blog com centenas de posts dificilmente passa de algumas dezenas de megabytes. O risco mora nas imagens. Se você versionar fotos em alta resolução direto no repositório em vez de otimizá-las antes de commitar, o histórico do Git vai acumulando cada versão de cada arquivo, e o tamanho do repo cresce de um jeito que não é óbvio até você tentar clonar e perceber que está baixando gigabytes de JPEGs antigos.\nGit LFS: 1 GB de storage, 1 GB de banda Para arquivos grandes que realmente precisam estar no repositório, o GitHub oferece o Git Large File Storage. O LFS armazena o arquivo fora do repo e deixa no lugar dele um ponteiro leve. O plano gratuito inclui 1 GB de armazenamento e 1 GB de banda por mês — e os dois limites são mais apertados do que parecem. Cada push de um arquivo consome storage cumulativamente (pushar o mesmo arquivo de 500 MB duas vezes esgota a cota), e cada download por qualquer pessoa ou CI consome banda. Para um blog pessoal com imagens otimizadas, você provavelmente nunca vai precisar de LFS. Mas é bom saber que o limite existe e que, se você esbarrar nele, os pushes são rejeitados silenciosamente.\nGitHub Actions: 2.000 minutos para repos privados O GitHub Actions é gratuito e ilimitado para repositórios públicos usando runners padrão. Para repositórios privados, o plano Free inclui 2.000 minutos por mês de execução em runners Linux — o que equivale a mais de 33 horas de CI/CD. Se o seu repositório Hugo é público, esse limite simplesmente não se aplica. Se é privado, os 2.000 minutos são mais do que suficientes para a maioria dos fluxos: um build Hugo típico leva menos de um minuto, então você precisaria fazer mais de 60 deploys por dia para chegar perto do teto.\nVale notar que a Cloudflare Pages já cuida do build e deploy quando você conecta o repositório, então a maioria dos blogs Hugo nessa stack nem usa GitHub Actions para deploy. Os minutos só entram na conta se você configurar workflows adicionais — linting, testes, otimização de imagens, esse tipo de coisa.\nO detalhe do repo público vs. privado A escolha entre repositório público e privado muda bastante a equação de limites. Com um repo público, você tem Actions ilimitado e visibilidade total do código — o que, para um blog, não é necessariamente um problema, já que o conteúdo vai ser publicado de qualquer forma. Com um repo privado, você ganha privacidade sobre rascunhos e configurações, mas entra no regime de cotas de Actions e Packages. Para a maioria dos blogs pessoais, manter o repositório público é a decisão mais simples e a que deixa mais margem dentro do plano gratuito. Se você tem razões para manter o repo privado — conteúdo sensível em rascunho, configurações que não quer expor — os limites do plano Free ainda são confortáveis para um único site com deploys moderados.\nCloudflare Pages Free: generoso, mas com teto A Cloudflare Pages é, de longe, a peça mais generosa dessa stack. Bandwidth ilimitado, requests ilimitados para conteúdo estático, SSL automático, CDN global, sites ilimitados — tudo no plano gratuito, sem pedir cartão de crédito. É o tipo de oferta que faz você desconfiar, mas a lógica por trás dela é sólida: a Cloudflare quer seu domínio passando pela rede deles, e hospedar sites estáticos é computacionalmente barato o suficiente para servir como porta de entrada. Os limites que existem não estão no tráfego, mas no processo de build e na escala de arquivos.\n500 builds por mês — e é por conta, não por projeto Cada push para o repositório conectado dispara um build na Cloudflare. O plano gratuito permite 500 builds por mês, e esse é o limite que mais confunde as pessoas: ele é por conta, não por projeto. Se você tem três sites rodando na mesma conta Cloudflare, os três compartilham a mesma cota de 500 builds. Para um blog pessoal com um deploy por dia, são cerca de 30 builds por mês — confortável até com vários sites. O cenário onde isso aperta é o de times que fazem iterações rápidas com múltiplos pushes por dia em vários projetos, ou quem usa preview deployments extensivamente durante o desenvolvimento.\nNa prática, o limite de builds é mais uma questão de disciplina de workflow do que uma restrição técnica. Consolidar mudanças em commits menos frequentes em vez de pushar cada vírgula resolve o problema para quase todo mundo. E se 500 builds por mês não forem suficientes, o plano Pro aumenta para 5.000 — mas a essa altura você provavelmente já tem um motivo comercial para pagar.\nUm build por vez O plano gratuito processa um único build de cada vez. Se você faz push em dois projetos ao mesmo tempo, o segundo espera na fila até o primeiro terminar. Para um blog Hugo, onde o build inteiro leva segundos, isso é imperceptível. A limitação se torna relevante para sites maiores com processos de build mais pesados — frameworks JavaScript com etapas de bundling, otimização de imagens no pipeline, esse tipo de coisa — ou para contas com muitos projetos fazendo deploy simultaneamente. O plano Pro sobe para 5 builds concorrentes, e o Business para 20.\n20.000 arquivos por site Cada site no plano gratuito pode ter no máximo 20.000 arquivos. Isso conta tudo que é deployado: HTMLs gerados, imagens, CSS, JavaScript, fontes, favicons. Um blog Hugo de porte médio fica muito longe desse número — mesmo com centenas de posts e imagens, dificilmente você passa de alguns milhares de arquivos. O limite começa a ser relevante para sites de documentação grandes com milhares de páginas, ou para projetos que geram muitos assets por página. Planos pagos elevam o teto para 100.000 arquivos.\nFunctions: o limite de 100.000 requests/dia vem do Workers Se você usar Cloudflare Pages Functions — código serverless que roda no edge — as requisições contam contra a cota do plano Workers Free, que é de 100.000 requests por dia, resetando à meia-noite UTC. Esse limite é compartilhado entre Pages Functions e qualquer Worker que você tenha na mesma conta. Para um blog estático puro, isso não se aplica — HTML, CSS, imagens e JavaScript estático não consomem essa cota. Ela só entra em jogo se você adicionar funcionalidades dinâmicas ao site, como um formulário de contato processado no edge ou uma API customizada. Ainda assim, 100.000 requests por dia é um volume considerável para funcionalidades auxiliares de um blog.\nPages CMS: grátis, open source — e os riscos que vêm com isso O Pages CMS ocupa uma posição diferente das outras peças da stack. GitHub e Cloudflare são empresas bilionárias oferecendo planos gratuitos como estratégia comercial; o Pages CMS é um projeto open source criado e mantido por Ronan Berder, um desenvolvedor que precisava de um CMS simples para sites estáticos e decidiu compartilhar a solução. É 100% gratuito, licenciado sob MIT, e pode ser usado tanto na versão hospedada em app.pagescms.org quanto deployado por conta própria no Vercel ou self-hosted. Não existe plano pago, não existe tier premium, não existe empresa por trás cobrindo custos de infraestrutura com receita de outros produtos.\nIsso é ao mesmo tempo a maior qualidade e o maior risco do Pages CMS.\nProjeto de um único desenvolvedor O Pages CMS não tem uma equipe, não tem funding público conhecido, e não tem uma organização sustentando o desenvolvimento. Isso não é uma crítica — muitos dos melhores softwares open source começaram exatamente assim. Mas significa que a velocidade de correção de bugs, o ritmo de novas funcionalidades, e a própria continuidade do projeto dependem da disponibilidade e do interesse de uma única pessoa. Se você acompanhar o repositório no GitHub, vai ver períodos de atividade intensa seguidos de períodos mais silenciosos, o que é perfeitamente normal para um projeto mantido no tempo livre de alguém.\nPara um blog pessoal, isso é um risco aceitável. Para um site que uma empresa depende para publicar conteúdo regularmente, é o tipo de fragilidade que merece pelo menos um plano B.\nDependência total da API do GitHub (e seus rate limits) O Pages CMS não tem banco de dados próprio para conteúdo. Ele lê e escreve diretamente nos arquivos do seu repositório GitHub usando a API do GitHub. Isso é elegante — seu conteúdo nunca sai do repositório, não existe sincronização para quebrar, e qualquer mudança feita pelo CMS é um commit normal que aparece no histórico do Git. Mas também significa que toda operação no CMS está sujeita aos rate limits da API do GitHub: 5.000 requests por hora para usuários autenticados. Na prática, editar posts e fazer upload de imagens em um blog pessoal não chega perto desse limite. O cenário problemático é o de um time com vários editores trabalhando simultaneamente em um repositório com muitos arquivos, onde a navegação e o cache do CMS podem gerar um volume de chamadas à API que começa a encontrar throttling.\nSem suporte comercial nem SLA Se algo quebrar no Pages CMS — e software quebra — não existe um canal de suporte para abrir um ticket e esperar uma resposta com prazo definido. O que existe é a issue queue do GitHub do projeto, onde você pode reportar o problema e torcer para que a comunidade ou o mantenedor responda. Para quem está acostumado com o ecossistema open source, isso é o padrão e funciona razoavelmente bem. Para quem vem do mundo de SaaS com SLA e suporte por chat, a diferença de expectativa pode ser frustrante. Não existe garantia de uptime para a instância hospedada em app.pagescms.org, e não existe ninguém de plantão se ela sair do ar em um domingo à noite.\nO que acontece se o projeto for abandonado Essa é a pergunta que ninguém gosta de fazer sobre projetos open source que usa, mas que todo adulto responsável deveria considerar. Se o mantenedor do Pages CMS decidir parar de manter o projeto amanhã, seu conteúdo continua exatamente onde sempre esteve: no seu repositório GitHub, em arquivos Markdown com front matter YAML. Você não perde nada. O que você perde é a interface de edição — e aí volta a editar conteúdo direto no GitHub ou no editor de texto local, o que não é o fim do mundo, mas é exatamente o inconveniente que o CMS existia para resolver. A licença MIT garante que qualquer pessoa pode fazer um fork e continuar o desenvolvimento, mas entre o abandono de um projeto e o surgimento de um fork viável costuma existir um período de limbo que pode durar meses.\nO fato de o conteúdo ser portável — Markdown puro em um repositório Git — é a melhor proteção que essa arquitetura oferece. Você nunca fica preso a um CMS específico, e migrar para qualquer alternativa é uma questão de reconfigurar a ferramenta de edição, não de exportar dados de um banco de dados proprietário.\nQuando esses limites realmente importam Listar limites fora de contexto assusta mais do que deveria. Quinhentos builds, vinte mil arquivos, dois mil minutos — são números que parecem restritivos até você colocar um caso de uso real do lado e perceber que a maioria das pessoas nunca vai chegar perto deles. O que importa não é o número absoluto, mas a relação entre o seu uso e o teto disponível.\nO blogueiro solo provavelmente nunca vai esbarrar neles Um blog pessoal com um autor publicando alguns posts por mês é o cenário para o qual essa stack gratuita foi praticamente desenhada. Faça as contas: se você publica três vezes por semana e faz um push por post, são cerca de 12 builds por mês — 2,4% da cota da Cloudflare. O repositório de um blog com quinhentos posts em Markdown, imagens otimizadas e o tema Hugo inteiro dificilmente passa de 200 MB. As chamadas à API do GitHub pelo Pages CMS para editar um post e subir uma imagem cabem confortavelmente em uma fração mínima do rate limit de 5.000 requests por hora. Nesse cenário, os limites são tão distantes que funcionam como se não existissem.\nMúltiplos sites na mesma conta mudam a equação O cálculo muda quando você começa a acumular projetos. A cota de 500 builds da Cloudflare Pages é por conta, então cinco sites com 100 builds cada já esgotam o mês. O mesmo vale para os minutos de GitHub Actions em repos privados — os 2.000 minutos são compartilhados entre todos os repositórios da conta. Se você é o tipo de pessoa que gosta de manter um blog pessoal, um site de portfólio, uma landing page para um projeto paralelo e a documentação de uma ferramenta que escreveu, cada projeto sozinho é leve, mas a soma pode começar a pressionar os limites.\nA solução mais simples é manter projetos em contas separadas quando faz sentido, ou aceitar que a consolidação tem um custo em termos de cota. Outra abordagem é reduzir builds desnecessários: desabilitar deploy automático de branches que não precisam de preview, acumular mudanças em menos commits, e configurar ignore patterns no Cloudflare Pages para que pushes que não alterem o conteúdo do site não disparem builds.\nTimes com vários editores e deploys frequentes O cenário onde os limites começam a morder de verdade é o de um time — mesmo pequeno — com múltiplas pessoas editando conteúdo e fazendo deploy ao longo do dia. Cada salvamento no Pages CMS é um commit, cada commit em uma branch conectada é um build. Três editores publicando e revisando conteúdo ativamente podem gerar dezenas de builds por dia sem perceber. O build concorrente único do plano gratuito da Cloudflare significa que essas builds entram em fila, e a cota mensal de 500 começa a parecer menos confortável.\nNo lado do Pages CMS, múltiplos editores navegando e editando simultaneamente multiplicam as chamadas à API do GitHub. O rate limit de 5.000 requests por hora é por token de autenticação, então na prática cada editor tem sua própria cota — mas se o time compartilha uma mesma instalação com um único GitHub App, o limite é compartilhado. Além disso, repositórios com muitos arquivos fazem o CMS trabalhar mais para carregar e cachear a estrutura de diretórios, o que pode resultar em uma experiência mais lenta e em bugs de cache que já foram reportados na issue queue do projeto.\nPara times nesse perfil, a stack gratuita ainda funciona, mas exige mais atenção ao workflow. E é exatamente o ponto em que faz sentido avaliar se um plano pago em uma das pontas — Cloudflare Pro para mais builds, ou um CMS com suporte comercial — não se paga em tempo e frustração economizados.\nAlternativas em alto nível Se você chegou até aqui e concluiu que algum limite da stack gratuita é relevante para o seu caso, a boa notícia é que cada peça pode ser substituída de forma independente. A arquitetura de site estático com repositório Git é modular por natureza — você pode trocar o hosting sem trocar o CMS, trocar o repositório sem trocar o gerador, e assim por diante. O que segue não é uma análise detalhada de cada alternativa, mas um mapa de opções para que você saiba onde procurar.\nPara o repositório: GitLab, Codeberg, Gitea self-hosted Se os limites do GitHub Free são o problema — tamanho de repositório, minutos de CI, ou simplesmente uma preferência por não depender de uma plataforma específica — existem alternativas maduras. O GitLab oferece repositórios privados ilimitados com 400 minutos de CI/CD por mês no plano gratuito e tem seu próprio sistema de Pages para hosting estático. O Codeberg é uma alternativa sem fins lucrativos, baseado no Forgejo, sem limites artificiais de CI e com uma filosofia explicitamente voltada para software livre. Para quem quer controle total, o Gitea ou o Forgejo podem ser instalados em qualquer servidor próprio — um VPS barato é mais do que suficiente para hospedar repositórios Git com CI integrado.\nA principal consideração ao trocar de plataforma Git é o efeito cascata sobre o resto da stack. O Pages CMS depende especificamente da API do GitHub, então trocar o repositório para o GitLab significa trocar também o CMS. A Cloudflare Pages se integra nativamente com GitHub e GitLab, então essa parte da migração é direta.\nPara o hosting/deploy: Netlify, Vercel, GitHub Pages, Caddy/Nginx + CI próprio A Cloudflare Pages não é a única opção para hospedar sites estáticos com deploy automático a partir de um repositório Git. O Netlify oferece um plano gratuito com 300 minutos de build por mês e 100 GB de bandwidth — menos generoso que a Cloudflare em banda, mas com um ecossistema de plugins e funcionalidades como formulários e identity que podem simplificar certos casos de uso. O Vercel tem um plano gratuito voltado para frameworks JavaScript, mas funciona perfeitamente com Hugo e oferece 100 GB de banda por mês. O próprio GitHub Pages é uma opção sólida para sites Jekyll e Hugo, com bandwidth generoso e deploy direto do repositório — a limitação principal é a ausência de build customizado sem GitHub Actions e o suporte mais limitado a configurações avançadas de roteamento.\nPara quem quer sair completamente de plataformas gerenciadas, a combinação de um servidor próprio com Caddy ou Nginx, um CI leve como Drone ou Woodpecker, e um deploy via rsync ou webhook oferece controle absoluto. O custo é um VPS de cinco ou dez dólares por mês e o tempo de configuração e manutenção — que para um sysadmin é trivial, mas para quem montou o blog seguindo um tutorial pode ser um salto de complexidade considerável.\nPara o CMS: Decap CMS, Sveltia CMS, TinaCMS, Publii O Pages CMS é uma das várias opções de CMS para sites estáticos baseados em Git, e se os riscos de depender de um projeto de um único mantenedor não te agradam, existem alternativas com perfis diferentes de maturidade e suporte.\nO Decap CMS, antigo Netlify CMS, é o veterano do espaço. É open source, funciona como uma single-page app que roda no próprio site em uma rota /admin, e commita diretamente no repositório. Tem uma comunidade grande e é o mais testado em produção, mas o desenvolvimento desacelerou nos últimos anos e a experiência de edição não é das mais modernas. O Sveltia CMS se propõe a ser um substituto drop-in para o Decap com uma interface mais rápida e polida, mantendo compatibilidade com a mesma configuração — vale a pena como alternativa direta se o Decap funciona para o seu fluxo mas a interface te incomoda.\nO TinaCMS é a opção mais ambiciosa do grupo. Oferece edição visual em tempo real no próprio site, suporta Markdown e MDX, e tem tanto uma versão self-hosted quanto um serviço cloud com plano gratuito. O trade-off é a complexidade de setup, que é significativamente maior do que o Pages CMS, e uma dependência mais forte do ecossistema JavaScript e de frameworks como Next.js — embora funcione com Hugo, a integração não é tão direta.\nO Publii segue uma filosofia completamente diferente: é um aplicativo desktop para Windows, Mac e Linux que funciona como um CMS local. Você edita o conteúdo na sua máquina, o Publii gera o site estático e faz o deploy para GitHub Pages, Netlify, S3 ou qualquer servidor via FTP. Não depende de nenhuma API, não precisa de internet para editar, e elimina toda a camada de CMS web. A desvantagem é que o conteúdo vive na máquina onde o Publii está instalado, e o workflow colaborativo com múltiplos editores não é o ponto forte — na verdade é praticamente impossível de conseguir manter um blog com múltiplos autores no Publii.\nEm todos os casos, o fato de o conteúdo ser Markdown com front matter em um repositório Git significa que a migração entre essas ferramentas é uma questão de reconfiguração, não de conversão de dados. Você escolheu uma arquitetura onde o conteúdo é independente da ferramenta de edição, e essa é a decisão mais valiosa de toda a stack.\nMinha opinião: vale começar com tudo grátis Depois de listar limites, riscos e alternativas, seria fácil terminar este post com uma recomendação de cautela — algo como \u0026ldquo;avalie bem antes de escolher\u0026rdquo; ou \u0026ldquo;cada caso é um caso\u0026rdquo;. Mas a verdade é mais simples do que isso: para quem está começando um blog ou site pessoal, a stack gratuita com Hugo, GitHub, Cloudflare Pages e Pages CMS é a melhor escolha disponível hoje, e não é uma escolha de compromisso.\nOs limites existem, mas são desenhados para um volume de uso que a grande maioria dos sites pessoais nunca vai atingir. O bandwidth ilimitado da Cloudflare elimina a preocupação mais comum de quem hospeda conteúdo próprio. Os 500 builds mensais são mais do que suficientes para qualquer ritmo razoável de publicação. O GitHub Free oferece tudo que um repositório de blog precisa sem cobrar nada. E o Pages CMS, apesar dos riscos inerentes a um projeto de um único mantenedor, resolve o problema real de editar conteúdo sem precisar abrir um terminal.\nO mais importante é que essa stack não te prende. Seu conteúdo é Markdown em um repositório Git — o formato mais portável que existe para texto na web. Se amanhã o Pages CMS for abandonado, você troca de CMS. Se a Cloudflare mudar os termos do plano gratuito, você move o site para o Netlify em uma tarde. Se o GitHub fizer algo que te desagrade, seus arquivos estão no seu disco local e podem ir para qualquer outro serviço Git com um git remote set-url. Cada decisão nessa arquitetura é reversível, e isso vale mais do que qualquer garantia de SLA.\nComece com tudo grátis. Publique. Escreva. Quando — e se — algum limite se tornar real no seu uso concreto, você vai saber exatamente qual peça trocar e por quê, porque o problema vai ser específico e não hipotético. Otimizar antes de ter um problema é engenharia prematura, e engenharia prematura é o jeito mais eficiente de nunca publicar nada.\nSe uma das lacunas que você sentir primeiro for a falta de comentários nativos, já escrevi sobre como resolver isso com uma solução self-hosted leve e sem rastreamento.\n","date":"24/03/2026","lang":"pt","tags":["segurança","self-hosted","github","cloudflare","hugo","devops","limites","alternativas"],"title":"O Lado B do Site Grátis: Limites e Alternativas para Hugo + GitHub + Cloudflare Pages + Pages CMS","url":"https://devops.sarmento.org/posts/o-lado-b-do-site-gratis-limites-e-alternativas-para-hugo-github-cloudflare-pages-pages-cms/"},{"categories":["macOS"],"content":"No post anterior eu mostrei como os systemd timers substituem o cron em servidores Debian e Ubuntu com vantagens concretas: logging integrado, recuperação de execuções perdidas, dependências declarativas e controle de recursos. A lógica é convincente e a migração é direta — desde que você esteja num sistema que roda systemd. Mas se o seu dia a dia inclui um Mac, a história é outra.\nO macOS tem seu próprio sistema de agendamento, anterior ao systemd e com uma filosofia diferente. Ele se chama launchd, existe desde o Mac OS X Tiger em 2005, e é responsável por praticamente tudo que roda em background no sistema — desde serviços internos da Apple até aquele updater do Spotify que você nunca pediu para instalar. Apesar de ser a forma oficial e recomendada de agendar tarefas no Mac, o launchd vive numa espécie de ponto cego: quem vem do Linux tende a procurar o cron por reflexo, e quem usa o Mac sem background em administração de sistemas nem sabe que a possibilidade existe.\nEste post mostra como usar o launchd para agendar um comando que roda todos os dias às 7h da manhã no meu Mac — o fuqu telegram, que gera um briefing diário das minhas tarefas e envia para o Telegram. O caminho serve como modelo para qualquer script ou comando que você queira agendar de forma confiável sem depender de terminal aberto, sem cron e sem aplicativos de terceiros.\nO cron funciona no Mac. Mas não é assim que se faz. O cron existe no macOS. Você pode abrir o Terminal, digitar crontab -e e agendar um job exatamente como faria num servidor Linux. O daemon está lá, funciona, e ninguém removeu. Então por que não usar?\nO primeiro motivo é que a Apple considera o cron deprecado em favor do launchd desde 2005. O daemon continua presente por compatibilidade, mas não recebe melhorias, não se integra com os mecanismos modernos do sistema e não aparece em nenhuma documentação oficial como a forma recomendada de agendar tarefas. Na prática, ele funciona até o dia em que uma atualização do macOS muda alguma coisa e ele para de funcionar — e quando isso acontecer, não vai ter fix porque o cron não é prioridade da Apple há duas décadas.\nO segundo motivo é mais imediato: o cron no macOS não sabe lidar com a forma como um Mac realmente opera. Macs dormem, fecham a tampa, ficam desligados de noite e ligam de manhã. Se o cron job estava agendado para as 3h e o Mac estava dormindo às 3h, a execução se perde sem aviso — o mesmo problema que o cron tem no Linux, mas agravado pelo fato de que um laptop pessoal fica desligado ou em sleep com muito mais frequência do que um servidor. O launchd tem mecanismo nativo para detectar execuções perdidas e dispará-las assim que o sistema acordar, o que por si só já justifica a troca para qualquer tarefa que precisa rodar diariamente de forma confiável.\nO terceiro motivo é o ambiente de execução. O cron no macOS roda com um PATH mínimo e sem acesso ao ambiente do seu shell. Scripts que dependem de binários instalados pelo Homebrew em /opt/homebrew/bin, de ambientes virtuais Python ou de variáveis de ambiente configuradas no .zshrc simplesmente não encontram o que precisam quando executados via cron. A solução clássica — colocar o PATH completo na primeira linha do crontab ou encapsular tudo num wrapper que faz source do profile — funciona, mas é o tipo de gambiarra que você precisa lembrar que existe a cada novo script que adiciona. O launchd oferece uma forma declarativa de definir variáveis de ambiente por job, sem depender do shell do usuário.\nNada disso significa que o cron vai explodir se você usá-lo no Mac. Para um script simples que roda enquanto o laptop está aberto e acordado, ele faz o trabalho. Mas se você quer agendamento que sobrevive ao sleep, com logging previsível e integração com o sistema operacional, o launchd é a ferramenta certa — e é a que a Apple espera que você use.\nlaunchd: o systemd do macOS LaunchDaemons vs. LaunchAgents O launchd é o processo de PID 1 do macOS — o primeiro processo que o kernel inicia e o pai de todos os outros. Nesse sentido, ele ocupa exatamente o mesmo papel que o systemd no Linux: gerencia serviços, controla dependências de inicialização e cuida do ciclo de vida de tudo que roda em background. A diferença é que o launchd faz isso desde 2005, quase uma década antes de o systemd se tornar padrão nas distribuições Linux.\nAs tarefas gerenciadas pelo launchd se dividem em duas categorias que é preciso entender antes de criar qualquer coisa: LaunchDaemons e LaunchAgents.\nLaunchDaemons são serviços de sistema. Rodam como root, iniciam durante o boot antes de qualquer usuário fazer login e não têm acesso à sessão gráfica. Seus arquivos plist ficam em /Library/LaunchDaemons/ (para daemons de terceiros) ou em /System/Library/LaunchDaemons/ (para os da Apple, que você não deve tocar). São o equivalente dos serviços que no Linux você colocaria em /etc/systemd/system/ — backups de nível de sistema, serviços de rede, monitoramento de hardware.\nLaunchAgents são tarefas de usuário. Rodam com as permissões do usuário logado, iniciam quando o usuário faz login e têm acesso ao ambiente gráfico da sessão. Existem em dois lugares: /Library/LaunchAgents/ para agents instalados por aplicativos de terceiros que se aplicam a todos os usuários, e ~/Library/LaunchAgents/ para agents pessoais que pertencem exclusivamente à sua conta. Esse último diretório é onde vai morar tudo que você criar para uso próprio — e é o equivalente mais próximo dos user timers do systemd em ~/.config/systemd/user/, com uma vantagem prática: no macOS, LaunchAgents pessoais funcionam automaticamente enquanto o usuário está logado, sem precisar de nenhum equivalente a loginctl enable-linger.\nPara o caso de agendar um comando pessoal como o fuqu telegram, a escolha é direta: LaunchAgent em ~/Library/LaunchAgents/. Não precisa de root para criar, não precisa de root para carregar, e roda no contexto da sua sessão de usuário.\nO formato plist e a lógica de agendamento Enquanto o systemd usa arquivos .ini com seções e diretivas, e o cron usa uma linha de texto com cinco campos, o launchd usa arquivos plist — property lists em formato XML. A reação inicial de quem vê um plist pela primeira vez costuma ser de estranhamento: é verboso, cheio de tags, e parece desproporcional para o que está fazendo. Mas o formato é o padrão que a Apple usa para configuração em todo o sistema operacional, e depois de escrever o primeiro arquivo o padrão fica previsível.\nUm plist de LaunchAgent é um dicionário XML com chaves que descrevem o que executar, quando executar e como lidar com a saída. As chaves obrigatórias são apenas duas: Label, que é o identificador único do agent (por convenção, no formato de domínio reverso como com.janio.fuqu-telegram), e ProgramArguments, que é um array com o comando e seus argumentos. O mínimo necessário para definir uma tarefa cabe em poucas linhas — toda a cerimônia do XML é sintática, não conceitual.\nO agendamento se configura com a chave StartCalendarInterval, que aceita um dicionário com campos para hora, minuto, dia do mês, mês e dia da semana. A lógica é similar ao cron: você especifica apenas os campos que importam e os demais assumem o valor \u0026ldquo;qualquer\u0026rdquo;. Um dicionário com apenas Hour e Minute definidos significa \u0026ldquo;todo dia nesse horário\u0026rdquo; — o equivalente direto de 0 7 * * * no cron ou *-*-* 07:00:00 no OnCalendar do systemd.\nA diferença que importa na prática é o comportamento quando o Mac está dormindo ou desligado no horário agendado. O launchd detecta que a execução foi perdida e a dispara assim que o sistema acorda ou reinicia. Esse comportamento é o padrão — não precisa de nenhuma diretiva equivalente ao Persistent=true do systemd. Se o agent está carregado e o horário já passou, a execução acontece na próxima oportunidade. Para quem agenda tarefas num laptop que não fica ligado 24 horas, essa garantia sozinha vale a transição do cron para o launchd.\nOutras chaves úteis que aparecerão no exemplo prático incluem StandardOutPath e StandardErrorPath para direcionar a saída para arquivos de log, EnvironmentVariables para definir variáveis de ambiente disponíveis durante a execução, e WorkingDirectory para definir o diretório de trabalho do processo. Tudo declarativo, tudo no mesmo arquivo, sem precisar de wrappers ou source de profiles.\nCaso prático: fuqu telegram às 7h da manhã O que o comando faz O FUQU é um gerenciador de tarefas pessoal que roda no terminal, construído em Python com Textual e SQLite. Entre outras coisas, ele tem um subcomando telegram que gera um briefing diário — um resumo das tarefas pendentes, atrasadas e agendadas para o dia — e envia para um bot do Telegram. O briefing é gerado por um modelo de linguagem (LM Studio local com fallback para a API do Cerebras), formatado em Markdown e entregue como mensagem no chat do bot. O resultado é que todo dia às 7h da manhã, antes de eu abrir o laptop, o resumo do dia já está esperando no Telegram do meu celular.\nNo terminal, o comando que faz isso acontecer é:\ncd ~/Dropbox/fuqu \u0026amp;\u0026amp; .venv/bin/python -m fuqu telegram O projeto vive em ~/Dropbox/fuqu, usa um virtualenv local em .venv e é executado como módulo Python. O cd para o diretório do projeto é necessário porque o FUQU resolve o caminho do banco de dados SQLite relativamente ao diretório de trabalho. Essa combinação de cd + caminho absoluto do interpretador Python dentro do venv é o tipo de coisa que funciona perfeitamente no shell interativo mas quebra quando executada fora do contexto do usuário — que é exatamente o que acontece num LaunchAgent.\nO wrapper script O launchd não executa comandos através do shell. Ele chama o binário diretamente, sem passar por .zshrc, sem expandir aliases, sem herdar o PATH da sessão interativa. Por isso, a abordagem mais limpa é encapsular o comando num script de shell dedicado que cuida do ambiente de execução.\n#!/bin/bash cd ~/Dropbox/fuqu || exit 1 .venv/bin/python -m fuqu telegram O script faz duas coisas: muda para o diretório do projeto (abortando se o diretório não existir) e executa o comando com o caminho absoluto do Python dentro do virtualenv. Não depende de alias, não depende de PATH, não depende de nada que exista apenas na sessão interativa do shell.\nO arquivo pode ficar em qualquer lugar razoável. Eu mantenho scripts pessoais desse tipo em ~/bin/, que é um diretório que já existe no meu PATH interativo mas que aqui não importa — o plist vai referenciar o caminho absoluto de qualquer forma:\nmkdir -p ~/bin cat \u0026gt; ~/bin/fuqu-telegram.sh \u0026lt;\u0026lt; \u0026#39;EOF\u0026#39; #!/bin/bash cd ~/Dropbox/fuqu || exit 1 .venv/bin/python -m fuqu telegram EOF chmod +x ~/bin/fuqu-telegram.sh Antes de envolver o launchd, vale testar o script isoladamente para garantir que ele funciona fora do contexto do shell interativo:\n/usr/bin/env -i HOME=\u0026#34;$HOME\u0026#34; ~/bin/fuqu-telegram.sh O env -i limpa todas as variáveis de ambiente, simulando o ambiente mínimo que o launchd vai fornecer. Se o briefing chegar no Telegram, o script está pronto.\nO arquivo plist O LaunchAgent é um arquivo XML em ~/Library/LaunchAgents/. Por convenção, o nome segue o formato de domínio reverso — no meu caso, com.janio.fuqu-telegram.plist:\n\u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34;?\u0026gt; \u0026lt;!DOCTYPE plist PUBLIC \u0026#34;-//Apple//DTD PLIST 1.0//EN\u0026#34; \u0026#34;http://www.apple.com/DTDs/PropertyList-1.0.dtd\u0026#34;\u0026gt; \u0026lt;plist version=\u0026#34;1.0\u0026#34;\u0026gt; \u0026lt;dict\u0026gt; \u0026lt;key\u0026gt;Label\u0026lt;/key\u0026gt; \u0026lt;string\u0026gt;com.janio.fuqu-telegram\u0026lt;/string\u0026gt; \u0026lt;key\u0026gt;ProgramArguments\u0026lt;/key\u0026gt; \u0026lt;array\u0026gt; \u0026lt;string\u0026gt;/Users/janiosarmento/bin/fuqu-telegram.sh\u0026lt;/string\u0026gt; \u0026lt;/array\u0026gt; \u0026lt;key\u0026gt;StartCalendarInterval\u0026lt;/key\u0026gt; \u0026lt;dict\u0026gt; \u0026lt;key\u0026gt;Hour\u0026lt;/key\u0026gt; \u0026lt;integer\u0026gt;7\u0026lt;/integer\u0026gt; \u0026lt;key\u0026gt;Minute\u0026lt;/key\u0026gt; \u0026lt;integer\u0026gt;0\u0026lt;/integer\u0026gt; \u0026lt;/dict\u0026gt; \u0026lt;key\u0026gt;StandardOutPath\u0026lt;/key\u0026gt; \u0026lt;string\u0026gt;/Users/janiosarmento/.local/log/fuqu-telegram.out.log\u0026lt;/string\u0026gt; \u0026lt;key\u0026gt;StandardErrorPath\u0026lt;/key\u0026gt; \u0026lt;string\u0026gt;/Users/janiosarmento/.local/log/fuqu-telegram.err.log\u0026lt;/string\u0026gt; \u0026lt;key\u0026gt;WorkingDirectory\u0026lt;/key\u0026gt; \u0026lt;string\u0026gt;/Users/janiosarmento/Dropbox/fuqu\u0026lt;/string\u0026gt; \u0026lt;/dict\u0026gt; \u0026lt;/plist\u0026gt; Algumas observações sobre cada chave:\nO Label é o identificador único do agent no launchd. Precisa ser idêntico ao nome do arquivo sem a extensão .plist. É o que aparece na saída do launchctl list e o que você usa para interagir com o agent via linha de comando.\nO ProgramArguments recebe um array com o caminho absoluto do script. Caminhos com ~ não são expandidos pelo launchd — sempre use o caminho completo a partir de /Users/. Se o comando precisasse de argumentos adicionais, cada um seria um \u0026lt;string\u0026gt; separado dentro do array.\nO StartCalendarInterval com apenas Hour e Minute definidos dispara o agent todo dia às 7h00. Omitir os campos de dia, mês e dia da semana equivale a colocar * em cada um deles — o padrão é \u0026ldquo;qualquer valor\u0026rdquo;. Se o Mac estiver dormindo às 7h, o launchd executa o agent assim que o sistema acordar.\nOs caminhos de StandardOutPath e StandardErrorPath direcionam a saída para arquivos de log. O diretório precisa existir antes da primeira execução — o launchd não cria diretórios automaticamente. Sem essas chaves, toda saída do script desaparece silenciosamente.\nO WorkingDirectory é redundante com o cd no wrapper script, mas serve como uma garantia adicional. Se por algum motivo o cd dentro do script falhar de forma inesperada, o processo ainda estará no diretório correto.\nAntes de carregar o agent, crie o diretório de logs:\nmkdir -p ~/.local/log Carregando e testando Com o plist no lugar e o diretório de logs criado, o agent precisa ser registrado no launchd. O macOS oferece dois conjuntos de comandos para isso: os antigos launchctl load e launchctl unload, e os mais recentes launchctl bootstrap e launchctl bootout. Os comandos antigos ainda funcionam e são mais simples, então é o que eu uso:\nlaunchctl load ~/Library/LaunchAgents/com.janio.fuqu-telegram.plist Para verificar que o agent foi carregado:\nlaunchctl list | grep fuqu A saída mostra três colunas: o PID (um - se o agent não está rodando neste momento), o último código de saída (0 para sucesso) e o label. Ver o label na lista confirma que o launchd reconheceu o agent e está monitorando o agendamento.\nPara testar sem esperar as 7h da manhã, você pode disparar o agent manualmente:\nlaunchctl start com.janio.fuqu-telegram O comando retorna imediatamente — a execução acontece em background. Para ver o resultado, verifique o log de saída:\ncat ~/.local/log/fuqu-telegram.out.log E o log de erro, que estará vazio se tudo correu bem:\ncat ~/.local/log/fuqu-telegram.err.log Se o briefing chegou no Telegram e os logs não mostram erros, o agent está funcionando. A partir de agora, todo dia às 7h — ou no momento em que você abrir o laptop se ele estava dormindo às 7h — o resumo das tarefas do dia vai aparecer no Telegram automaticamente, sem terminal aberto, sem intervenção manual.\nSe precisar alterar o horário ou qualquer outra configuração, o ciclo é: descarregar o agent, editar o plist, recarregar:\nlaunchctl unload ~/Library/LaunchAgents/com.janio.fuqu-telegram.plist # ... editar o arquivo ... launchctl load ~/Library/LaunchAgents/com.janio.fuqu-telegram.plist Para remover o agent permanentemente, basta descarregá-lo e apagar o arquivo plist. Sem o arquivo em ~/Library/LaunchAgents/, ele não será carregado no próximo login.\nO que o launchd resolve de graça StartCalendarInterval e execuções perdidas No post sobre systemd timers, a diretiva Persistent=true foi apresentada como uma vantagem concreta sobre o cron: se o sistema estava desligado no horário agendado, o timer dispara a execução assim que possível após o boot. No launchd, esse comportamento não precisa ser habilitado — ele é o padrão. Se o Mac estava dormindo ou desligado às 7h, o agent roda assim que o sistema acorda, sem nenhuma chave adicional no plist.\nO mecanismo é simples: o launchd compara o StartCalendarInterval com o horário atual e com o timestamp da última execução. Se detecta que um ou mais intervalos foram perdidos, dispara o agent imediatamente. Diferente do systemd, que grava o timestamp da última execução em disco explicitamente quando Persistent=true está ativo, o launchd faz esse rastreamento internamente como parte do seu funcionamento normal.\nNa prática, isso significa que o briefing das 7h da manhã chega no Telegram mesmo que eu só abra o laptop às 9h. O atraso é o tempo que o sistema leva para acordar e carregar os LaunchAgents — questão de segundos. Para tarefas diárias como essa, o comportamento é exatamente o que se espera: a execução acontece uma vez por dia, no horário definido ou na primeira oportunidade depois dele, sem duplicação e sem perda.\nExiste uma sutileza que vale conhecer: se o Mac ficou desligado por vários dias, o launchd dispara o agent apenas uma vez ao acordar, não uma vez para cada dia perdido. Para um briefing diário isso é o comportamento correto — não faz sentido receber cinco briefings atrasados de uma vez. Mas para tarefas onde cada execução perdida importa individualmente, como rotações de log ou coletas de métricas, esse comportamento precisa ser levado em conta.\nLogging com stdout e stderr O cron no macOS herda o mesmo problema de logging que tem no Linux: a saída dos jobs vai para o email local do usuário via o MTA do sistema, que na maioria dos Macs pessoais não está configurado. O resultado é que a saída simplesmente desaparece, e descobrir se um job rodou — e o que ele fez — vira um exercício de adivinhação.\nAs chaves StandardOutPath e StandardErrorPath no plist resolvem isso de forma direta. Toda a saída padrão do processo vai para um arquivo, toda a saída de erro vai para outro, ambos com append automático. Não precisa de redirecionamento no script, não precisa de logger, não precisa de nada além das duas linhas no plist.\nA separação entre stdout e stderr é útil na prática. O arquivo de saída contém o resultado normal do comando — no caso do fuqu telegram, a confirmação de que o briefing foi gerado e enviado. O arquivo de erro captura exceções Python, stack traces, problemas de conexão com a API do LLM ou do Telegram, e qualquer outro diagnóstico que o comando escreva em stderr. Quando tudo funciona, o arquivo de erro fica vazio, e o fato de ele ter conteúdo já é por si só um sinal de que algo precisa de atenção.\nUma limitação em relação ao systemd é que o launchd não tem um equivalente ao journald. Os logs são arquivos de texto simples que crescem indefinidamente. Cabe a você gerenciar a rotação — seja com um segundo LaunchAgent que trunca ou rotaciona os arquivos periodicamente, seja com o newsyslog que o macOS inclui nativamente e que pode ser configurado para rotacionar qualquer arquivo de log. Para tarefas que rodam uma vez por dia e produzem poucas linhas de saída, o crescimento é negligível e a rotação pode ser algo que você resolve uma vez por ano apagando manualmente os arquivos antigos. Para tarefas mais verbosas ou frequentes, vale configurar rotação desde o início.\nVariáveis de ambiente e PATH Quando você abre o Terminal no Mac e digita um comando, o shell carrega o .zshrc (ou .bash_profile, dependendo da configuração), define o PATH com os diretórios do Homebrew, exporta variáveis de ambiente personalizadas e configura tudo que o seu fluxo de trabalho precisa. Nenhuma dessas coisas existe no contexto de um LaunchAgent. O launchd executa o processo com um ambiente mínimo: HOME, USER, TMPDIR e pouco mais. O PATH padrão contém apenas /usr/bin:/bin:/usr/sbin:/sbin — o Homebrew em /opt/homebrew/bin, ferramentas instaladas via pip ou cargo em ~/.local/bin, e qualquer outro diretório personalizado simplesmente não existem.\nO wrapper script contorna esse problema para o caso específico do FUQU porque usa o caminho absoluto do Python dentro do virtualenv, sem depender do PATH para nada. Mas para agents que chamam binários do Homebrew ou dependem de variáveis de ambiente específicas, o plist oferece a chave EnvironmentVariables:\n\u0026lt;key\u0026gt;EnvironmentVariables\u0026lt;/key\u0026gt; \u0026lt;dict\u0026gt; \u0026lt;key\u0026gt;PATH\u0026lt;/key\u0026gt; \u0026lt;string\u0026gt;/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin\u0026lt;/string\u0026gt; \u0026lt;key\u0026gt;LANG\u0026lt;/key\u0026gt; \u0026lt;string\u0026gt;en_US.UTF-8\u0026lt;/string\u0026gt; \u0026lt;/dict\u0026gt; Cada variável é um par chave-valor dentro do dicionário. O PATH pode ser estendido para incluir qualquer diretório necessário, e variáveis como LANG garantem que a codificação de caracteres se comporte da mesma forma que no terminal interativo — algo que importa quando o comando produz saída com acentos ou caracteres especiais.\nA abordagem declarativa tem uma vantagem sobre o padrão do cron de colocar PATH=... no topo do crontab: cada agent tem seu próprio conjunto de variáveis, isolado dos demais. Um agent que precisa de uma versão específica do Python pode apontar para um PATH diferente de outro que usa Node, sem conflito. No cron, o PATH definido no crontab vale para todos os jobs daquele usuário.\nTroubleshooting: quando o agent não roda launchctl e os comandos que você vai usar O launchctl é a interface de linha de comando para o launchd, e na prática você vai usar um punhado de subcomandos repetidamente. Vale tê-los à mão porque a página de manual do launchctl é extensa e nem sempre amigável.\nPara ver todos os agents carregados na sua sessão de usuário:\nlaunchctl list A saída tem três colunas: PID, último código de saída e label. Um PID com - significa que o agent não está rodando neste momento, o que é normal para agents baseados em StartCalendarInterval que executam e terminam. O código de saída 0 indica sucesso na última execução; qualquer outro número aponta para um problema.\nPara verificar o status de um agent específico sem filtrar com grep:\nlaunchctl print gui/$(id -u)/com.janio.fuqu-telegram O subcomando print mostra informações detalhadas: o estado atual do agent, o caminho do plist, o último código de saída, os caminhos de log e as condições de agendamento. O prefixo gui/$(id -u) identifica o domínio do usuário logado — é o equivalente a dizer \u0026ldquo;o agent que pertence a mim, não ao sistema\u0026rdquo;.\nPara disparar uma execução manual fora do horário agendado:\nlaunchctl start com.janio.fuqu-telegram Para descarregar um agent (remove do launchd mas não apaga o arquivo):\nlaunchctl unload ~/Library/LaunchAgents/com.janio.fuqu-telegram.plist Para recarregar depois de editar o plist:\nlaunchctl unload ~/Library/LaunchAgents/com.janio.fuqu-telegram.plist launchctl load ~/Library/LaunchAgents/com.janio.fuqu-telegram.plist Não existe um launchctl reload. Descarregar e carregar de novo é o fluxo padrão, e precisa ser feito nessa ordem — tentar carregar um agent que já está carregado resulta em erro.\nErros comuns e onde procurar O erro mais frequente ao criar um LaunchAgent pela primeira vez é o agent simplesmente não fazer nada. Nenhuma mensagem, nenhum log, nenhum sinal de vida. O diagnóstico segue uma sequência previsível.\nO primeiro passo é confirmar que o agent está carregado. Se launchctl list | grep fuqu não retorna nada, o launchd não sabe que o agent existe. As causas mais comuns são o arquivo plist fora do diretório correto (precisa estar em ~/Library/LaunchAgents/, não em ~/LaunchAgents/ nem em nenhuma outra variação), permissões incorretas no arquivo (deve pertencer ao seu usuário, com permissão 644), ou XML malformado. O plutil valida a sintaxe do plist sem carregá-lo:\nplutil -lint ~/Library/LaunchAgents/com.janio.fuqu-telegram.plist Se o plutil reportar erro, o problema é estrutural no XML — uma tag não fechada, um tipo errado, uma chave fora do dicionário principal. O plutil indica a linha e a natureza do erro, o que costuma ser suficiente para encontrar o problema.\nO segundo passo, se o agent está carregado mas o código de saída não é zero, é olhar os logs. Se StandardOutPath e StandardErrorPath estão configurados no plist, o conteúdo do arquivo de erro geralmente revela o problema de imediato. Se os arquivos de log existem mas estão vazios, o processo pode estar terminando antes de produzir qualquer saída — o que aponta para um problema no próprio comando ou no wrapper script.\nSe os arquivos de log nem sequer foram criados, a causa mais provável é que o diretório pai não existe. O launchd não cria diretórios intermediários — se ~/.local/log/ não existir, os logs simplesmente não são escritos, e o launchd não avisa que isso aconteceu. Criar o diretório e disparar o agent manualmente com launchctl start costuma resolver.\nO terceiro padrão de falha é o comando funcionar no terminal mas falhar no LaunchAgent. Isso quase sempre é um problema de ambiente. O script depende de algo que existe na sessão interativa do shell mas não no contexto do launchd — um PATH que inclui o Homebrew, uma variável de ambiente exportada no .zshrc, um serviço que só roda quando o terminal está aberto. O teste com env -i descrito na seção anterior existe para pegar esse tipo de problema antes de envolver o launchd, mas se o agent já está criado e falhando, o diagnóstico é o mesmo: execute o wrapper script com env -i HOME=\u0026quot;$HOME\u0026quot; e veja o que quebra.\nOutros erros que aparecem com menos frequência incluem o Label no plist não corresponder ao nome do arquivo (o launchd é tolerante com isso na maioria dos casos, mas pode causar comportamentos inesperados), o ProgramArguments usando ~ em vez do caminho absoluto (o launchd não expande til), e o script wrapper sem permissão de execução (chmod +x resolve). Em todos esses casos, o launchctl print do agent costuma ter a informação necessária para chegar na causa — vale acostumar-se a usá-lo como primeira ferramenta de diagnóstico em vez de tentar adivinhar o que deu errado.\nComparando com systemd timers Quem leu o post anterior e chegou até aqui provavelmente já percebeu que o launchd e o systemd timers resolvem os mesmos problemas fundamentais do cron — logging, execuções perdidas, variáveis de ambiente, dependências — mas com abordagens diferentes que refletem as filosofias dos seus respectivos sistemas operacionais.\nA separação de responsabilidades é o ponto onde a semelhança é mais direta. O systemd divide a tarefa em dois arquivos: um .service que define o que executar e um .timer que define quando. O launchd concentra tudo num único plist, com chaves para o comando e para o agendamento no mesmo arquivo. Na prática, a abordagem do systemd é mais flexível — o serviço pode ser testado, monitorado e controlado independentemente do timer, e o mesmo serviço pode ser disparado por múltiplos timers ou por outros eventos do sistema. O plist do launchd é mais autocontido e mais simples de mover entre máquinas, mas acopla a definição da tarefa ao seu agendamento.\nO logging é onde o systemd leva vantagem clara. O journald centraliza toda a saída de todos os serviços num sistema de logging estruturado, com filtros por unit, por tempo, por prioridade e com persistência gerenciada automaticamente. O launchd escreve em arquivos de texto plano que você mesmo precisa rotacionar. Para um agent pessoal que roda uma vez por dia, a diferença é irrelevante. Para um servidor com dezenas de tarefas agendadas, a ausência de um journal centralizado no macOS se faz sentir.\nA recuperação de execuções perdidas funciona nos dois sistemas, mas com mecanismos diferentes. No systemd, é opt-in via Persistent=true — se você não declarar a diretiva, execuções perdidas durante o downtime são ignoradas, exatamente como no cron. No launchd, a recuperação é o comportamento padrão e não pode ser desabilitada. A escolha da Apple faz mais sentido para laptops que dormem e acordam constantemente; a do systemd faz mais sentido para servidores onde o administrador pode ter boas razões para não querer que uma tarefa perdida rode fora do horário previsto.\nO controle de recursos e sandboxing é território exclusivo do systemd. Diretivas como CPUQuota, MemoryMax, ProtectHome e PrivateTmp não têm equivalente direto no launchd para LaunchAgents de usuário. O macOS tem seus próprios mecanismos de sandboxing (App Sandbox, seatbelt profiles), mas eles são voltados para aplicativos distribuídos pela App Store, não para scripts pessoais em ~/Library/LaunchAgents/. Na prática, um LaunchAgent roda com as permissões completas do usuário, sem nenhuma restrição adicional — semelhante ao que o cron faz em ambos os sistemas.\nA sintaxe de agendamento é mais expressiva no systemd. O formato OnCalendar aceita intervalos como Mon..Fri, repetições como *:0/15, múltiplas diretivas empilhadas no mesmo timer e a possibilidade de validar tudo previamente com systemd-analyze calendar. O StartCalendarInterval do launchd é funcional mas limitado: aceita valores fixos para cada campo, sem intervalos nem repetições no mesmo dicionário. Agendar uma tarefa para dias úteis às 18h exige cinco entradas separadas no plist (uma por dia), enquanto no systemd é uma única linha. Para agendamentos simples como \u0026ldquo;todo dia às 7h\u0026rdquo;, ambos resolvem com a mesma facilidade.\nO ponto em que o launchd não tem rival é a integração com o ciclo de vida do macOS. Ele entende nativamente o sleep e o wake do sistema, sabe quando o usuário fez login e logout, e pode condicionar a execução de um agent a eventos como a montagem de um volume ou a conexão a uma rede específica. Systemd timers podem depender de targets e units do sistema, mas o vocabulário de eventos disponível no Linux é diferente — voltado para servidores que ficam ligados o tempo todo, não para laptops que abrem e fecham a tampa trinta vezes por dia.\nNo fim, a recomendação prática é a mesma para os dois sistemas: use a ferramenta nativa. Em servidores Linux, systemd timers. No Mac, launchd. O cron continua existindo em ambos, continua funcionando, e continua sendo a escolha mais rápida para um job descartável que você precisa rodar nas próximas duas horas. Mas para qualquer tarefa que vai fazer parte da rotina diária do sistema — como um briefing que precisa chegar no Telegram toda manhã, faça chuva, faça sol, o laptop esteja aberto ou não — o agendador nativo entrega confiabilidade que o cron simplesmente não consegue oferecer.\nE se em vez de agendar por horário você quiser reagir a mudanças em arquivos — backup automático quando um banco SQLite é modificado, conversão de imagens quando aparecem numa pasta — escrevi um post sobre WatchPaths, o outro lado do launchd.\n","date":"23/03/2026","lang":"pt","tags":["macos","launchd","devops","agendamento"],"title":"Agendando tarefas no macOS com launchd (sem cron, sem gambiarra)","url":"https://devops.sarmento.org/posts/agendando-tarefas-no-macos-com-launchd-sem-cron-sem-gambiarra/"},{"categories":["Linux"],"content":"Se você administra servidores Debian ou Ubuntu há algum tempo, provavelmente tem uma relação de conforto com o cron. Uma linha no crontab, cinco campos de agendamento e o caminho do script — pronto, resolvido. O cron funciona assim desde os anos 1970, e essa simplicidade é justamente o que o manteve relevante por tanto tempo.\nO problema é que \u0026ldquo;funciona\u0026rdquo; e \u0026ldquo;funciona bem em 2026\u0026rdquo; são coisas diferentes. Quando um job falha silenciosamente às três da manhã, quando você precisa descobrir qual dos vinte crontabs espalhados pelo sistema contém aquela tarefa específica, ou quando o servidor reinicia e simplesmente perde a execução que deveria ter acontecido durante o downtime — nesses momentos o cron mostra que foi projetado para uma época em que as expectativas sobre observabilidade e resiliência eram outras.\nOs systemd timers existem desde 2014 e fazem parte de qualquer distribuição baseada em systemd. Não é um pacote extra, não é uma ferramenta de terceiros, mas sim infraestrutura que já está rodando no seu servidor agora mesmo. Este post explica a lógica por trás da mudança, o modelo mental necessário para trabalhar com timers, e o que eles oferecem de concreto em relação ao cron.\nPor que mexer no que está funcionando O cron em contexto histórico O cron nasceu no Unix V7 em 1979. A ideia era elegante na sua simplicidade: um daemon lê uma tabela de agendamentos, verifica a cada minuto se alguma entrada corresponde ao horário atual e, se corresponde, executa o comando associado. Esse modelo sobreviveu praticamente intacto por quase cinco décadas, o que diz muito sobre a solidez do conceito original.\nA sintaxe dos cinco campos — minuto, hora, dia do mês, mês e dia da semana — se tornou uma espécie de linguagem franca entre administradores de sistemas. Mesmo quem não trabalha com Linux diariamente já esbarrou em alguma variação dela dentro de CI/CD pipelines, serviços de cloud e ferramentas de automação que adotaram o formato como padrão de fato para expressar agendamentos recorrentes.\nOnde o cron começa a mostrar a idade O cron faz uma coisa e faz de maneira confiável, mas o ecossistema ao redor dele mudou. Em um servidor moderno com dezenas de tarefas agendadas, algumas limitações se acumulam e começam a pesar.\nA mais imediata é a visibilidade. O cron não tem logging próprio — a saída dos jobs vai para o email local do usuário (se o MTA estiver configurado) ou simplesmente se perde. Descobrir se um job rodou, quanto tempo levou e por que falhou exige que você mesmo implemente redirecionamentos de saída, rotação de logs e algum mecanismo de notificação. É trabalho que o administrador acaba repetindo em cada script, de maneiras ligeiramente diferentes, sem nenhuma padronização.\nOutro ponto é a dispersão. Jobs podem estar no crontab do root, no crontab de outros usuários, em /etc/crontab, em arquivos dentro de /etc/cron.d/ e nos diretórios cron.daily, cron.hourly e assim por diante. O comando crontab -l mostra apenas o crontab do usuário atual, então montar o mapa completo do que está agendado no sistema exige vasculhar vários lugares. Não existe um equivalente a systemctl list-timers — uma visão consolidada com a próxima execução, a última execução e o status de cada job.\nPor fim, o cron não tem noção de dependências nem de estado. Se o servidor estava desligado no horário em que um backup deveria rodar, o cron simplesmente ignora aquela execução — não existe mecanismo nativo para recuperar jobs perdidos. Da mesma forma, se um job depende de a rede estar disponível ou de outro serviço estar ativo, cabe ao script verificar isso por conta própria. São problemas que têm solução, mas a solução fica sempre do lado de fora do cron, na forma de wrappers, locks com flock, verificações manuais e camadas extras de complexidade que vão se acumulando com o tempo.\nComo os systemd timers funcionam O modelo de dois arquivos: service + timer A diferença conceitual mais importante entre o cron e os systemd timers é a separação entre \u0026ldquo;o que executar\u0026rdquo; e \u0026ldquo;quando executar\u0026rdquo;. No cron, tudo mora na mesma linha — o agendamento e o comando ficam juntos no crontab. Nos systemd timers, essas responsabilidades são divididas em dois arquivos distintos: uma unit de serviço (.service) e uma unit de timer (.timer).\nO arquivo .service descreve a tarefa em si. Ele define qual comando executar, com qual usuário, com quais variáveis de ambiente, o que fazer em caso de falha e quais dependências precisam estar satisfeitas antes da execução. É o mesmo tipo de unit file que o systemd usa para gerenciar qualquer outro serviço do sistema — a diferença é que, em vez de rodar continuamente como um daemon, ele usa Type=oneshot para indicar que executa uma vez e termina.\nO arquivo .timer cuida exclusivamente do agendamento. Ele define quando o serviço correspondente deve ser disparado, com que precisão, se execuções perdidas devem ser recuperadas e qual aleatoriedade aplicar ao horário de disparo. Por convenção, o systemd associa automaticamente um timer ao service de mesmo nome — se o timer se chama backup.timer, ele vai disparar backup.service sem precisar de nenhuma configuração explícita para vincular os dois.\nEssa separação parece burocrática à primeira vista (dois arquivos onde antes bastava uma linha), mas o ganho prático é significativo. O serviço pode ser testado isoladamente a qualquer momento com systemctl start backup.service, sem precisar esperar o horário agendado ou mexer no timer. O timer pode ser ajustado, desabilitado ou substituído sem tocar na lógica de execução. E ambos aparecem no ecossistema padrão do systemd — visíveis no systemctl status, com logs no journalctl, sujeitos às mesmas políticas de recursos e segurança que qualquer outra unit.\nTimers de calendário (realtime) vs. timers monotônicos Os systemd timers se dividem em duas categorias que refletem duas formas fundamentalmente diferentes de pensar sobre tempo.\nTimers de calendário, configurados com a diretiva OnCalendar, disparam em datas e horários absolutos. São o equivalente direto do que o cron faz: \u0026ldquo;todo dia às 2h da manhã\u0026rdquo;, \u0026ldquo;toda segunda-feira às 8h\u0026rdquo;, \u0026ldquo;no primeiro dia de cada mês às 3h30\u0026rdquo;. O horário de referência é o relógio do sistema, e o timer se repete conforme o padrão definido, independentemente de quando o sistema foi ligado ou de quanto tempo se passou desde a última execução.\nTimers monotônicos funcionam com durações relativas a algum evento do sistema. As diretivas mais comuns são OnBootSec (tempo após o boot), OnStartupSec (tempo após o systemd ter iniciado), OnUnitActiveSec (tempo após a última ativação do serviço associado) e OnUnitInactiveSec (tempo após a última vez que o serviço ficou inativo). Um timer configurado com OnBootSec=5min e OnUnitActiveSec=1h vai rodar cinco minutos após o boot e depois se repetir a cada hora — medindo sempre a partir do fim da execução anterior, não a partir de um ponto fixo no relógio.\nA distinção é relevante na prática. Timers de calendário são a escolha natural para tarefas que precisam acontecer em horários específicos — backups noturnos, rotação de logs, relatórios diários. Timers monotônicos fazem mais sentido para tarefas que dependem de intervalo entre execuções — coleta de métricas, verificações de saúde, limpeza periódica de cache — especialmente em máquinas que não ficam ligadas 24 horas, como laptops e desktops.\nO formato OnCalendar e como validá-lo com systemd-analyze A sintaxe do OnCalendar segue o padrão DiaDaSemana Ano-Mês-Dia Hora:Minuto:Segundo, onde o dia da semana é opcional e qualquer campo pode usar * para significar \u0026ldquo;qualquer valor\u0026rdquo;. Alguns exemplos tornam o formato mais claro do que qualquer explicação abstrata:\n*-*-* 02:00:00 — todo dia às 2h (equivalente a 0 2 * * * no cron) Mon..Fri *-*-* 08:00:00 — dias úteis às 8h *-*-01 03:30:00 — primeiro dia de cada mês às 3h30 *-*-* *:0/15 — a cada 15 minutos O systemd também aceita atalhos legíveis como daily, hourly, weekly e monthly, que se traduzem internamente para expressões completas no formato acima.\nUma das vantagens concretas sobre a sintaxe do cron é a possibilidade de empilhar múltiplas diretivas OnCalendar no mesmo timer. Um agendamento que roda em horários diferentes durante a semana e no fim de semana — algo que no cron exigiria duas entradas separadas — pode ficar em um único arquivo .timer com duas linhas OnCalendar.\nAntes de ativar um timer, o comando systemd-analyze calendar permite validar e visualizar a expressão sem risco. Executando systemd-analyze calendar \u0026quot;Mon..Fri *-*-* 08:00:00\u0026quot;, o systemd mostra a forma normalizada da expressão e calcula as próximas datas de disparo. É o tipo de ferramenta que o cron nunca teve — uma forma de testar o agendamento antes de colocá-lo em produção, em vez de esperar para ver se o job roda no horário certo.\nO que os timers fazem que o cron não faz Logging integrado com journald Quando um cron job falha, a investigação costuma começar com uma pergunta desconfortável: \u0026ldquo;onde foi parar a saída desse script?\u0026rdquo;. Dependendo de como o job foi escrito, a resposta pode ser um arquivo de log em algum canto do filesystem, o spool de email local, ou simplesmente lugar nenhum. Cada administrador resolve isso do seu jeito — redirecionando stderr para um arquivo, configurando um MTA para entregar emails locais, adicionando chamadas a logger dentro dos scripts — e o resultado é uma colcha de retalhos onde cada job tem sua própria arqueologia de logs.\nCom systemd timers, toda a saída padrão e de erro do serviço vai automaticamente para o journal. Sem configuração, sem redirecionamento, sem dependência de MTA. O comando journalctl -u backup.service mostra o histórico completo de execuções daquele serviço com timestamps precisos, e filtros como --since yesterday ou --priority err permitem recortar exatamente o que interessa. A saída do timer e a saída do serviço ficam no mesmo sistema de logging que todo o resto do systemd usa, então correlacionar eventos entre diferentes componentes do sistema deixa de ser um exercício de grep em múltiplos arquivos.\nNa prática, isso significa que a pergunta muda de \u0026ldquo;onde está o log?\u0026rdquo; para \u0026ldquo;o que o log diz?\u0026rdquo; — que é a pergunta que realmente importa quando algo dá errado às três da manhã.\nPersistent=true e execuções perdidas O cron opera com uma premissa simples: se o sistema está ligado no momento em que um job deveria rodar, ele roda. Se não está, a execução se perde sem aviso e sem recuperação. Para tarefas em servidores que ficam ligados o tempo todo, isso raramente é um problema. Mas para qualquer cenário que envolva reinicializações planejadas, janelas de manutenção, atualizações de kernel que exigem reboot ou máquinas que eventualmente são desligadas, essa é uma lacuna real.\nA diretiva Persistent=true no arquivo .timer resolve isso de maneira direta. Quando habilitada, o systemd grava em disco o timestamp da última execução bem-sucedida do timer. No próximo boot, ele compara esse timestamp com o agendamento e, se detecta que uma ou mais execuções foram perdidas durante o período em que o sistema esteve desligado, dispara o serviço assim que possível. O comportamento é determinístico e visível — systemctl list-timers mostra tanto a última execução quanto a próxima, então é trivial confirmar que a recuperação aconteceu.\nReplicar esse comportamento com cron exigiria manter arquivos de controle por fora, verificar timestamps no início de cada script e decidir programaticamente se a execução deve prosseguir — lógica que cada administrador teria que implementar, testar e manter por conta própria.\nDependências entre units Um padrão comum em cron jobs é começar o script com verificações manuais: testar se a rede está acessível, verificar se um banco de dados está respondendo, checar se um filesystem remoto está montado. Esse tipo de guarda existe porque o cron não tem nenhuma noção do estado do sistema — ele dispara o comando no horário combinado e o que acontece depois é problema do script.\nOs systemd timers herdam o sistema de dependências do próprio systemd. As diretivas After=, Requires= e Wants= no arquivo .service permitem declarar que a tarefa só deve ser executada depois que determinadas units estejam ativas. Um backup que depende de um compartilhamento NFS pode declarar After=mnt-backup.mount e Requires=mnt-backup.mount — se o mount point não estiver disponível, o systemd nem tenta executar o serviço, e o motivo fica registrado no journal. Um job que precisa de conectividade pode usar After=network-online.target e Wants=network-online.target em vez de ficar em loop testando se consegue resolver DNS — esse padrão aparece na prática no serviço de túnel SSH reverso que uso para acessar máquinas atrás de NAT.\nIsso não elimina toda necessidade de verificação dentro dos scripts — dependências de aplicação, estados de API e condições de negócio continuam sendo responsabilidade da lógica do job. Mas a camada de infraestrutura (rede, mounts, serviços do sistema) passa a ser resolvida de forma declarativa, no mesmo lugar onde o resto da configuração do serviço vive.\nControle de recursos e sandboxing Cada cron job roda essencialmente sem limites. Se um script consome toda a memória disponível, satura a CPU ou escreve até encher o disco, o impacto recai sobre todo o sistema. Ferramentas como nice, ionice e ulimit existem, mas precisam ser invocadas explicitamente dentro de cada job, e a granularidade de controle é limitada.\nComo os serviços disparados por timers são units regulares do systemd, eles têm acesso ao mesmo conjunto de controles de recursos disponível para qualquer outro serviço. Diretivas como CPUQuota=50%, MemoryMax=512M e IOWeight=100 podem ser declaradas diretamente no arquivo .service, impondo limites via cgroups sem nenhuma modificação no script executado. Um job de backup que não deve consumir mais que metade da CPU e meio giga de RAM expressa isso em duas linhas de configuração, e o systemd garante o cumprimento.\nAlém de recursos, o systemd oferece diretivas de segurança que permitem restringir o que o serviço pode fazer no sistema. ProtectHome=true impede acesso aos diretórios home dos usuários, ProtectSystem=strict torna o filesystem raiz somente leitura (exceto caminhos explicitamente liberados com ReadWritePaths=), NoNewPrivileges=true bloqueia escalação de privilégios, e PrivateTmp=true dá ao serviço um /tmp isolado. Nenhuma dessas proteções existe no modelo do cron, onde cada job roda com as permissões completas do usuário que o agendou, sem nenhum isolamento adicional.\nDa teoria à prática: anatomia de um timer Estrutura mínima do .service Um arquivo .service para uso com timers precisa de muito pouco. O exemplo abaixo mostra uma unit que executa um script de limpeza de logs antigos:\n[Unit] Description=Limpeza de logs com mais de 30 dias [Service] Type=oneshot ExecStart=/usr/local/bin/limpa-logs.sh A seção [Unit] contém apenas a descrição, que aparece na saída de systemctl list-timers e systemctl status. A seção [Service] define o tipo como oneshot — indicando que o processo executa, termina e isso é considerado sucesso — e o caminho absoluto do comando em ExecStart.\nNão há seção [Install] porque o serviço não é habilitado diretamente. Quem será habilitado é o timer; o serviço existe apenas para ser disparado por ele.\nAlgumas diretivas opcionais que vale conhecer desde o início:\n[Service] Type=oneshot User=backup Group=backup ExecStart=/usr/local/bin/limpa-logs.sh TimeoutStartSec=5min User e Group definem com qual conta o processo roda — sem eles, o padrão é root. TimeoutStartSec estabelece um tempo máximo de execução, depois do qual o systemd encerra o processo e marca o serviço como falho. Para scripts que podem travar esperando um recurso de rede ou um lock, esse limite evita que a tarefa fique pendurada indefinidamente.\nO arquivo deve ser salvo em /etc/systemd/system/ com a extensão .service. Seguindo o exemplo: /etc/systemd/system/limpa-logs.service.\nEstrutura mínima do .timer O timer correspondente precisa ter o mesmo nome-base do serviço. Para limpa-logs.service, o arquivo será limpa-logs.timer:\n[Unit] Description=Executa limpeza de logs diariamente às 3h [Timer] OnCalendar=*-*-* 03:00:00 Persistent=true [Install] WantedBy=timers.target A seção [Timer] é onde mora a lógica de agendamento. OnCalendar define o horário de disparo — neste caso, todo dia às 3h da manhã. Persistent=true garante que, se o sistema estiver desligado às 3h, a execução aconteça assim que possível após o boot.\nA seção [Install] com WantedBy=timers.target é o que permite habilitar o timer com systemctl enable. Quando habilitado, o systemd inclui esse timer no target timers.target, que é ativado automaticamente durante o boot. Diferente do .service, aqui o [Install] é obrigatório — sem ele, o timer funciona se iniciado manualmente, mas não sobrevive a um reboot.\nPara um timer monotônico, a seção [Timer] teria uma forma diferente:\n[Timer] OnBootSec=2min OnUnitActiveSec=1h Esse timer dispara dois minutos após o boot e depois se repete a cada hora, medindo a partir do fim da execução anterior. Note que Persistent=true não se aplica a timers monotônicos — a diretiva só faz sentido com OnCalendar, onde existe um horário absoluto contra o qual comparar.\nO arquivo vai no mesmo diretório: /etc/systemd/system/limpa-logs.timer.\nAtivando, verificando e acompanhando Com os dois arquivos no lugar, a sequência de ativação é sempre a mesma. Primeiro, o systemd precisa recarregar suas definições para enxergar as novas units:\nsudo systemctl daemon-reload Em seguida, habilitar e iniciar o timer:\nsudo systemctl enable limpa-logs.timer sudo systemctl start limpa-logs.timer O enable cria o vínculo simbólico para que o timer seja ativado em todo boot. O start o coloca em funcionamento imediatamente, sem esperar pelo próximo reboot. As duas operações podem ser combinadas com a flag --now:\nsudo systemctl enable --now limpa-logs.timer Para confirmar que o timer está ativo e verificar quando será a próxima execução:\nsystemctl list-timers | grep limpa-logs A saída mostra a próxima execução prevista, quanto tempo falta, quando foi a última execução e o nome da unit associada — tudo em uma linha. Para uma visão mais detalhada:\nsystemctl status limpa-logs.timer Antes de esperar pelo horário agendado, faz sentido testar o serviço isoladamente para garantir que ele funciona:\nsudo systemctl start limpa-logs.service E depois verificar a saída no journal:\njournalctl -u limpa-logs.service -n 20 Se o serviço completou sem erros, o timer pode ficar por conta do agendamento. Se algo falhou, o journal vai mostrar exatamente o que aconteceu — sem precisar caçar arquivos de log pelo filesystem, sem depender de email local, sem adivinhar se o script sequer chegou a executar.\nFaz total sentido. O logging integrado é uma das maiores vantagens dos timers sobre o cron, e até aqui o post só mencionou o journalctl de passagem sem realmente ensinar a usá-lo. Uma seção dedicada antes da referência rápida fecha esse gap e dá ao leitor a ferramenta prática para acompanhar o que os timers estão fazendo.\nLendo os logs com journalctl O journal do systemd é o destino automático de tudo que os serviços escrevem em stdout e stderr. Não precisa de configuração, não depende de redirecionamento no script e não exige um MTA funcionando. Saber navegar nele é o que transforma a vantagem teórica do logging integrado em ganho prático no dia a dia.\nLogs de um serviço específico O filtro mais comum é por unit. Para ver todas as entradas do serviço de limpeza de logs usado nos exemplos anteriores:\njournalctl -u limpa-logs.service A saída inclui timestamps, o PID do processo e tudo que o script enviou para a saída padrão e de erro. As entradas aparecem em ordem cronológica, da mais antiga para a mais recente.\nPara ver apenas as últimas execuções sem percorrer o histórico inteiro, o flag -n limita o número de linhas:\njournalctl -u limpa-logs.service -n 30 E para acompanhar a saída em tempo real enquanto o serviço roda — útil quando se está testando com systemctl start — o flag -f funciona como um tail -f:\njournalctl -u limpa-logs.service -f Filtrando por tempo Em um servidor com semanas ou meses de histórico, filtrar por período é mais prático do que rolar páginas de log. O journalctl aceita filtros de tempo em linguagem bastante direta:\njournalctl -u limpa-logs.service --since today journalctl -u limpa-logs.service --since yesterday --until today journalctl -u limpa-logs.service --since \u0026#34;2026-03-20 03:00:00\u0026#34; --until \u0026#34;2026-03-20 03:05:00\u0026#34; O último exemplo é particularmente útil para inspecionar uma execução específica — sabendo o horário do OnCalendar, basta abrir uma janela de alguns minutos ao redor dele para ver exatamente o que aconteceu.\nIdentificando falhas Quando um serviço termina com código de saída diferente de zero, o systemd o marca como failed. O comando systemctl status mostra essa informação de forma resumida:\nsystemctl status limpa-logs.service A saída inclui o estado atual (inactive (dead) para um oneshot que terminou com sucesso, failed se houve erro), o código de saída e as últimas linhas do journal. Para filtrar apenas mensagens de erro e de severidade superior:\njournalctl -u limpa-logs.service -p err Os níveis de prioridade seguem o padrão syslog: emerg, alert, crit, err, warning, notice, info e debug. O filtro -p err mostra tudo de err para cima, o que normalmente é suficiente para chegar direto ao problema.\nCorrelacionando timer e serviço Às vezes o problema não está no serviço em si, mas no disparo do timer — ele pode não estar ativando no horário esperado, ou pode estar ativando duas vezes. Para ver as entradas do timer e do serviço juntas, basta passar as duas units ao journalctl:\njournalctl -u limpa-logs.timer -u limpa-logs.service --since today Isso produz uma timeline intercalada onde dá para ver o momento exato em que o timer disparou e o que o serviço fez em seguida. É o tipo de visibilidade que, com cron, exigiria cruzar logs do syslog com a saída redirecionada do script — quando essa saída existe.\nUma nota sobre persistência dos logs Por padrão no Debian e Ubuntu, o journal armazena logs em /var/log/journal/ de forma persistente entre reboots. O espaço em disco é gerenciado automaticamente pelo journald, que por padrão limita o journal a 10% do filesystem ou 4 GB (o que for menor). Para verificar quanto espaço o journal está consumindo:\njournalctl --disk-usage Se o diretório /var/log/journal/ não existir no seu sistema, o journal está rodando em modo volátil — armazenando apenas em memória, com perda total no reboot. Criar o diretório e reiniciar o serviço resolve isso:\nsudo mkdir -p /var/log/journal sudo systemctl restart systemd-journald Para a maioria dos servidores essa configuração padrão é suficiente, mas vale confirmar que a persistência está ativa antes de depender do journal como registro histórico das execuções dos seus timers.\nReferência rápida: cron → OnCalendar A tabela abaixo mapeia os agendamentos mais comuns do cron para seus equivalentes em OnCalendar. Em todos os casos, a expressão pode ser validada antes de usar com systemd-analyze calendar \u0026quot;expressão\u0026quot;.\nDescrição cron OnCalendar A cada minuto * * * * * *-*-* *:*:00 A cada 5 minutos */5 * * * * *-*-* *:0/5:00 A cada hora (minuto zero) 0 * * * * hourly Todo dia à meia-noite 0 0 * * * daily Todo dia às 2h30 30 2 * * * *-*-* 02:30:00 Toda segunda-feira às 8h 0 8 * * 1 Mon *-*-* 08:00:00 Dias úteis às 18h 0 18 * * 1-5 Mon..Fri *-*-* 18:00:00 Primeiro dia do mês às 3h 0 3 1 * * *-*-01 03:00:00 Todo domingo à meia-noite 0 0 * * 0 Sun *-*-* 00:00:00 A cada 6 horas 0 */6 * * * 0/6:00:00 Uma vez por semana (domingo, 0h) 0 0 * * 0 weekly Uma vez por mês (dia 1, 0h) 0 0 1 * * monthly Os atalhos hourly, daily, weekly e monthly são formas abreviadas que o systemd expande internamente para a expressão completa correspondente. Funcionam bem para agendamentos simples, mas para qualquer variação — um horário diferente de meia-noite, um dia da semana específico — a expressão explícita é necessária.\nUma diferença sutil que vale notar: no cron, o campo de dia da semana usa 0 ou 7 para domingo e números de 1 a 6 para os demais dias. No OnCalendar, os dias são sempre abreviações em inglês de três letras (Mon, Tue, Wed, Thu, Fri, Sat, Sun), e intervalos usam .. em vez de -. A notação é mais legível, mas exige essa adaptação para quem tem a sintaxe do cron na memória muscular.\nQuando o cron ainda faz sentido Seria desonesto fechar este post sem reconhecer que o cron não se tornou uma ferramenta ruim da noite para o dia. Ele continua sendo uma opção perfeitamente válida em contextos específicos, e migrar tudo para systemd timers por princípio, sem um ganho concreto, é trocar trabalho útil por trabalho burocrático.\nEm sistemas onde o agendamento se resume a meia dúzia de tarefas simples — um script de backup, uma rotação de logs, uma limpeza de arquivos temporários — o cron resolve com uma linha o que os timers resolvem com dois arquivos. Se essas tarefas não precisam de logging centralizado, não dependem de outros serviços e rodam em um servidor que fica ligado o tempo todo, o custo-benefício da migração é questionável. O cron funciona, a equipe conhece, o risco de mexer é maior que o ganho.\nO cron também tem a vantagem da portabilidade. Ele existe em praticamente qualquer sistema Unix-like — BSDs, distribuições Linux sem systemd como Alpine e Void, ambientes containerizados mínimos onde o systemd não está presente. Se você mantém scripts que precisam rodar em ambientes heterogêneos, o crontab é o denominador comum mais confiável. Systemd timers simplesmente não são uma opção onde o systemd não existe.\nOutro caso é o de usuários sem privilégios administrativos. Qualquer usuário pode editar seu próprio crontab com crontab -e sem precisar de sudo. Timers em /etc/systemd/system/ exigem permissões de root. Existem timers de usuário (em ~/.config/systemd/user/), mas eles só rodam enquanto o usuário tem uma sessão ativa — a menos que loginctl enable-linger esteja habilitado para aquela conta, o que por si só requer intervenção do administrador.\nO ponto não é escolher um lado. Em um mesmo servidor, cron jobs antigos e estáveis podem coexistir com systemd timers sem nenhum conflito. A recomendação prática é adotar timers para tarefas novas e migrar as existentes conforme a oportunidade aparecer — quando um job precisar de melhor logging, quando uma falha silenciosa causar um problema real, quando o script ganhar uma dependência que justifique declaração explícita. A migração gradual, motivada por necessidade concreta, tende a dar melhores resultados do que uma conversão em massa feita de uma vez.\nSe você quer um caso prático para começar, a limpeza de revisões antigas do Snap é um bom candidato para o seu primeiro timer. E se o seu dia a dia inclui um Mac, escrevi um post sobre o launchd — o equivalente nativo do macOS.\n","date":"23/03/2026","lang":"pt","tags":["segurança","devops","self-hosted","systemd","timers","cron"],"title":"Systemd Timers: hora de aposentar o cron","url":"https://devops.sarmento.org/posts/systemd-timers-hora-de-aposentar-o-cron/"},{"categories":["Linux"],"content":"Se você usa Ubuntu há algum tempo, provavelmente já notou que o diretório /var/lib/snapd cresce de forma silenciosa e constante. O motivo não são os pacotes Snap que você instalou — são as cópias antigas que o sistema guarda automaticamente toda vez que um desses pacotes recebe atualização. Em uma instalação com dezenas de snaps, é comum encontrar 5, 8 ou até mais gigabytes ocupados por revisões que você nunca vai usar. O problema é especialmente incômodo em partições menores, SSDs com espaço limitado ou VMs com disco enxuto. A boa notícia é que identificar e remover esse excesso leva poucos minutos, desde que você saiba onde olhar e o que não apagar.\nO que são revisões do Snap e por que elas existem O snapd trabalha com um conceito de revisões imutáveis. Cada vez que um snap recebe uma atualização, a versão anterior não é apagada — ela é marcada como disabled e permanece no disco, intacta, pronta para ser reativada caso a versão nova apresente problemas. Isso significa que o sistema sempre mantém pelo menos duas cópias de cada snap instalado: a ativa e a anterior. O mecanismo é análogo ao que distribuições como o NixOS e o Fedora Silverblue fazem com o sistema inteiro, mas aplicado no nível de cada pacote individual.\nEsse comportamento é controlado pela configuração refresh.retain, que define quantas revisões o snapd deve manter por snap. O valor padrão é 2, e esse também é o valor mínimo — não é possível configurar o sistema para manter apenas a versão ativa sem nenhum backup. A Canonical impõe esse piso porque o rollback é uma das garantias fundamentais da arquitetura Snap, e permitir que o usuário eliminasse toda redundância comprometeria essa promessa.\nO cenário onde isso faz mais sentido é o de dispositivos IoT, edge computing e infraestrutura industrial. Nesses ambientes, os snaps são atualizados remotamente e de forma autônoma, e a capacidade de reverter para a versão anterior sem intervenção humana no local é parte do apelo comercial da plataforma. Se um gateway de borda em uma fábrica recebe uma atualização defeituosa às 3 da manhã, o rollback automático resolve o problema antes que alguém precise sair da cama. No seu desktop ou laptop, porém, a utilidade prática desse seguro é bem menor — e o custo em disco pode ser difícil de justificar quando o espaço está curto.\nDiagnóstico: quanto espaço está sendo consumido Antes de sair removendo revisões, vale entender o tamanho do problema na sua instalação específica. A quantidade de espaço desperdiçado varia bastante dependendo de quantos snaps você tem instalados e com que frequência eles são atualizados — um sistema com meia dúzia de snaps leves pode estar perdendo apenas algumas centenas de megabytes, enquanto um com Firefox, Chromium, LibreOffice e vários runtimes GNOME pode facilmente ultrapassar os 5 GB de revisões desativadas.\nListando todas as revisões O ponto de partida é o comando snap list --all, que mostra todos os snaps instalados junto com suas revisões, incluindo as desativadas:\nsudo snap list --all A saída inclui colunas como nome, versão, número da revisão e notas. A informação que interessa aqui é a coluna \u0026ldquo;Notes\u0026rdquo; — revisões antigas aparecem marcadas como disabled. Cada linha com essa marcação representa uma cópia inativa ocupando espaço no disco. Se você quiser uma visão mais limpa, filtrando apenas as revisões desativadas:\nsnap list --all | grep disabled O número de linhas na saída desse comando já dá uma ideia da escala do problema. Cada uma delas é um candidato à remoção.\nMedindo o consumo real O comando snap list não mostra o tamanho de cada revisão, então para ter uma noção do impacto real em disco você precisa olhar diretamente no filesystem. O caminho mais rápido é:\nsudo du -sh /var/lib/snapd Esse valor inclui tudo que o snapd gerencia — revisões ativas, desativadas, caches e metadados. Para um detalhamento por snap individual, navegue até o diretório onde os arquivos .snap ficam armazenados:\nsudo du -sh /var/lib/snapd/snaps/* Cada arquivo nesse diretório corresponde a uma revisão específica (o nome segue o padrão nome_revisão.snap), e você consegue cruzar os números de revisão com a saída do snap list --all para saber quais são ativos e quais são os candidatos à limpeza. Se preferir uma visão gráfica, o Disk Usage Analyser (Analisador de Uso de Disco) do GNOME permite navegar até /var/lib/snapd/snaps e ver rapidamente quais arquivos são maiores e mais antigos — a combinação de tamanho e data ajuda a tomar decisões com mais confiança sobre o que remover.\nLimpeza manual: removendo revisões uma a uma A abordagem mais segura para limpar revisões desativadas é removê-las individualmente, conferindo cada uma antes de executar o comando. O processo é simples mas exige atenção — o snap remove com a flag --revision não pede confirmação antes de agir.\nO comando segue este formato:\nsudo snap remove --revision=NÚMERO nome-do-snap Por exemplo, se o snap list --all mostra que o Firefox tem a revisão 5678 marcada como disabled, o comando seria:\nsudo snap remove --revision=5678 firefox A remoção é imediata e silenciosa. Se o número da revisão ou o nome do snap estiver errado, o comando falha sem causar dano — mas se você acidentalmente informar a revisão ativa, o snapd se recusa a removê-la, então não há risco de quebrar um snap em uso por engano.\nO fluxo de trabalho que funciona melhor na prática é manter dois terminais lado a lado: um com a saída do snap list --all | grep disabled visível para consulta, e outro onde você digita os comandos de remoção. Isso evita erros de digitação nos números de revisão e permite que você vá riscando mentalmente as linhas já processadas. Em uma instalação com poucos snaps desativados, o processo inteiro leva menos de dois minutos.\nA vantagem dessa abordagem sobre um script automatizado é o controle granular. Você pode decidir, por exemplo, manter a revisão anterior do snapd ou do core22 como precaução extra enquanto remove sem peso na consciência as revisões antigas do snap-store ou do gtk-common-themes. Nem todo snap tem o mesmo nível de criticidade, e a remoção manual permite que você faça essa triagem caso a caso.\nLimpeza rápida: o one-liner para remover tudo de uma vez Se você já entende o que está sendo removido e não precisa avaliar cada snap individualmente, um loop em uma linha resolve o trabalho inteiro de uma vez. Essa é a abordagem que a maioria dos tutoriais na internet recomenda, e ela funciona bem — desde que você saiba o que está fazendo.\nO loop em uma linha O comando combina snap list --all com filtros de texto para extrair os nomes e revisões de todos os snaps desativados e passá-los diretamente ao snap remove:\nsnap list --all \\ | awk \u0026#39;/disabled/{print $1, $3}\u0026#39; \\ | while read name rev; do sudo snap remove \u0026#34;$name\u0026#34; --revision=\u0026#34;$rev\u0026#34; done O awk filtra as linhas que contêm \u0026ldquo;disabled\u0026rdquo; e extrai o primeiro campo (nome do snap) e o terceiro (número da revisão). O while read itera sobre cada par e executa a remoção. O processo leva de alguns segundos a pouco mais de um minuto dependendo da quantidade de revisões acumuladas, e a saída do terminal vai mostrando cada snap removido conforme o loop avança.\nVariação com dry-run O snap remove não tem uma flag de dry-run nativa, mas é fácil simular uma substituindo a execução por um echo:\nsnap list --all \\ | awk \u0026#39;/disabled/{print $1, $3}\u0026#39; \\ | while read name rev; do echo \u0026#34;snap remove $name --revision=$rev\u0026#34; done A saída mostra exatamente quais comandos seriam executados, sem tocar em nada. Isso permite revisar a lista completa antes de comprometer qualquer mudança. Se tudo parecer correto, basta rodar a versão real. Se algum snap específico aparecer na lista e você preferir preservá-lo — como o snapd ou um kernel snap — anote o nome e adicione um filtro extra ao awk, por exemplo:\nsnap list --all \\ | awk \u0026#39;/disabled/ \u0026amp;\u0026amp; !/snapd/ \u0026amp;\u0026amp; !/core/{print $1, $3}\u0026#39; \\ | while read name rev; do sudo snap remove \u0026#34;$name\u0026#34; --revision=\u0026#34;$rev\u0026#34; done Essa variação exclui da remoção qualquer linha que contenha \u0026ldquo;snapd\u0026rdquo; ou \u0026ldquo;core\u0026rdquo;, preservando as revisões de backup dos componentes mais sensíveis do sistema enquanto limpa todo o resto.\nRiscos e cuidados Remover revisões desativadas do Snap não é uma operação destrutiva no sentido clássico — você não está apagando dados pessoais nem desinstalando programas. Mas também não é uma ação sem consequências, e vale entender o que você está abrindo mão antes de sair limpando tudo.\nSem rollback disponível O efeito mais direto da remoção é a perda da capacidade de rollback. Se a versão ativa de um snap apresentar um bug após a próxima atualização, o snapd não terá uma revisão anterior para a qual reverter — o comando snap revert nome-do-snap vai falhar porque não existe mais uma cópia de backup no disco. Na prática, isso significa que sua única opção diante de uma atualização problemática será esperar por uma correção upstream ou reinstalar o snap por completo, perdendo eventuais configurações locais que não estejam salvas em outro lugar.\nPara a maioria dos snaps de desktop — editores de texto, clientes de mensagem, utilitários diversos — esse risco é baixo. Atualizações problemáticas nesses pacotes costumam ser mais irritantes do que incapacitantes, e uma correção geralmente aparece em poucos dias. O cenário muda quando se trata de snaps que afetam o funcionamento básico do sistema.\nSnaps de sistema e kernel Os snaps snapd, core, core20, core22, core24 e eventuais kernel snaps formam a base sobre a qual todos os outros snaps executam. Uma atualização defeituosa em qualquer um deles pode impedir que outros snaps iniciem ou, em casos mais graves, afetar o processo de boot em sistemas que dependem de snaps para componentes de inicialização. Remover a revisão de backup desses pacotes elimina a rede de segurança justamente onde ela é mais necessária.\nA recomendação é simples: se for usar o one-liner para limpeza em massa, exclua esses snaps do filtro (como mostrado na variação com !/snapd/ \u0026amp;\u0026amp; !/core/ na seção anterior). Se for fazer a remoção manual, pule as linhas correspondentes a esses pacotes. O espaço que eles ocupam raramente justifica o risco.\nIsso não é permanente A limpeza de revisões desativadas resolve o problema de espaço no momento em que é feita, mas não altera o comportamento do snapd dali em diante. Na próxima vez que qualquer snap instalado receber uma atualização, a versão atual será retida como backup e o ciclo recomeça. Dependendo de quantos snaps você tem e da frequência com que eles são atualizados, o acúmulo pode voltar a níveis incômodos em algumas semanas ou poucos meses.\nNão existe uma forma nativa de desabilitar a retenção de revisões ou de agendar a limpeza automática. Se o espaço em disco for uma preocupação recorrente no seu sistema, essa remoção vai se tornar uma tarefa periódica — algo para incluir na sua rotina de manutenção junto com apt autoremove e limpeza de cache do journalctl. Se quiser automatizar essa limpeza, vale considerar os systemd timers em vez do cron — ou o launchd se estiver no macOS.\nAlternativa: ajustando o refresh.retain Antes de recorrer à remoção manual ou ao one-liner, vale verificar se o seu sistema já está configurado para reter o mínimo de revisões possível. A configuração refresh.retain do snapd define quantas revisões de cada snap são mantidas no disco, e o valor padrão em instalações mais antigas pode estar em 3 — o que significa que o sistema guarda duas cópias de backup em vez de uma.\nPara consultar o valor atual:\nsudo snap get system refresh.retain Se a saída mostrar um número maior que 2, você pode reduzi-lo ao mínimo com:\nsudo snap set system refresh.retain=2 A mudança é imediata e afeta o comportamento de todas as atualizações futuras. A partir desse ponto, o snapd passa a manter no máximo uma revisão de backup por snap, descartando automaticamente a mais antiga sempre que uma nova atualização chegar. Revisões excedentes que já existem no disco não são removidas retroativamente — para limpar o que já acumulou, você ainda precisa usar os comandos das seções anteriores.\nO valor 2 é o piso absoluto dessa configuração. Tentar definir refresh.retain=1 resulta em erro, porque a Canonical considera a existência de pelo menos um backup como requisito inegociável da arquitetura Snap. Não existe flag oculta, variável de ambiente ou workaround documentado para contornar essa limitação. Se manter uma única cópia de cada snap (sem backup nenhum) é o que você realmente precisa, a única forma de chegar lá é removendo as revisões desativadas manualmente após cada ciclo de atualização — o que nos traz de volta aos comandos já cobertos neste post.\n","date":"22/03/2026","lang":"pt","tags":["segurança","sem-servidor","limpeza","snapd","revisões","remoção"],"title":"Limpando revisões antigas do Snap para liberar espaço no Ubuntu","url":"https://devops.sarmento.org/posts/limpando-revisoes-antigas-do-snap-para-liberar-espaco-no-ubuntu/"},{"categories":["Sites Estáticos"],"content":"Por que abandonar o WordPress O peso de manter um CMS dinâmico O WordPress é um software extraordinário que alimenta quase metade da internet. Dito isso, manter uma instalação WordPress saudável é um trabalho que nunca termina. Cada visita ao seu site dispara uma cadeia de eventos: o servidor recebe a requisição, o PHP acorda, consulta o MySQL, monta a página na hora, e devolve o HTML para o navegador. Multiplica isso por cem visitantes simultâneos e você tem um servidor suando para entregar páginas que, na maioria dos blogs, são exatamente iguais para todo mundo.\nE aí vem o resto. Plugins precisam de atualização constante — não por capricho dos desenvolvedores, mas porque cada plugin é uma porta de entrada potencial para quem quer invadir o seu site. O próprio WordPress exige atualizações regulares pelo mesmo motivo. O banco de dados cresce, acumula revisões de posts, transients expirados, e metadados de plugins que você desinstalou há dois anos. O servidor precisa de PHP na versão certa, extensões habilitadas, e memória suficiente para não cair quando o Google resolver indexar tudo de uma vez. Quem administra um WordPress sabe que a pergunta não é se vai ter problema, é quando.\nSome a isto a voracidade dos scrapers de conteúdo, tanto os usados para treinar IAs quanto os usados pela concorrência (ou por você mesmo) para analisar conteúdo, SEO, etc.\nNada disso é culpa do WordPress em si. Ele foi feito para ser flexível, e flexibilidade tem um custo. O problema aparece quando tudo o que você quer é publicar texto e imagens — e o sistema que deveria te ajudar nisso exige mais manutenção do que o conteúdo em si.\nO que um site estático resolve (e o que não resolve) Um site estático é o oposto dessa engrenagem. Em vez de montar cada página na hora da visita, todas as páginas são geradas de antemão — arquivos HTML puros que ficam prontos no servidor esperando alguém pedir. Não tem PHP rodando, não tem banco de dados, não tem login de administrador exposto na internet. O servidor só precisa fazer uma coisa: entregar arquivos. Qualquer servidor faz isso rápido, e o resultado é um site que carrega em milissegundos e que não tem superfície de ataque para explorar.\nA segurança melhora drasticamente. Sem banco de dados, não existe SQL injection. Sem painel de admin acessível pela web, não tem brute force em /wp-login.php. Sem PHP, não tem execução remota de código. O site é um conjunto de arquivos de texto — tão vulnerável quanto uma pasta de documentos num servidor de arquivos.\nMas convém ser honesto sobre os limites. Um site estático não é um substituto direto do WordPress para todo mundo. Funcionalidades que dependem de processamento no servidor — comentários nativos (embora existam soluções self-hosted para isso), busca no banco de dados, e-commerce com carrinho de compras, área de membros com login — não existem num HTML puro. Elas podem ser adicionadas com serviços externos (e vamos falar sobre isso mais adiante), mas a complexidade aumenta. Se o seu site depende fortemente de interação em tempo real ou de funcionalidades dinâmicas, o WordPress ainda faz sentido. Para blogs, sites de notícias, portfólios, documentação e sites institucionais, um gerador estático entrega mais resultado com menos dor de cabeça.\nAs peças do quebra-cabeça Para um site estático funcionar como um blog de verdade — com editor visual, deploy automático e sem depender da sua máquina — você precisa de quatro peças trabalhando juntas. Nenhuma delas é complicada sozinha, mas entender o papel de cada uma antes de começar evita confusão depois.\nHugo — o gerador de sites O Hugo é o programa que transforma os seus textos em um site HTML completo. Você escreve em Markdown (um formato de texto simples com marcações leves para títulos, links, negrito e afins), escolhe um tema visual, e o Hugo compila tudo em páginas HTML prontas para publicar. Ele gera o site inteiro de uma vez — todos os posts, todas as páginas de categoria, o feed RSS, o sitemap — e coloca o resultado numa pasta chamada public/.\nO Hugo é escrito em Go e é absurdamente rápido. Um site com 500 posts compila em menos de um segundo. Isso importa porque toda vez que alguém publica um post novo, o site inteiro precisa ser reconstruído, e ninguém quer esperar minutos por isso.\nExistem outros geradores estáticos — Jekyll, Eleventy, Astro — mas o Hugo tem a combinação certa de velocidade, maturidade e ecossistema de temas para o que estamos montando aqui.\nPages CMS — o editor para humanos Um gerador estático, sozinho, exige que você edite arquivos de texto num repositório Git. Para um desenvolvedor, isso é natural. Para qualquer outra pessoa, é um pesadelo. O Pages CMS resolve esse problema colocando uma interface web amigável na frente do repositório.\nNa prática, o autor abre o Pages CMS no navegador, vê uma lista de posts, clica em \u0026ldquo;novo post\u0026rdquo;, preenche os campos (título, data, categorias, conteúdo), e salva. Nos bastidores, o Pages CMS pega tudo isso e cria um arquivo Markdown no repositório do GitHub com o front matter (os metadados do post) e o corpo do texto formatado. O autor não precisa saber que o Git existe.\nO Pages CMS é open source, gratuito, e roda no próprio serviço deles — você não instala nada no seu servidor. A configuração inteira é um único arquivo YAML no repositório que define quais campos cada tipo de conteúdo tem.\nCloudflare — a hospedagem invisível O Cloudflare entra como a infraestrutura que constrói e hospeda o site. Quando alguém publica um post pelo Pages CMS (ou quando você faz um push direto no repositório), o Cloudflare detecta a mudança, clona o repositório, roda o Hugo para gerar o HTML, e distribui o resultado pela sua rede global de mais de 300 data centers. O visitante em São Paulo recebe o site de um servidor em São Paulo; o visitante em Lisboa recebe de um servidor em Lisboa.\nO plano gratuito do Cloudflare oferece bandwidth ilimitado e 500 builds por mês — o suficiente para publicar mais de 15 posts por dia sem pagar nada. O site fica rápido por padrão, HTTPS é automático, e se uma matéria viralizar, o Cloudflare absorve o tráfego sem piscar.\nGitHub — o cofre onde tudo mora O GitHub é o repositório central. Todo o conteúdo do site — os textos em Markdown, as imagens, a configuração do Hugo, os arquivos do tema — vive num repositório Git. Isso significa que cada alteração é versionada: se alguém editar um post e o resultado ficar ruim, basta voltar para a versão anterior. Se alguém deletar algo por engano, o histórico completo está preservado.\nO GitHub também é o ponto de conexão entre as outras peças. O Pages CMS se comunica com o GitHub para ler e gravar conteúdo. O Cloudflare se conecta ao GitHub para detectar mudanças e disparar o build. O seu computador, se você preferir editar localmente, também fala com o GitHub via Git. Tudo converge para o mesmo lugar, e tudo fica registrado.\nComo as peças se encaixam Quatro ferramentas independentes não servem de nada se não conversam entre si. A boa notícia é que a integração entre elas é quase automática — uma vez configurada, o fluxo funciona sem intervenção humana.\nO fluxo: do editor ao site publicado Tudo começa no Pages CMS. O autor abre o navegador, acessa o painel, e escreve um post. Pode estar no computador do escritório, no laptop do sofá, ou no celular no ônibus — tanto faz, é uma página web. Ele preenche o título, escolhe uma categoria, escreve o texto, sobe uma imagem, e clica em salvar.\nO Pages CMS pega esses dados e faz um commit no repositório do GitHub. Na prática, ele cria um arquivo Markdown com os metadados do post no cabeçalho (título, data, categorias) e o conteúdo logo abaixo. Se o autor subiu uma imagem, ela vai junto para a pasta de mídia do repositório. Tudo isso acontece via API do GitHub — o Pages CMS nunca armazena nada, ele apenas lê e escreve no repositório.\nO GitHub recebe esse commit e dispara um webhook para o Cloudflare. O Cloudflare acorda, clona a versão mais recente do repositório, e executa o Hugo. O Hugo lê todos os arquivos Markdown, aplica o tema visual, e gera o site inteiro — cada post vira uma página HTML, as listagens são recriadas, o feed RSS é atualizado, o sitemap é refeito. O resultado é uma pasta com dezenas (ou centenas) de arquivos HTML estáticos, prontos para servir.\nO Cloudflare distribui esses arquivos pela sua rede de data centers espalhados pelo mundo. Quando alguém acessa o site, recebe o HTML do servidor mais próximo. Não tem fila, não tem processamento, não tem banco de dados — só um arquivo sendo entregue.\nDo clique em \u0026ldquo;salvar\u0026rdquo; até o post estar no ar, o processo inteiro leva entre um e dois minutos. A maior parte desse tempo é o Cloudflare preparando o ambiente de build. O Hugo em si compila o site em menos de um segundo.\nO que acontece quando alguém clica em \u0026ldquo;Salvar\u0026rdquo; Vale detalhar a sequência porque ela mostra onde cada peça atua e onde as coisas podem dar errado:\nO autor clica em \u0026ldquo;Salvar\u0026rdquo; no Pages CMS. O CMS envia uma requisição para a API do GitHub com o conteúdo do arquivo Markdown e, se houver imagem, o binário da imagem. O GitHub recebe e registra como um commit normal — com autor, data, e mensagem. Esse commit aparece no histórico do repositório como qualquer outro, e pode ser revertido se necessário.\nO GitHub notifica o Cloudflare de que houve uma mudança no branch principal. O Cloudflare inicia o processo de build: provisiona um container temporário, clona o repositório, instala a versão do Hugo que você configurou, e executa o comando de build. Se o build falhar — um erro de sintaxe no template, um arquivo corrompido — o Cloudflare mantém a versão anterior do site no ar e registra o erro no log. O site nunca fica fora do ar por causa de um build quebrado.\nSe o build passar, o Cloudflare compara os arquivos gerados com os que já estão distribuídos na rede. Só os arquivos que mudaram são atualizados — se você publicou um post novo, apenas as páginas afetadas (o post, a listagem, o RSS) são redistribuídas. O restante do site continua como estava, servido do cache.\nO resultado final é que o autor escreve num editor web amigável e, dois minutos depois, o conteúdo está disponível para o mundo inteiro num site que carrega em milissegundos. Sem servidor para manter, sem banco de dados para otimizar, sem medo de ataque. Se o autor publicar algo errado, basta editar ou reverter — o histórico completo está no GitHub.\nMãos à obra A partir daqui, cada passo tem comandos reais que você vai executar no terminal. Se algo der errado, pare e leia a mensagem de erro antes de continuar — a maioria dos problemas nessa fase são erros de digitação ou de caminho.\nPré-requisitos Você vai precisar de quatro coisas instaladas na sua máquina antes de começar:\nGit — o sistema de controle de versão. Se você usa Mac, provavelmente já tem (digite git --version no terminal para conferir). No Linux, sudo apt install git resolve. No Windows, baixe em git-scm.com.\nHugo — o gerador de sites. No Mac com Homebrew: brew install hugo. No Linux, baixe o .deb da página de releases no GitHub do Hugo. No Windows, Chocolatey ou Scoop instalam com um comando. Confirme com hugo version — você precisa da versão 0.128 ou superior, e do Hugo extended (que é o padrão nas instalações via gerenciador de pacotes).\nUma conta no GitHub — gratuita, em github.com. Se você trabalha com desenvolvimento, já tem. Se não tem, crie uma agora.\nUma conta no Cloudflare — gratuita, em dash.cloudflare.com. Você vai usar o plano free, que é mais do que suficiente para o que estamos fazendo.\nNão precisa instalar mais nada. O Pages CMS roda no navegador e não exige instalação local.\nCriando o repositório no GitHub Acesse github.com/new e crie um novo repositório:\nRepository name: escolha algo curto e sem espaços, como meu-blog ou o slug do seu domínio. Visibility: Private (seu código não precisa ser público). Initialize with: marque \u0026ldquo;Add a README file\u0026rdquo;. Depois de criar, clone o repositório na sua máquina. Abra o terminal e digite:\ncd ~/projects git clone git@github.com:SEU-USUARIO/meu-blog.git cd meu-blog Substitua SEU-USUARIO pelo seu nome de usuário no GitHub e meu-blog pelo nome que escolheu. Se a pasta ~/projects não existir, crie com mkdir ~/projects antes.\nInstalando o Hugo e escolhendo um tema Com o terminal aberto dentro do diretório do seu repositório, crie a estrutura do site Hugo:\nhugo new site . --force O --force é necessário porque o diretório já existe (tem o README do GitHub). O Hugo vai criar várias pastas — content/, layouts/, static/, themes/ — e um arquivo hugo.toml com configuração mínima.\nAgora adicione o tema. No nosso caso usamos o Terminal, um tema com estética de linha de comando que combina com um blog de tecnologia:\ngit submodule add https://github.com/panr/hugo-theme-terminal.git themes/terminal O comando git submodule add baixa o tema e o registra como dependência do seu repositório. Isso é importante: quando o Cloudflare clonar o repo para fazer o build, ele vai saber que precisa baixar o tema também.\nEscolher um tema é uma decisão que você pode mudar depois, mas que é mais fácil acertar de primeira. O diretório de temas do Hugo em themes.gohugo.io tem centenas de opções. Para um blog de notícias ou magazine, o Mainroad é uma boa pedida. Para documentação técnica, o Docsy. Para algo minimalista, o PaperMod. Navegue, veja as demos, e escolha um que tenha a cara do que você quer — a instalação é sempre o mesmo comando git submodule add com a URL do tema.\nConfigurando o site Apague o hugo.toml gerado automaticamente e crie um novo com a configuração do seu site. Cada tema tem suas próprias opções, mas a estrutura básica é parecida. Aqui está um exemplo funcional para o tema Terminal:\nbaseURL = \u0026#34;https://seu-dominio.com/\u0026#34; title = \u0026#34;Nome do seu blog\u0026#34; languageCode = \u0026#34;pt-BR\u0026#34; defaultContentLanguage = \u0026#34;pt\u0026#34; theme = \u0026#34;terminal\u0026#34; paginate = 5 [params] contentTypeName = \u0026#34;posts\u0026#34; themeColor = \u0026#34;orange\u0026#34; showMenuItems = 3 fullWidthTheme = false centerTheme = true subtitle = \u0026#34;Sua tagline aqui\u0026#34; [params.logo] logoText = \u0026#34;Nome do seu blog\u0026#34; [languages] [languages.pt] languageName = \u0026#34;Português\u0026#34; title = \u0026#34;Nome do seu blog\u0026#34; [[languages.pt.menu.main]] name = \u0026#34;Início\u0026#34; identifier = \u0026#34;home\u0026#34; url = \u0026#34;/\u0026#34; weight = 1 [[languages.pt.menu.main]] name = \u0026#34;Posts\u0026#34; identifier = \u0026#34;posts\u0026#34; url = \u0026#34;/posts\u0026#34; weight = 2 [[languages.pt.menu.main]] name = \u0026#34;Sobre\u0026#34; identifier = \u0026#34;about\u0026#34; url = \u0026#34;/about\u0026#34; weight = 3 [markup] [markup.tableOfContents] startLevel = 2 endLevel = 3 ordered = false Substitua os valores óbvios — baseURL, title, subtitle, logoText — pelos seus. O baseURL vai mudar quando você configurar o domínio definitivo, mas por enquanto pode deixar qualquer coisa; o importante é que não fique vazio.\nO parâmetro showMenuItems controla quantos itens aparecem no menu principal antes de os demais serem jogados num submenu. Se você tem três itens no menu, coloque 3. Se adicionar um quarto depois, atualize para 4.\nPrimeiro post de teste Crie a estrutura de pastas para os posts e a página \u0026ldquo;Sobre\u0026rdquo;:\nmkdir -p content/posts Crie o arquivo content/posts/primeiro-post.md com o seguinte conteúdo:\n--- title: \u0026#34;Meu primeiro post\u0026#34; date: 2026-03-26T10:00:00 description: \u0026#34;Um post de teste para verificar se tudo funciona.\u0026#34; tags: - \u0026#34;teste\u0026#34; slug: como-criei-este-blog-sem-gastar-um-centavo-e-sem-tocar-em-wordpress draft: false toc: false --- Se você está lendo isso, o Hugo está funcionando. ## Um subtítulo de teste Aqui vai um parágrafo normal com **negrito** e *itálico*. ### Um sub-subtítulo E uma lista: - Item um - Item dois - Item três Pronto. Se isso renderizou corretamente, o tema está configurado e podemos seguir em frente. Crie também a página content/about.md:\n--- title: \u0026#34;Sobre\u0026#34; slug: como-criei-este-blog-sem-gastar-um-centavo-e-sem-tocar-em-wordpress draft: false --- Escreva aqui uma breve descrição sobre você ou sobre o blog. Esta página é acessível pelo menu principal. Testando localmente antes de publicar Rode o servidor de desenvolvimento do Hugo:\nhugo server -D O -D inclui posts marcados como rascunho. Abra http://localhost:1313 no navegador e você deve ver o site com o tema aplicado, o menu funcionando, e o post de teste na listagem.\nNavegue pelo site. Clique no post para ver se abre. Verifique se o menu mostra os itens que você configurou. Se algo não estiver certo, edite o hugo.toml e salve — o Hugo recarrega automaticamente e o navegador atualiza sozinho.\nQuando estiver satisfeito, é hora de enviar para o GitHub:\ngit add . git commit -m \u0026#34;Setup inicial: Hugo + tema + primeiro post\u0026#34; git push A partir deste ponto, seu site existe como código no GitHub. Na próxima seção, vamos colocá-lo no ar.\nColocando no ar com Cloudflare Até agora, o site só existe na sua máquina e no GitHub. Ninguém mais consegue vê-lo. O Cloudflare é quem vai transformar o repositório num site público, acessível por qualquer navegador do mundo.\nConectando o repositório Acesse dash.cloudflare.com e, no menu lateral, clique em Workers \u0026amp; Pages. Clique em Create application e escolha a opção de importar um repositório do GitHub. Se é a primeira vez que você conecta o Cloudflare ao GitHub, ele vai pedir para instalar o app \u0026ldquo;Cloudflare Workers and Pages\u0026rdquo; na sua conta. Durante a instalação, o GitHub pergunta a quais repositórios o Cloudflare terá acesso — você pode liberar todos ou selecionar apenas o repositório do seu blog. Recomendo selecionar apenas o que precisa; você pode adicionar outros depois.\nUm detalhe que pega muita gente: se você criar um novo repositório depois de já ter feito essa configuração, o Cloudflare não vai enxergá-lo automaticamente. Você precisa voltar às configurações do GitHub App (em github.com/settings/installations), clicar em \u0026ldquo;Configure\u0026rdquo; ao lado de \u0026ldquo;Cloudflare Workers and Pages\u0026rdquo;, e adicionar o novo repositório à lista de permissões.\nCom o repositório visível, selecione-o e siga para a tela de configuração do build.\nConfigurando o build A tela de configuração pede algumas informações. A interface do Cloudflare muda com certa frequência, então os nomes exatos dos campos podem variar, mas o conceito é sempre o mesmo:\nO Build command é o comando que o Cloudflare vai executar para gerar o site. Use:\nhugo --minify O --minify é opcional, mas reduz o tamanho do HTML gerado removendo espaços e quebras de linha desnecessárias. Não afeta a aparência do site, só diminui o tamanho dos arquivos.\nVocê vai precisar de um arquivo wrangler.toml na raiz do seu repositório para que o Cloudflare saiba onde encontrar o resultado do build. Crie o arquivo com este conteúdo:\nname = \u0026#34;nome-do-seu-projeto\u0026#34; compatibility_date = \u0026#34;2026-03-22\u0026#34; [assets] directory = \u0026#34;./public\u0026#34; A seção [assets] com directory = \u0026quot;./public\u0026quot; é a parte que diz ao Cloudflare que o conteúdo a ser publicado está na pasta public/ — que é exatamente onde o Hugo coloca o HTML gerado.\nNas configurações de variáveis de ambiente (normalmente escondidas atrás de um botão \u0026ldquo;Advanced settings\u0026rdquo; ou similar), adicione uma variável chamada HUGO_VERSION com o valor da versão que você instalou na sua máquina — por exemplo, 0.158.0. Sem essa variável, o Cloudflare vai usar uma versão antiga do Hugo e o build pode falhar silenciosamente ou gerar um site diferente do que você viu localmente.\nFaça commit do wrangler.toml e push:\ngit add wrangler.toml git commit -m \u0026#34;Adiciona wrangler.toml para Cloudflare\u0026#34; git push O primeiro deploy (e os erros que você vai encontrar) Clique em Deploy e acompanhe o log. O Cloudflare vai clonar o repositório, instalar o Hugo, executar o build, e publicar o resultado. O primeiro deploy leva entre um e dois minutos.\nSe tudo correr bem, o log termina com uma mensagem de sucesso e uma URL no formato nome-do-projeto.seu-subdominio.workers.dev. Abra no navegador e confira — o site deve ser idêntico ao que você viu no localhost:1313.\nMas convém estar preparado para as coisas que costumam dar errado na primeira tentativa, porque quase ninguém acerta de primeira:\n\u0026ldquo;Unable to locate config file\u0026rdquo; significa que o Cloudflare está rodando o Hugo no diretório errado. Verifique se o campo \u0026ldquo;Path\u0026rdquo; na configuração de build está como / (a raiz do repositório) e não apontando para algum subdiretório.\n\u0026ldquo;Error building site: template failed\u0026rdquo; geralmente indica incompatibilidade entre a versão do Hugo e o tema. Temas mais antigos usam funções que o Hugo removeu em versões recentes. Confirme que a variável HUGO_VERSION está configurada e que o valor corresponde à versão que funciona na sua máquina.\n\u0026ldquo;Authentication error\u0026rdquo; durante o deploy pode indicar que o token de API gerado automaticamente não tem as permissões necessárias. Se isso acontecer, tente recriar o projeto do zero — às vezes a interface do Cloudflare tropeça na primeira configuração e funciona perfeitamente na segunda.\nO importante é saber que o build do Hugo e o deploy para o Cloudflare são etapas separadas. Se o log mostra \u0026ldquo;Build command completed\u0026rdquo; seguido de um erro no deploy, o problema não é no seu site — é na comunicação entre o Cloudflare e a sua conta. Se o erro aparece durante o build, antes do \u0026ldquo;Build command completed\u0026rdquo;, o problema está na configuração do Hugo ou no tema.\nUma vez que o primeiro deploy funciona, os seguintes são automáticos. Cada push no repositório — seja do seu terminal, seja do Pages CMS — dispara um novo build e deploy sem que você precise tocar em nada. O ciclo inteiro, do commit ao site atualizado, leva entre um e dois minutos.\nCom o site no ar, atualize o baseURL no hugo.toml para a URL que o Cloudflare gerou, faça commit e push. Esse detalhe é fácil de esquecer, mas o Hugo usa o baseURL para gerar links internos, o sitemap e o feed RSS — se estiver errado, esses recursos vão apontar para o lugar errado.\nConectando o Pages CMS O site está no ar e o deploy automático funciona. O que falta é a peça que torna tudo isso utilizável por alguém que não sabe (e não quer saber) o que é Git: o editor web.\nO arquivo .pages.yml — o mapa do seu conteúdo O Pages CMS não adivinha a estrutura do seu site. Você precisa dizer a ele onde ficam os posts, quais campos cada post tem, e onde as imagens são armazenadas. Tudo isso vai num único arquivo chamado .pages.yml, na raiz do repositório.\nUm exemplo funcional para um blog Hugo:\nmedia: input: static/img output: /img content: - name: posts label: Posts type: collection path: content/posts view: fields: [title, date] fields: - name: title label: Título type: string - name: date label: Data type: date - name: description label: Resumo type: string - name: cover label: Imagem de capa type: image - name: tags label: Tags type: string list: true - name: toc label: Índice de conteúdo type: boolean default: true - name: draft label: Rascunho type: boolean default: false - name: body label: Conteúdo type: code options: language: markdown Cada bloco dentro de fields corresponde a um campo que o autor vai ver no editor. O type define o tipo de controle — string é um campo de texto simples, date mostra um seletor de data, image permite upload, boolean é uma chave liga/desliga, e code com a opção language: markdown oferece um editor de texto com destaque de sintaxe para Markdown.\nA seção media merece atenção. O input é o caminho dentro do repositório onde as imagens são salvas — static/img. O output é o caminho como o Hugo vai servir esses arquivos no site final — /img. Essa distinção existe porque o Hugo serve tudo que está dentro de static/ a partir da raiz do site, eliminando o prefixo static da URL. Se você configurar media: static/img sem separar input e output, as imagens vão funcionar no editor mas dar 404 no site publicado.\nFaça commit e push do arquivo:\ngit add .pages.yml git commit -m \u0026#34;Adiciona configuração do Pages CMS\u0026#34; git push Acessando o editor pela primeira vez Abra app.pagescms.org e faça login com sua conta do GitHub. O Pages CMS vai pedir permissão para acessar os seus repositórios — assim como o Cloudflare, ele funciona como um GitHub App, e você pode restringir o acesso a repositórios específicos.\nDepois de autorizar, a tela principal mostra a lista de projetos. Selecione o repositório do seu blog e o Pages CMS vai ler o .pages.yml para montar a interface. Se tudo estiver correto, você vai ver \u0026ldquo;Posts\u0026rdquo; no menu lateral e, ao clicar, a lista de posts existentes no repositório.\nSe o repositório não aparecer na lista, o problema é o mesmo do Cloudflare: o GitHub App do Pages CMS não tem acesso ao repositório. Vá em github.com/settings/installations, encontre o app do Pages CMS, e adicione o repositório.\nCriando e publicando um post pelo navegador Clique em \u0026ldquo;Add an entry\u0026rdquo; no canto superior direito. O editor vai mostrar os campos que você definiu no .pages.yml — título, data, resumo, imagem de capa, tags, e o campo de conteúdo em Markdown.\nPreencha os campos e escreva o post no editor Markdown. O campo usa o Codemirror com destaque de sintaxe, então títulos, links, negrito e código aparecem coloridos enquanto você digita. Não é um editor WYSIWYG com preview visual ao vivo, mas para quem está acostumado com Markdown a experiência é fluida.\nQuando terminar, clique em \u0026ldquo;Save\u0026rdquo;. O Pages CMS vai criar o arquivo Markdown no repositório com o front matter preenchido e o conteúdo do post. Esse commit dispara o webhook para o Cloudflare, que roda o build do Hugo e publica a versão atualizada do site. Em um ou dois minutos, o post está no ar.\nUma nota sobre apagar posts: o Pages CMS tem um bug conhecido na função de exclusão — o post aparenta ser removido no painel, mas o arquivo permanece no repositório, e o post continua aparecendo no site. Até que isso seja corrigido, a forma segura de \u0026ldquo;despublicar\u0026rdquo; um post é editar o post e marcar o campo Rascunho como ativo. O Hugo ignora posts marcados como draft no build de produção, então o post desaparece do site sem precisar apagar o arquivo. Se você realmente precisar excluir um post do repositório, faça pelo terminal com git rm e push.\nConfigurando imagem destacada O campo cover no .pages.yml permite que o autor faça upload de uma imagem que será usada como destaque do post — na listagem, no topo do artigo, e nos cards de compartilhamento em redes sociais. O comportamento exato depende do tema: o Terminal usa o campo cover no front matter, o Mainroad usa thumbnail, e outros temas podem ter nomes diferentes. Consulte a documentação do tema que escolheu para saber o nome correto.\nAo clicar no campo de imagem no editor, o Pages CMS permite selecionar uma imagem já existente na pasta de mídia ou fazer upload de uma nova. A imagem é salva no repositório no caminho configurado em media.input, e o caminho gravado no front matter do post segue o padrão definido em media.output.\nO detalhe do caminho das imagens Este é o erro mais comum na integração entre o Pages CMS e o Hugo, e vale reforçar porque ele é silencioso — tudo parece funcionar no editor, mas as imagens aparecem quebradas no site.\nO Hugo serve os arquivos da pasta static/ diretamente na raiz do site. Um arquivo em static/img/foto.jpg fica acessível em seusite.com/img/foto.jpg, e não em seusite.com/static/img/foto.jpg. Se o Pages CMS gravar o caminho completo do repositório no front matter (/static/img/foto.jpg), o navegador vai pedir um arquivo que não existe naquele caminho e mostrar uma imagem quebrada.\nA solução é a separação entre input e output na configuração de mídia do .pages.yml que mostramos acima. O input: static/img diz ao Pages CMS onde salvar o arquivo no repositório. O output: /img diz qual caminho gravar no front matter do post. Quando o Hugo gera o site, o arquivo está em static/img/ e a referência no HTML aponta para /img/ — e tudo bate.\nDomínio próprio O site funciona no subdomínio .workers.dev que o Cloudflare gerou, mas ninguém vai levar a sério um blog com uma URL que parece um projeto de teste. Colocar o domínio próprio é o toque final que transforma o setup num site de verdade.\nApontando o DNS Se o seu domínio já está no Cloudflare (como nameserver ou com proxy ativo), o processo é direto. No dashboard do Cloudflare, vá em DNS → Records e adicione um registro CNAME:\nType: CNAME Name: o subdomínio que você quer (por exemplo, devops para devops.sarmento.org, ou @ para usar o domínio raiz) Target: a URL que o Cloudflare gerou para o seu projeto, sem o https:// — algo como nome-do-projeto.seu-subdominio.workers.dev Proxy status: Proxied (nuvem laranja ativa) Depois, vá em Workers \u0026amp; Pages, abra o projeto do seu site, e em Settings procure a seção de domínios customizados (Custom Domains). Adicione o domínio — por exemplo, devops.sarmento.org. O Cloudflare vai verificar que o CNAME existe e associar o domínio ao projeto.\nSe o seu domínio está registrado em outro lugar (Registro.br, GoDaddy, Namecheap), você tem duas opções. A mais simples é transferir os nameservers para o Cloudflare — o plano gratuito inclui DNS hosting, e a propagação leva de minutos a poucas horas. A alternativa é criar o CNAME no painel do seu registrador atual apontando para o endereço .workers.dev, mas nesse caso você perde o proxy do Cloudflare e algumas otimizações de cache.\nA propagação de DNS pode levar de poucos minutos até 24 horas, dependendo do TTL configurado e do provedor. Na prática, com o Cloudflare como nameserver, a mudança costuma refletir em menos de cinco minutos.\nHTTPS automático Não precisa fazer nada. O Cloudflare gera e renova automaticamente um certificado SSL para o seu domínio customizado. Assim que o DNS propagar e o Cloudflare reconhecer o domínio, o site passa a responder em HTTPS sem configuração adicional, sem instalar certificado, e sem renovação manual.\nSe alguém acessar o site por HTTP, o Cloudflare redireciona automaticamente para HTTPS. Isso já vem habilitado por padrão — mas se por algum motivo não estiver, vá em SSL/TLS → Edge Certificates e ative \u0026ldquo;Always Use HTTPS\u0026rdquo;.\nDepois de confirmar que o domínio está funcionando, atualize o baseURL no hugo.toml para o endereço definitivo, faça commit e push. Essa é a última vez que você precisa mexer nesse campo.\nE as coisas que o WordPress fazia sozinho? Quem vem do WordPress está acostumado a resolver tudo com plugins. Busca, comentários, formulário de contato, posts agendados — tudo existe a um clique de distância no painel. Num site estático, essas funcionalidades não vêm de graça, mas também não são impossíveis. Algumas são surpreendentemente fáceis; outras exigem compromissos.\nBusca em site estático — como funciona sem banco de dados A primeira reação de quem descobre sites estáticos costuma ser: \u0026ldquo;mas como é que busca funciona sem banco de dados?\u0026rdquo; A resposta é que a busca roda inteiramente no navegador do visitante.\nDurante o build, o Hugo pode gerar um arquivo de índice em JSON contendo os títulos, resumos e (opcionalmente) o conteúdo completo de todos os posts. Quando o visitante abre a página de busca e digita alguma coisa, um JavaScript carrega esse índice e filtra os resultados ali mesmo, sem nenhuma requisição ao servidor.\nA solução mais comum é o Fuse.js — uma biblioteca leve de fuzzy search que funciona bem para blogs de pequeno e médio porte. O visitante digita, os resultados aparecem instantaneamente enquanto ele tecla. Vários temas Hugo já trazem busca integrada com Fuse.js; basta ativar na configuração.\nO ponto de atenção é o tamanho do índice. Se o blog tem 50 posts, o JSON pesa poucos kilobytes e carrega imperceptivelmente. Com 500 posts indexando conteúdo completo, o arquivo pode passar de 2 ou 3 MB — o visitante precisa baixar isso antes de a busca funcionar. A solução para sites grandes é o Pagefind, uma ferramenta que gera um índice binário fragmentado e só baixa os pedaços relevantes para cada busca. Ele se integra com o Hugo sem dificuldade e escala para milhares de páginas sem que o visitante perceba qualquer lentidão.\nComentários sem backend — Giscus, Utterances e alternativas Comentários são a funcionalidade que mais falta faz quando se sai do WordPress, e ao mesmo tempo a que mais dor de cabeça causa em qualquer plataforma. Spam, moderação, GDPR, performance — o sistema de comentários nativo do WordPress resolve o básico, mas qualquer instalação séria acaba recorrendo a plugins como Akismet ou Disqus de qualquer forma.\nNum site estático, comentários precisam de um serviço externo. As opções mais populares e gratuitas:\nO Giscus usa o sistema de Discussions do GitHub como backend. Cada post do blog vira uma discussion no repositório, e os comentários são threads dentro dela. O visitante precisa ter conta no GitHub para comentar, o que é uma barreira para público não técnico, mas um filtro natural contra spam para blogs de tecnologia. A integração é um trecho de JavaScript no template do tema.\nO Utterances funciona de forma parecida, mas usa Issues do GitHub em vez de Discussions. Cada post gera uma issue, e os comentários são respostas nela. A experiência é quase idêntica à do Giscus, com a mesma limitação de exigir conta GitHub.\nO Disqus é o mais universal — aceita login por várias redes sociais e email — mas injeta scripts pesados, rastreia visitantes, e exibe anúncios no plano gratuito. Se o público do blog não é técnico e precisa de uma experiência familiar de comentários, o Disqus funciona, mas o custo é performance e privacidade.\nPara blogs técnicos como este, Giscus é a melhor escolha. Para blogs voltados ao público geral, o melhor pode ser simplesmente não ter comentários e direcionar a conversa para redes sociais — que é o que a maioria dos grandes sites de notícias faz hoje. Quem prefere controle total sobre os dados pode considerar o Isso, um servidor de comentários self-hosted que não depende de nenhuma plataforma.\nPosts relacionados — o que o Hugo oferece nativamente O Hugo tem um sistema nativo de posts relacionados que funciona sem nenhum plugin. Ele analisa tags, categorias, data de publicação e palavras-chave do front matter para calcular uma pontuação de relevância entre os posts e exibir uma lista de sugestões no final de cada artigo.\nA configuração vai no hugo.toml:\n[related] includeNewer = true threshold = 80 toLower = true [[related.indices]] name = \u0026#34;tags\u0026#34; weight = 100 [[related.indices]] name = \u0026#34;categories\u0026#34; weight = 80 [[related.indices]] name = \u0026#34;date\u0026#34; weight = 10 O threshold define a pontuação mínima para um post aparecer como relacionado — quanto mais alto, mais restritivo. Os pesos em cada índice controlam o que pesa mais na comparação: neste exemplo, tags compartilhadas valem mais do que categorias, e a data de publicação tem influência mínima.\nA exibição depende do tema. Alguns temas já incluem um bloco de posts relacionados no layout do post; outros exigem que você adicione o trecho no template. No pior caso, são poucas linhas de Go template para inserir a lista — a documentação do Hugo cobre isso em detalhe.\nPosts agendados — publicar no futuro sem cron job O Hugo tem suporte nativo a datas futuras. Se o front matter de um post tem uma data posterior ao momento do build, o Hugo simplesmente não inclui o post no site gerado. O post existe no repositório, mas é invisível para os visitantes.\nO problema é que alguém precisa disparar o build depois que a data chega. No WordPress, o cron interno cuida disso automaticamente. Num site estático, o build só acontece quando há um push no repositório ou quando alguém o dispara manualmente.\nA solução mais simples é um cron job no Cloudflare ou uma GitHub Action agendada. Uma Action que roda uma vez por dia (ou a cada hora, dependendo da sua necessidade) faz um push vazio no repositório, o que dispara o build no Cloudflare. Se houver posts cuja data já passou, eles aparecem no site; se não, o build termina sem mudar nada.\nname: Publicar posts agendados on: schedule: - cron: \u0026#39;0 */6 * * *\u0026#39; jobs: trigger: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: | git config user.name \u0026#34;github-actions\u0026#34; git config user.email \u0026#34;actions@github.com\u0026#34; git commit --allow-empty -m \u0026#34;Trigger build para posts agendados\u0026#34; git push Esse workflow roda a cada seis horas e faz um commit vazio — suficiente para disparar o build sem alterar nenhum conteúdo. A granularidade é ajustável; se precisar que posts entrem no ar com precisão de hora, mude o cron para rodar a cada hora. Para a maioria dos blogs, uma vez por dia é suficiente.\nAnúncios — onde e como inserir nos layouts e nos posts Monetização com anúncios funciona normalmente num site estático — o HTML gerado pelo Hugo é idêntico ao que qualquer CMS dinâmico produziria, e as redes de anúncios (Google AdSense, Ezoic, Mediavine) não distinguem um do outro. O que muda é onde você coloca o código.\nNo WordPress, plugins como Ad Inserter permitem posicionar anúncios sem mexer em código. Num site Hugo, os anúncios vão diretamente nos templates do tema. Existem dois lugares naturais:\nPara anúncios no layout (sidebar, cabeçalho, rodapé, entre posts na listagem), você edita os partials do tema. A maioria dos temas bem estruturados tem arquivos como layouts/partials/header.html, layouts/partials/footer.html, e layouts/partials/sidebar.html. O código do anúncio — normalmente um trecho de JavaScript fornecido pela rede de anúncios — vai direto nesses arquivos, na posição desejada.\nPara anúncios dentro do conteúdo dos posts (por exemplo, depois do terceiro parágrafo), a abordagem mais limpa é criar um shortcode. Crie o arquivo layouts/shortcodes/ad.html com o código do anúncio, e no Markdown do post insira {{\u0026lt; ad \u0026gt;}} onde quiser que o anúncio apareça. Isso mantém o conteúdo separado da monetização — se você trocar de rede de anúncios, altera um arquivo em vez de editar centenas de posts.\nFormulário de contato — opções sem servidor Um site estático não processa formulários — não tem backend para receber os dados. Mas existem serviços especializados que resolvem isso com uma única linha de configuração:\nO Formspree é o mais simples. Você cria um formulário HTML normal no seu site, aponta o action para o endpoint do Formspree, e os envios chegam no seu e-mail. O plano gratuito permite 50 envios por mês, o que é mais do que suficiente para um blog.\nO Formspark e o Basin funcionam de forma parecida, com planos gratuitos de diferentes capacidades. O Cloudflare Workers também pode processar formulários, o que mantém tudo dentro do mesmo ecossistema — mas exige escrever um pouco de JavaScript.\nPara a maioria dos blogs, um formulário simples com Formspree resolve sem nenhuma complicação. Adicione uma página content/contato.md com o formulário em HTML puro no corpo do Markdown — o Hugo renderiza HTML dentro de Markdown sem problemas.\nRSS — o feed que já vem pronto Essa é a boa notícia mais curta do post: o Hugo gera RSS automaticamente. Não precisa instalar nada, não precisa configurar nada. O feed fica disponível em /index.xml por padrão e inclui todos os posts do site com título, data, resumo e link.\nSe quiser personalizar o que aparece no feed — por exemplo, incluir o conteúdo completo em vez de só o resumo, ou limitar a quantidade de itens — o Hugo permite sobrescrever o template RSS. Mas para a maioria dos blogs, o padrão funciona perfeitamente desde o primeiro build.\nBasta adicionar o link no menu de navegação ou no rodapé do site para que os leitores encontrem. Agregadores de RSS como Feedly, Inoreader e NewsBlur reconhecem o formato automaticamente.\nO que aprendi no caminho Vantagens que não esperava A vantagem mais óbvia — velocidade — eu já esperava. Um site estático servido por CDN carrega rápido, e pronto. O que me surpreendeu foram os benefícios colaterais que só aparecem depois de usar o setup por um tempo.\nO versionamento de conteúdo é o primeiro. No WordPress, se um editor sobrescreve um parágrafo e salva, a versão anterior fica enterrada num sistema de revisões que quase ninguém sabe usar. Com o conteúdo no Git, cada alteração é um commit com data, autor e diff. Se alguém publicar algo errado às duas da manhã, reverter é um git revert — ou, no pior caso, um clique no histórico do GitHub. Isso parece um detalhe técnico até o dia em que salva o seu pescoço.\nO custo operacional foi a segunda surpresa. Não é só a hospedagem que sai de graça — é a ausência total de manutenção de infraestrutura. Não tem servidor para atualizar, não tem PHP para manter compatível, não tem MySQL para otimizar, não tem plugin de cache para configurar. O site simplesmente existe como um conjunto de arquivos estáticos distribuídos pelo mundo. A conta mensal de infraestrutura é zero. A conta mensal de horas gastas apagando incêndio também é zero.\nA terceira foi a portabilidade. O conteúdo inteiro do site é um repositório Git com arquivos Markdown. Se o Cloudflare desaparecer amanhã, eu mudo o deploy para Netlify, Vercel, ou um Nginx no meu próprio servidor em menos de uma hora. Se o Pages CMS fechar, os arquivos continuam no GitHub — eu só preciso de outro editor, ou de um editor de texto qualquer. Nenhuma peça do sistema é insubstituível, e nenhuma delas segura os meus dados como refém.\nLimitações que você precisa conhecer A limitação mais concreta é a ausência de funcionalidades dinâmicas nativas. Tudo o que depende de processamento no servidor — busca avançada, comentários, formulários, área restrita — precisa de um serviço externo ou de um workaround. As soluções existem (e cobrimos as principais neste post), mas cada uma adiciona uma dependência e um ponto de configuração. No WordPress, é um plugin. Aqui, é um serviço externo com conta própria, documentação própria, e limites próprios.\nO Pages CMS, apesar de funcional, ainda é um projeto jovem mantido essencialmente por uma pessoa. O editor rich-text não existe — o campo de conteúdo é um editor de código com syntax highlighting de Markdown, o que é aceitável para quem conhece a sintaxe, mas inviável para um redator que nunca viu um ## na vida. A função de excluir posts tem um bug que deixa o arquivo no repositório mesmo depois de confirmar a exclusão. São problemas contornáveis, mas que mostram que a ferramenta ainda não está pronta para entregar a um cliente não técnico sem supervisão.\nO build não é instantâneo. Entre o clique em \u0026ldquo;salvar\u0026rdquo; e o post aparecer no site, passam-se um a dois minutos. Para um blog, isso é irrelevante. Para um site de notícias que precisa publicar com urgência de breaking news, pode ser um incômodo. O Hugo compila em menos de um segundo — a latência está toda no Cloudflare provisionando o ambiente e distribuindo os arquivos.\nEditar o tema exige mexer em templates Go. O sistema de templates do Hugo é poderoso, mas a sintaxe não é intuitiva para quem vem do PHP do WordPress. Coisas simples como mudar a posição de um elemento na página ou adicionar um campo customizado no layout do post significam abrir um arquivo .html cheio de {{ }} e entender a lógica de partials, blocos e contextos. A curva de aprendizado não é íngreme, mas existe, e aparece justamente na hora em que você quer fazer algo que o tema não previu.\nPara quem essa abordagem faz sentido (e para quem não faz) Essa stack faz sentido para blogs pessoais e profissionais, sites de documentação, portfólios, sites institucionais, e pequenos sites de notícias. Faz sentido especialmente quando o autor é técnico ou tem acesso a alguém técnico que configure o sistema inicial — porque a configuração exige terminal, Git, e leitura de documentação. Depois de configurado, o dia a dia é simples o bastante para qualquer pessoa que saiba preencher um formulário web.\nNão faz sentido para sites que dependem de interatividade pesada em tempo real: e-commerce com carrinho de compras, aplicações web com login de usuário, fóruns de discussão, plataformas de e-learning com progresso de aluno. Essas funcionalidades precisam de um backend, e tentar replicá-las com serviços externos colados num site estático é nadar contra a corrente.\nTambém não faz sentido — pelo menos por enquanto — quando os autores são completamente não técnicos e não existe ninguém disponível para resolver os inevitáveis tropeços. O Pages CMS é amigável, mas não é WordPress. Não tem dashboard com métricas, não tem preview visual antes de publicar, não tem lixeira que recupera posts apagados por engano. Quem opera o site precisa ter um mínimo de conforto com ferramentas digitais e, idealmente, acesso a alguém que saiba abrir um terminal quando as coisas derem errado.\nPara todo o resto — para quem quer um site rápido, seguro, barato de operar, e que não exija vigiar um servidor 24 horas por dia — essa combinação de Hugo, Pages CMS e Cloudflare entrega mais do que o necessário com menos dor de cabeça do que qualquer alternativa dinâmica que eu já administrei.\nSe quiser entender onde estão os limites reais dessa stack gratuita — e o que fazer quando esbarrar neles — escrevi um post dedicado ao assunto. E se você vem do WordPress e quer entender por que temas no Hugo funcionam de forma tão diferente, o post De WordPress a Hugo: temas não são o que você pensa mapeia as diferenças conceituais. E quando o número de posts crescer e manter tags e meta descriptions consistentes virar um problema, o Hugin usa IA para classificar e resumir posts Hugo automaticamente.\n","date":"20/03/2026","lang":"pt","tags":["hugo","cloudflare","sem-servidor","github","segurança"],"title":"Como criei este blog sem gastar um centavo (e sem tocar em WordPress)","url":"https://devops.sarmento.org/posts/como-criei-este-blog-sem-gastar-um-centavo-e-sem-tocar-em-wordpress/"},{"categories":["Meta"],"content":"Resolvi fazer mais um blog, depois de tantos que sobreviveram e tantos que pereceram, sem contar as contas no Substack, no Medium, as \u0026ldquo;segundos cérebros\u0026rdquo; locais, cadernos de anotação, etc.\nDessa vez o objetivo é centralizar aqui o conhecimento de DevOps que já tenho e que continuo adquirindo diariamente. Além de usar esse espaço como laboratório para uma nova abordagem: um site “à prova de falhas” utilizando apenas recursos gratuitos como o Github, para armazenamento do conteúdo, e as Cloudflare Pages para publicação.\nAssim, sem servidor para cuidar, sem PHP nem MySQL, nem qualquer possível porta para invasão, todos os pontos de entrada são cobertos pelas grandes empresas, sob suas diversas camadas de segurança e autenticação.\nVamos ver até onde eu vou dessa vez!\nSe quiser saber como a stack foi montada na prática, escrevi um guia passo a passo.\n","date":"18/03/2026","lang":"pt","tags":["devops","github","blog","segurança","sem-servidor","cloudflare"],"title":"Por que mais um blog","url":"https://devops.sarmento.org/posts/por-que-mais-um-blog/"},{"categories":null,"content":"Janio Sarmento — sysadmin, 53 anos, brasileiro trabalhando de casa para o mundo. Cuido de servidores Linux, containers LXC, e-mail que não cai em spam, e gatos que não saem de cima do teclado.\nEste blog documenta o dia a dia de quem mantém infraestrutura de pé, incluindo as coisas que quebram (e como consertar).\n","date":"01/01/0001","lang":"pt","tags":null,"title":"Sobre","url":"https://devops.sarmento.org/about/"}]