Deploy em servidor Docker compartilhado
Quando usar
- O servidor não é dedicado: já executa deploys de outras pessoas ou outros projetos.
- A aplicação tem dependência nativa (
better-sqlite3,sharp,canvas) ou navegador (Chromium). - Já existe um proxy reverso (Caddy ou nginx) que publica vários domínios.
- Qualquer
EADDRINUSE, OOM ou disco cheio afetaria os outros stacks.
Regra de ouro: a nova aplicação não pode ser a causa da queda de outra. Por isso, tudo é delimitado e medido antes.
0. Antes de construir: medir e liberar disco
Um build com Chromium baixa ~2 GB de imagem. Se o disco está em 78%, o build falha ou deixa o servidor no limite, e o próximo prune apaga imagens de produção.
ssh SERVIDOR 'docker system df; df -h /'
O que geralmente está solto e é seguro liberar:
docker builder prune -af # cache de build (geralmente o mais pesado)
docker image prune -af # imagens não usadas
docker container prune -f # contêineres parados
Antes e depois, conte os contêineres para confirmar que nada caiu:
docker ps -q | wc -l # guardar o número, comparar depois
Medido em um caso real: 78% → 32% recuperados, com os 41 contêineres de produção intactos.
1. Módulos nativos e versão do Node
Um arquivo .node compilado para outra major do Node não falha com erro claro: ele aborta o processo com um assert interno e um stack nativo inútil.
Sintoma típico:
# node.exe[61084]: void __cdecl node::RemoveEnvironmentCleanupHook(Isolate *, CleanupHook, void *)
# Assertion failed: (env) != nullptr
----- Native stack trace -----
Causa: better-sqlite3@11 com Node 24. O suporte vem no better-sqlite3@12.
Regras:
- Verificar a versão do módulo nativo contra a major do Node antes de integrar.
- No
Dockerfilede build, instalar o toolchain (python3 make g++) para que um binário pré-compilado que não exista possa ser compilado na hora. - Em runtime, não assumir que o toolchain é necessário: melhor uma imagem final menor.
- Se o módulo é importado de vários pacotes do workspace, testá-lo em um processo que não faça mais nada: um crash por GC postergado pode aparecer horas depois.
2. Separar o processo pesado do que responde
Chromium e um módulo nativo no mesmo processo que o servidor HTTP causam crash e, além disso, bloqueiam o event loop durante os jobs longos.
api : HTTP + SSE, memória baixa, healthcheck HTTP
worker : fila + navegador, memória alta, sem portas, sem healthcheck HTTP
Eles se comunicam por um armazenamento compartilhado (SQLite, Redis, Postgres). A API não precisa saber se o worker está vivo.
O healthcheck é definido apenas no que serve HTTP. Herdá-lo do Dockerfile no worker o deixa unhealthy para sempre, porque ele nunca abre uma porta:
worker:
healthcheck:
disable: true
3. Delimitar memória por serviço
api:
mem_limit: 400m
worker:
mem_limit: 1600m
mem_reservation: 400m
volumes:
- /dev/shm:/dev/shm:rw,size=256m # Chromium usa /dev/shm; 64 MB o quebra
tmpfs:
- /tmp:size=256m
security_opt:
- no-new-privileges:true
Além disso, no código, reduzir o trabalho: o scraper deve ter um DETAIL_MAX / MAX_PAGES mais baixo no servidor do que localmente. Se algum dia faltar RAM no servidor, o culpado é quase sempre esse número, não o Docker.
Medir antes e depois de uma busca real:
docker stats --no-stream --format '{{.Name}}\t{{.MemUsage}}\t{{.CPUPerc}}' <api> <worker>
free -m | head -2
4. Build: estágios e caches que realmente funcionam
FROM mcr.microsoft.com/playwright:v1.63.0-noble AS build # versão fixada ao pacote npm
...
FROM mcr.microsoft.com/playwright:v1.63.0-noble AS runtime
- Fixar a versão da imagem à do pacote npm instalado. Se não coincidirem, o navegador não está onde o Playwright o procura.
- Copiar primeiro os
package.jsone depois o código, para queCOPY . .não invalide a camada deinstall. - Em runtime,
pnpm/npmsão necessários se os scripts de inicialização os usam →corepack enable. - Se
node_modulesé copiado do build e as permissões não coincidem, o pnpm tenta "repará-lo" a cada inicialização e falha. Isso é desativado comverify-deps-before-run=falseem um.npmrcda imagem. - Usuário não root em runtime. As imagens do Playwright já trazem
pwuser.
pnpm-lock.yaml deve ser commitado e o install do build deve ser com --frozen-lockfile.
5. Publicar por um proxy que já existe
Se já existe um Caddy publicando vários domínios, não levantar outro proxy nem abrir outra porta pública: adiciona-se um bloco ao Caddyfile existente e conecta-se à rede Docker.
services:
api:
networks: [edge_proxy] # rede externa, já criada
networks:
edge_proxy:
external: true
name: edge_proxy
Descobrir a rede e onde vive o Caddyfile:
docker inspect <proxy> --format '{{range .Mounts}}{{.Source}} -> {{.Destination}} rw={{.RW}}{{println}}{{end}}'
Editar o arquivo no host: se o bind mount é rw=false, escrever dentro do contêiner falha com Resource busy ou se perde ao reiniciar.
Procedimento seguro (nunca editar às cegas):
cp "$CADDYFILE" "$CADDYFILE.bak-$(date +%Y%m%d-%H%M%S)" # 1. backup
# 2. adicionar o bloco
docker exec <proxy> caddy validate --config /etc/caddy/Caddyfile # 3. validar
docker exec <proxy> caddy reload --config /etc/caddy/Caddyfile # 4. recarregar (sem downtime)
Se validate falhar, restaurar o backup. O reload não corta as conexões existentes.
Autenticação básica
prospector.exemplo.dev {
basic_auth {
leo $2a$14$... # bcrypt, NUNCA em texto claro
}
reverse_proxy api:3000 {
flush_interval -1 # imprescindível para SSE
}
encode zstd gzip
header {
X-Frame-Options DENY
-Server
}
}
- Gerar o hash no servidor:
docker exec <proxy> caddy hash-password --plaintext '...'. - Guardar a senha em texto claro apenas em um arquivo root (
/root/<app>-password.txt, 600) para poder recuperá-la ou rotacioná-la, e nunca no repositório. - Não usar o proxy do Cloudflare na frente se a aplicação usa SSE: o timeout de 100 s e o buffering cortam o progresso. DNS-only (nuvem cinza) permite que a origem emita o certificado real.
basic_authprotege apenas o que passa pelo proxy. Se a aplicação também é exposta por outra porta, fica sem proteção: documente isso.
6. Implantar a partir do Windows
rsync não existe no Windows. O que funciona e é multiplataforma é tar por pipe SSH:
const tar = spawn('tar', ['-cf', '-', '--exclude', './node_modules', ...excludes, '.'], { cwd: ROOT });
const ssh = spawn('ssh', [HOST, `mkdir -p ${DIR} && tar -xf - -C ${DIR}`], { stdio: ['pipe', 'inherit', 'inherit'] });
tar.stdout.pipe(ssh.stdin);
spawn com shell: true é obrigatório para resolver pnpm no Windows (é um .cmd), mas então não é possível passar handles de arquivo para redirecionar a saída: use a variável de ambiente LOG_FILE e deixe que a própria aplicação escreva o log.
7. Antes de tocar no servidor compartilhado
-
docker ps -q | wc -lguardado, para comparar no final - Espaço em disco suficiente após liberar
-
mem_limitem todos os serviços novos - O processo pesado em seu próprio contêiner, sem portas
- Healthcheck apenas em quem serve HTTP
- Rede externa do proxy declarada, e não uma rede nova
- Backup do Caddyfile e
caddy validateantes do reload - Log em arquivo (o processo fica sem console)
- Script de deploy idempotente:
check / push / build / up / logs / stats / backup - Volume com backup testado e restaurado, não apenas criado
Diagnóstico rápido
docker stats --no-stream --format '{{.Name}} {{.MemUsage}} {{.CPUPerc}}' <api> <worker>
docker logs --tail 80 <worker>
docker exec <api> node -e "fetch('http://127.0.0.1:3000/api/health').then(r=>r.text()).then(console.log)"
free -m && df -h /