2

Como rodo 6 apps em produção numa VPS de 3,6 GB com Docker Swarm + Traefik (e os perrengues que os tutoriais não contam)

Sou dev e professor universitário, e nos últimos meses coloquei no ar vários projetos pessoais: um app de finanças com agente de WhatsApp, um to-do multiusuário, um sistema de cardápio para marmitarias, um site de indicação de presentes, um blog privado e um app para organizar pelada de futebol. Tudo roda numa única VPS de 3,6 GB de RAM, junto com n8n, Evolution API, Chatwoot e MinIO.

Este post é o relato honesto de como isso está montado, quanto custa e o que quebrou no caminho. Os tutoriais de VPS costumam parar no "hello world". Os problemas de verdade aparecem depois.

Por que não Vercel / Render?

Para um projeto, Vercel e Render são ótimos. O problema começa no segundo: cada app vira API + front + banco + worker, e cada um desses é cobrado separado, ou dorme no plano grátis. Com 6 projetos, a conta não fecha para quem ainda não fatura com eles.

Na VPS eu pago um valor fixo de cerca de R$ 75 por mês (plano de 3 anos), e o projeto número 7 custa zero a mais (até a RAM acabar, mas já chego nisso).

A arquitetura

                 Internet
                    │
             ┌──────▼──────┐
             │   Traefik   │  HTTPS automático (Let's Encrypt)
             └──────┬──────┘
          rede overlay "externa"
   ┌────────┬───────┼────────┬─────────┐
 app1-web app1-api app2   app3 ...   Portainer
   └────────┴───────┬────────┘
          rede overlay "interna"
             ┌──────▼──────┐
             │  Postgres   │  um banco por projeto
             └─────────────┘
  • Docker Swarm de um nó só. Parece exagero para uma máquina, mas o Swarm me dá docker service update com troca de versão sem derrubar o site, rollback e secrets. O Compose puro não faz isso.
  • Traefik lê os labels de cada serviço e cria a rota e o certificado sozinho. Subir um app novo é só criar um registro DNS e colocar os labels.
  • Duas redes overlay. A externa é onde o Traefik enxerga os apps. A interna é onde fica o Postgres. O banco nunca fica exposto para fora.
  • Um Postgres compartilhado (imagem pgvector/pg16) com um banco e um usuário por projeto. Rodar um Postgres por app numa máquina de 3,6 GB seria desperdício de RAM.
  • Portainer para olhar logs e o estado dos serviços sem precisar de SSH a toda hora.

Os labels de um serviço ficam assim (no Swarm, eles vão dentro de deploy, não no nível do serviço; esse é um erro clássico):

services:
  web:
    image: meuusuario/apps:meuapp-web-latest
    networks: [externa, interna]
    deploy:
      labels:
        - traefik.enable=true
        - traefik.http.routers.meuapp.rule=Host(`meuapp.meudominio.com.br`)
        - traefik.http.routers.meuapp.entrypoints=websecure
        - traefik.http.routers.meuapp.tls.certresolver=le
        - traefik.http.services.meuapp.loadbalancer.server.port=3000

O deploy: git push e pronto

O fluxo é git push → GitHub Actions faz o build → envia a imagem para o Docker Hub → entra na VPS por SSH → docker service update.

Até chegar nisso, apanhei de três coisas.

1. Docker Hub grátis só tem 1 repositório privado

A saída foi usar um único repositório privado (apps) com tags por projeto: apps:moneymap-api-<sha>, apps:prazocerto-web-<sha> e assim por diante. Funciona perfeitamente para dezenas de projetos.

Detalhe que custou um deploy: o Docker Hub não aceita maiúsculas no nome do usuário nem do repositório. MeuUsuario/apps quebra.

2. O webhook do Portainer é pago

(Atualização: como lembraram nos comentários, o Portainer libera a Business grátis para até 3 nós (programa Take 3))

Eu ia usar o webhook de stack do Portainer para o GitHub avisar "tem versão nova". Esse recurso é do plano Business. A solução foi o próprio Actions entrar por SSH, com uma chave só para deploy, e rodar o update. Fica até mais simples de depurar.

3. O "pull access denied" que não era permissão

Esse me fez perder horas. De vez em quando o deploy falhava com pull access denied. Revisei token, login e permissão do repositório, e estava tudo certo.

Era uma corrida: o Actions terminava o push e mandava a VPS puxar a imagem antes de ela terminar de propagar no Docker Hub. A correção foi um retry simples antes do update:

for i in 1 2 3 4 5 6; do
  docker pull meuusuario/apps:meuapp-api-$SHA && break
  echo "imagem ainda não propagou, tentando de novo em 10s..."
  sleep 10
done
docker service update --with-registry-auth \
  --image meuusuario/apps:meuapp-api-$SHA meuapp_api

Depois disso, nunca mais falhou. Uma variação: se você esquecer o docker login na VPS, o erro é o mesmo e o retry fica em loop. A mensagem não ajuda a distinguir os dois casos.

O que quebrou em produção

Todos os subdomínios fora do ar ao mesmo tempo

Um dia, todos os apps começaram a responder ERR_CONNECTION_REFUSED, com a VPS de pé e o SSH funcionando. O Traefik não subia, com o erro network sandbox join failed.

A causa era uma interface VXLAN "presa" depois de um restart sujo do Docker, um bug conhecido do Swarm. Como o Traefik é a porta de entrada de tudo, um único ponto travado derrubou todos os projetos. A correção:

ip -d link show type vxlan       # acha a interface órfã (vx-xxxxxx)
sudo ip link delete vx-xxxxxx
sudo systemctl restart docker

A lição é que, num nó só, o Traefik é um ponto único de falha. Vale saber esse procedimento antes de precisar dele.

3,6 GB de RAM é apertado (e o disco enche escondido)

Com tudo rodando, a memória disponível fica perto de 2,4 GB e a máquina usa o swap de 6 GB. O que aprendi:

  • Olhe a memória "available", não a "free". O Linux usa RAM livre como cache, então "free" baixo não é problema.
  • docker stats --no-stream mostra quem está comendo memória. No meu caso, o vilão é o n8n em modo fila (uns 500 MB só para ele).
  • Build cache enche o disco em silêncio. Um docker builder prune me devolveu 15,6 GB. Somando imagens e volumes de projetos antigos, o disco caiu de 66 GB para 46 GB.

A senha do Traefik que "não funcionava"

O basic auth do dashboard do Traefik usa hash com $. No YAML da stack, cada $ precisa virar $$, senão o Docker interpreta como variável e a senha vira outra coisa. Não aparece erro nenhum, só "senha incorreta".

A chave SSH no repositório

Já commitei uma chave privada sem querer. Apagar o arquivo não adianta, porque ela continua no histórico. Precisei de git filter-repo e force-push, e depois troquei a chave. Hoje a chave de deploy é exclusiva para isso e fica só nos secrets do GitHub.

Quando NÃO fazer isso

  • Você tem um projeto só: fique no plano grátis da Vercel ou do Render.
  • Você não quer cuidar de servidor: Coolify e Dokploy fazem boa parte disso com interface gráfica. Eu preferi montar na mão porque é o mesmo padrão que uso no trabalho e quero entender cada peça quando algo quebra.
  • Você precisa de alta disponibilidade: um nó só é um ponto único de falha, como a história do VXLAN mostrou.

Agora, se você tem 3 ou mais projetos pequenos, quer HTTPS e deploy automático e não quer a conta crescendo a cada app novo, esse arranjo vale muito a pena.


Organizei tudo isso num único material pago: template (stacks do Traefik, Portainer e Postgres, workflow do Actions com o retry, script de preparação da VPS) e um guia de 27 páginas, que testei do zero numa VPS limpa antes de publicar: Do Zero ao Deploy. Mas tudo o que está neste post dá para fazer sozinho, e fico feliz em responder dúvidas aqui nos comentários.

Carregando publicação patrocinada...
1

Uso um sistema muito parecido com este, com sistema de 0 downtime com docker swarm + nginx conectando em cada imagem docker, uma dica vc pode só criar 1 repositorio no docker hub mas o github é ilimitado, eu buildo minhas imagens no ghcr https://docs.github.com/pt/packages/working-with-a-github-packages-registry/working-with-the-container-registry ai o ci cd meio q já cria as imagens de api, telemetria, frontend, nginx e entra via chave ssh mandando todo o relatório de deploy com migrations etc pro meu discord ai acabo tendo várias imagens (detalhe repositórios privados também podem hospedar imagens e pacotes) e uso um sistema para caso algo de errado nos testes etc não subir a build corrompida com testcontainers então sempre fico bem tranquilo, é mais caso alguma migration de banco de erro etc eu ja receber o log no discord, ai fico de olho nos alertas do grafana e por aqui ta tudo muito tranquilo nos ultimos 2 anos, sem me preocupar com prod, e tbm autoescalavel com o docker swarm, alem de o msm docker-compose de prod ser o de dev com menos réplicas. para equipes pequenas acho um máximo

2

Valeu, que setup completo! O GHCR é uma ótima saída para o limite do Docker Hub, e ainda tem a vantagem de o Actions autenticar direto com o GITHUB_TOKEN, sem precisar de token extra nos secrets. Acho que vou migrar.

Gostei muito da ideia de mandar o relatório do deploy, com as migrations, pro Discord. Hoje eu só descubro que algo deu errado olhando o Actions. Você usa webhook direto no step final do workflow ou alguma action pronta?

E os testcontainers rodam no próprio CI antes do push da imagem? Faz todo sentido barrar a build lá, antes de chegar na VPS.

1

uso no próprio ci cd uma secret com o link do dicord( o discord permite tu se for dono de um server copiar a url daquele canal ai ele automaticamente vira um webhook, ai tu pode ter um ci-cd logs e um ci-cd errors), eu deixo mutado os logs e recebo som só do errors, ai também tenho no grafana etc uns alertas customizados para caso alguma api retorne erro 500 interno, ou que a cpu suba mt e precise automaticamente aloca mais uma imagem docker swarm para dar conta de processar tudo etc, uso os webhooks do discord a rodo para isto.

Sobre os testcontainers rodam sim, eu uso o nx para controlar apps, tenho uns 15 clientes usando os apps etc, ai muitas vezes eu corrijo um bug em algum serviço o próprio nx build já sabe qual código foi alterado e precisa ser buildado, fazendo rodar testes apenas nos afetados

ai separo minhas imagens docker em major middle e minor

código minor só roda os testes dele mesmo
código middle roda os testes de integrações
código major vai rodar e2e testecontainers, integração etc

ai fica algo tipo ghcr.io/juliovianadev/project:12.4.3 ou ghcr.io/juliovianadev/project:main-51bf1f2ai

ai depende se tu vai se orientar por minor ou hash é um pouco de preciosismo demais, mas enquanto o servidor não cair do provedor tenho 0 downtime e perca de mensagens ou pagamentos etc, as requisições entre o nginx e o docker swarm interagem muito bem, ai o git roda os comandos e manda os relatórios

Caso você queira também (em projetos com ai hard eu usava isso) tu pode rodar os testcontainers em uma vps separada, ai funcionaria assim:

você cria uma PR
dispara um action request pra vps e bloqueia o merge na main
vps roda todos os testes containers valida tudo sem gastar os minutos de actions
vps após os logs dispara comentários na pr explicando tudo e desbloqueia para merge( pode inclusive mergear caso seus critérios valham), ai se quiser ir ainda mais longe pode utilizar essas bibliotecas para printar os reports:

https://tanstack.com/markdown/latest/docs/guides/react
https://tanstack.com/highlight/latest/docs/comparison

1

Pelo que entendi você está rodando o Postgres dentro dessa mesma máquina, mas sempre vi isso como um risco, pois a hospedagem pode querer mover sua VM para outro server, e para isso ela vai subir uma cópia da sua VM em outra instância e depois derrubar a instância atual.

Se algo estiver sendo gravado nesse período você pode perder dados. Por conta disso, vale considerar um serviço de banco de dados gerenciado como o Supabase, e S3 ou similar para object storage.

1

Bom ponto! Num live migration o provedor copia memória e disco e só troca no final, e o Postgres só confirma a transação depois de gravar no WAL. Então esse cenário específico não costuma perder dado confirmado.

O risco que eu considero maior é o disco ou a VPS em si: falha de hardware, corrupção, um rm errado. E aí você me pegou: hoje meu backup ainda fica na mesma máquina, e mandar para fora (S3 ou similar) é o próximo item da lista. Valeu por levantar isso.

Concordo que, para dado crítico de cliente pagando, um banco gerenciado (Supabase, Neon, RDS) ou ao menos backup com point-in-time recovery é o caminho. Para projetos pequenos, o custo de um banco gerenciado por projeto é justamente o que eu quis evitar. É um trade-off consciente.

1
1

Boa, não conhecia o Take 3! Para um nó só cabe com folga, e libera o webhook que eu tinha descartado.

Mesmo assim, acho que vou manter o deploy por SSH por dois motivos: a lógica do deploy fica versionada junto com o workflow (inclusive o retry do pull), e o deploy não depende do Portainer estar no ar nem de lembrar de renovar a licença. Mas para quem prefere disparar pelo webhook e acompanhar tudo pela interface, é uma ótima alternativa. Valeu pela dica!