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 updatecom 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. Ainternaé 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-streammostra 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 pruneme 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.