cloudflaretunnelvpsdockersegurancadevops

Uma VPS, zero portas de serviço abertas: anatomia de um túnel que serve tudo

Douglas Haruo 11 min 28/08/2026

A infraestrutura que serve os nossos produtos é deliberadamente pequena: uma VPS, tudo em docker compose, e nenhuma porta de serviço aberta para a internet. Nem 80, nem 443. Um scan externo da caixa encontra SSH e mais nada. E o SSH existe para uma única função, que não é deploy.

Todo o tráfego HTTP entra por um Cloudflare Tunnel: um conector (cloudflared) roda na caixa e abre a conexão de dentro para fora. A borda da Cloudflare recebe as requisições dos hostnames, os endereços públicos de cada serviço, e as entrega pelo túnel ao container certo. Nenhum serviço escuta a internet. Todos escutam apenas a rede interna do Docker.

Este post é a anatomia desse desenho. Não como propaganda de simplicidade, mas como um conjunto de decisões com custos conhecidos. Três armadilhas que custaram incidente. E as fronteiras de confiança que fazem uma caixa única ser defensável.


O mapa: três classes de hostname (e a classe sem hostname)

Numa caixa que serve vários produtos, a pergunta certa não é “qual porta cada serviço usa”. É quem pode chegar em cada um. O mapa tem quatro classes:

  1. Público de verdade. A API do produto principal, com autenticação própria (API key) e quota anônima para o free tier. É a única classe que aceita o mundo.
  2. Público, mas atrás de Cloudflare Access. As interfaces administrativas: o orquestrador de automações, o painel de operação. Têm hostname, mas a borda exige identidade antes de a requisição tocar o túnel.
  3. Fora da caixa. Sites estáticos servidos por Cloudflare Pages e uma página de status servida por um Worker. Isso é de propósito: a página de status de um serviço não pode morar no serviço (já volto nisso).
  4. Sem hostname nenhum. Os serviços internos, o cérebro dos robôs e o serviço de agentes, não têm rota no túnel. Não é pendência: é o desenho. A pergunta “como acesso de fora?” tem como resposta “não acessa”.

A quarta classe é a mais barata e a mais subestimada. Um serviço sem rota pública não precisa de autenticação perfeita, rate limiting ou WAF (firewall de aplicação). A rede docker é a fronteira. Os endpoints internos (/interno/*) só são alcançáveis por quem já está dentro da rede. O esforço de segurança se concentra onde há exposição de fato.

Access em vez de login próprio

As interfaces administrativas não têm login nosso. A autenticação é o Cloudflare Access: a borda exige identidade corporativa, e só então encaminha. A propriedade importante desse arranjo merece ser dita com precisão: remover o Access não degrada a proteção, remove a proteção. Não existe uma segunda camada de senha esperando atrás. Existe a decisão explícita de que a identidade na borda é a autenticação.

Isso só é aceitável com duas disciplinas:

  • O serviço valida o JWT do Access, o token assinado que a borda anexa, e não confia no roteamento. O painel de operação verifica a assinatura do token (JWKS do time, RS256, audiência e emissor) em ~40 linhas sobre a biblioteca de criptografia que já era dependência. E o e-mail do usuário sai de dentro do JWT assinado, nunca do header de conveniência que o Access também manda. Header sem assinatura é decoração.
  • Falha fechada. Sem as variáveis de configuração do Access, o painel responde 503 com instrução, nunca “temporariamente sem auth”. Um painel que degrada para aberto é pior que um painel que não existe.

As três armadilhas que custaram incidente

1. O conector não pode morar no compose de um app

O cloudflared começou a vida dentro do projeto compose de um dos produtos, o primeiro que precisou dele. Funcionou até o dia em que esse produto foi aposentado. Derrubar o projeto derrubaria o túnel da caixa inteira, incluindo os serviços que continuavam no ar.

A regra que ficou: o túnel serve a caixa, então vive em projeto compose próprio, num diretório próprio, sem compartilhar ciclo de vida com nenhum app. Infraestrutura transversal empacotada dentro de um app é uma bomba com o timer ajustado para o sunset desse app.

2. --remove-orphans numa caixa multi-projeto

Numa caixa com vários projetos compose, docker compose down --remove-orphans num projeto remove containers de outros projetos, os que o compose considera órfãos do seu ponto de vista. A flag que é higiene num repositório único é fogo amigo numa caixa compartilhada. A defesa é boba e funciona: a flag é proibida na caixa, por escrito, no runbook.

3. O ingress do túnel não está no git

A configuração de qual hostname aponta para qual serviço vive no painel da Cloudflare (token mode), não num arquivo versionado. Isso tem um custo conhecido. Quando um serviço morre, alguém precisa lembrar de remover o hostname do ingress, a lista de rotas do túnel. Um hostname que fica apontando para um origin que não existe mais serve 502 para o mundo e para o Googlebot, indefinidamente. Esse modo de falha rendeu um incidente de SEO com post próprio. A regra operacional que ficou: derrube a rota antes do container, nunca o contrário.

Deploy: dois caminhos, duas garantias diferentes

A mesma caixa recebe deploys por dois caminhos, e a diferença entre eles é uma lição em si:

  • Caminho A: runner self-hosted na caixa. Push em main → CI → o runner roda o script de redeploy. Ele reconstrói, espera o healthcheck (a checagem de saúde do serviço) e faz rollback automático, ou seja, volta ao build anterior, se o build novo não ficar saudável. CI vermelho bloqueia a publicação.
  • Caminho B: Cloudflare Pages. O build acontece na Cloudflare a partir do git. CI vermelho não bloqueia nada: o portão de qualidade é consultivo, e o rollback é manual.

Os dois caminhos são legítimos. O perigo é não saber em qual você está. Quando a cota de minutos do GitHub Actions da conta acabou, o caminho A parou de publicar, e foi notado no dia. O caminho B continuou publicando sem portão nenhum, e ninguém notou por uma semana. O acoplamento entre verificação e publicação, que é defeito na teoria, funciona como detector na prática.

Quem vigia não mora na caixa

Uma caixa única concentra outro risco: o monitoramento que mora nela morre com ela. O desenho usa três laços independentes, escolhidos pela pergunta que cada um responde:

  • De fora: um monitor externo de uptime nas URLs públicas, e a página de status servida por um Worker na borda. Esse Worker sonda, guarda histórico e renderiza sem tocar a caixa. A versão anterior da sonda rodava por GitHub Actions. Quando a cota morreu, a página congelou e, pior, a sonda era o alarme. Ela não podia migrar para o runner self-hosted, porque esse runner é a caixa vigiada. Uma sonda que mora na máquina que monitora reporta silêncio quando a máquina cai, e silêncio parece “tudo bem”.
  • De dentro: um guardião em cron, a agenda de tarefas do sistema, checa disco, containers unhealthy, containers parados que deviam estar de pé e um healthcheck profundo da aplicação. Depois pinga um healthcheck externo com o resultado. Se a caixa morre, o ping para, e a ausência é o alerta (dead-man switch).
  • Do processo: rastreio de erros de aplicação, configurado para só 5xx. 401, 413 e 429 são a API funcionando como documentado. Alertar sobre eles treina quem opera a ignorar o alarme.

As fronteiras, nomeadas

O que faz uma caixa única ser defensável não é a caixa. É saber exatamente onde passam as fronteiras de confiança:

  1. A rede docker é fronteira de verdade. O que não tem rota no túnel não é alcançável de fora, ponto. A segurança dos serviços internos começa aí, não em autenticação perfeita.
  2. Identidade na borda para admin. Access na frente, JWT validado dentro, falha fechada.
  3. Nenhum segredo no git. URLs de healthcheck (que são credencial: quem as conhece silencia o alarme), tokens e .env reais vivem fora do repositório; os repositórios carregam .env.example com a forma.
  4. O SSH existe para o backup puxar, e só. Nenhum deploy passa por SSH: deploy é o runner + script com healthcheck e rollback. A máquina de backup puxa os dumps; a VPS não tem credencial para escrever fora dela.

O trade-off, dito em voz alta

Uma caixa é um domínio de falha único. O dia em que ela morrer, morre tudo que mora nela. O desenho aceita isso de olhos abertos, porque o custo de operar é de uma pessoa só. E a mitigação não é redundância: é recuperabilidade medida. São três camadas de backup: dumps locais, snapshots do provedor, cópia puxada para fora. Mais um ensaio de restore com números de RTO e RPO (quanto tempo até voltar, quanto dado se perde). E uma tabela de incidentes que mapeia sintoma → causa provável. Por exemplo: “todos os hostnames caíram ao mesmo tempo” → o conector, não os apps; “silêncio do guardião por mais de 1h30” → a caixa.

Para um produto que precisa de five nines, esse desenho é errado. Para uma operação solo com produtos reais no ar, a alternativa realista não é um cluster. É a mesma caixa, com as fronteiras mal desenhadas. O túnel sem porta aberta, o Access sem login próprio, o interno sem hostname e o vigia do lado de fora custam, somados, um dia de configuração. O que eles compram é uma superfície de ataque que cabe numa frase, e incidente com causa encontrável.

Infraestrutura que escala sem quebrar o orçamento

Conta de cloud fora de controle? Opero a minha própria numa VPS única, sem porta aberta, com deploy automático e rollback por healthcheck. O desenho inteiro está publicado aqui.

Ver os posts de infraestrutura →