kelvin@cluxnei.dev:~/blog

kelvin@cluxnei:~/blog$ cat 2gb/04-o-deploy.md

2026-09-20 PT ~20 min de leitura read in English

O que você sobe não é a sua pasta

A máquina está legível, o processo supervisionado, a porta de entrada distribuindo. Falta a pergunta que você vem adiando: como o código chega lá. A resposta que todo mundo dá primeiro é git pull no diretório que está no ar, e ela esconde a classe de bug mais traiçoeira que existe, porque ela nunca reproduz na sua máquina.

Neste texto

  1. O artefato não é a sua pasta
  2. Releases numeradas e a troca atômica
  3. Rollback em um comando
  4. Onde buildar, quando o build não cabe
  5. Segredos que não viajam
  6. Migrações e a ordem que ninguém pensa
  7. O banco: a conta que ficou aberta na parte 1
  8. O script inteiro
  9. O checklist

O artefato não é a sua pasta

Existe uma diferença entre o código e o que roda em produção, e ela some quando você copia sua pasta de trabalho para o servidor. O que viaja junto, sem convite:

O padrão é sempre o mesmo, e é o que torna esse bug tão caro: ele depende de uma coisa existir na sua máquina e não existir no servidor. Localmente tudo funciona, porque localmente a peça que falta está instalada. O erro só aparece em produção e não tem como reproduzir, porque reproduzir exigiria não ter o que você tem.

A regra que resolve é de uma frase: o que sobe é um artefato construído, e você declara o que entra nele. Na prática:

rsync -az --delete \ --exclude '.git' \ --exclude 'node_modules' \ --exclude '.env*' \ --exclude 'tests/' \ --exclude 'storage/framework/cache/' \ --exclude 'bootstrap/cache/' \ ./ deploy@servidor:/srv/minhaapp/releases/2026-09-20-1432/

E uma observação sobre essa lista: ela é uma lista de proibições, e toda lista de proibições está errada em algum item que você ainda não descobriu. Se o seu projeto permite, prefira o contrário: construa um diretório limpo, copie para dentro dele só o que é necessário, e envie esse diretório. O que não foi convidado não vai.

Releases numeradas e a troca atômica

O layout que resolve deploy, rollback e arquivos persistentes de uma vez:

/srv/minhaapp/ ├── releases/ │ ├── 2026-09-18-2210/ │ ├── 2026-09-19-0930/ │ └── 2026-09-20-1432/ a nova ├── shared/ │ ├── .env segredos, fora de toda release │ ├── uploads/ o que usuários enviaram │ └── storage/ └── current -> releases/2026-09-20-1432

current é um link simbólico, e é para ele que o nginx e a unidade do systemd apontam. Publicar é mudar esse link. E aqui mora um detalhe que quase todo script de deploy caseiro erra.

O jeito intuitivo é apagar o link e criar de novo:

rm -f current ln -s releases/2026-09-20-1432 current

Entre esses dois comandos, current não existe. Qualquer requisição que chegar nessa janela encontra um caminho quebrado. Parece teórico. Montei um leitor abrindo current/index.html num laço fechado, e um trocador alternando entre duas releases pelos dois métodos, seis segundos cada:

# rm + ln -s leituras ok: 129.749 FileNotFoundError: 667.736 # ln -sfn + mv -T leituras ok: 305.276 FileNotFoundError: 0

O primeiro método falhou em 84% das leituras. Sendo justo com o número: o trocador estava alternando sem parar, o que não é um deploy real, onde a troca acontece uma vez. O que o teste mostra não é a taxa, é que a janela existe e é grande. O segundo método não falhou nenhuma vez em trezentas mil leituras, e não é sorte:

ln -sfn releases/2026-09-20-1432 current.novo mv -T current.novo current

O mv -T vira uma chamada rename(), e o rename() é atômico por garantia do kernel. Em qualquer instante, current aponta para a release antiga ou para a nova. Não existe instante em que ele não aponte para nada.

Um detalhe que anula tudo isso: se a configuração do nginx usa root /srv/minhaapp/current/public, ele resolve o link a cada requisição e a troca funciona. Mas o nginx guarda um cache de descritores abertos quando você liga open_file_cache, e aí ele continua servindo a release antiga por alguns segundos. Depois da troca, um systemctl reload nginx resolve.

Rollback em um comando

Essa é a razão de guardar as releases antigas em vez de sobrescrever. Voltar é a mesma operação atômica apontando para trás:

# voltar para a release anterior anterior=$(ls -1t /srv/minhaapp/releases | sed -n 2p) ln -sfn /srv/minhaapp/releases/$anterior /srv/minhaapp/current.novo mv -T /srv/minhaapp/current.novo /srv/minhaapp/current systemctl restart minhaapp

Segundos, e sem rede. Guarde cinco releases e apague o resto, porque cada uma ocupa disco e disco é a outra parede da parte 1.

Rollback de código é barato. Rollback de banco não existe, e é por isso que a seção das migrações é a mais delicada deste texto.

Onde buildar, quando o build não cabe

Numa VPS de 2 GB essa é uma decisão de verdade, não uma preferência. Medi dois passos que todo projeto JavaScript tem, com o próprio systemd contando o pico:

passopico de memóriatemporesultado
npm install, 4 dependências de dev406,4 MB8,7 s67 MB em disco
webpack, 401 módulos, 1,7 MB de fonte438,4 MB17,0 sbundle de 48 KB

Dois resultados que vale destacar. O primeiro é que instalar custa quase o mesmo que compilar, e quase ninguém conta com isso. O segundo é a desproporção do segundo passo: 438 MB de pico e 17 segundos de CPU para produzir 48 KB.

Agora junte com o orçamento da parte 1. Se a sua pilha já usa 600 MB em repouso, um pico de 438 MB deixa você a poucas centenas de megabytes da parede. E se o build rodar junto com um pico de tráfego, quem morre não é o build: pela parte 1, o OOM killer escolhe o maior processo, que provavelmente é o seu banco de dados.

Rodei o mesmo build com o teto da parte 2, o que sobraria numa caixa ocupada:

systemd-run --property=MemoryMax=256M ... npx webpack b-wp4.service: Failed with result 'oom-kill' b-wp4.service: Consumed 8.119s CPU time, 256M memory peak. bundle: NAO gerado

Morreu, e morreu sozinho. Nada mais na máquina foi afetado. Isso organiza as três opções em ordem de preferência:

  1. Buildar fora e enviar o artefato pronto. Na sua máquina, num runner de CI, em qualquer lugar que não seja o servidor que está atendendo. O servidor recebe arquivos, não compila nada. É a melhor opção e quase sempre é possível.
  2. Buildar no servidor, com teto. Se precisa mesmo, envolva o build num systemd-run com MemoryMax. O build ou termina, ou morre sozinho e o deploy falha de forma limpa. Um deploy que falha é infinitamente melhor que um site que cai.
  3. Buildar no servidor, sem teto. É a opção que quase todo mundo escolhe por padrão, e é a que troca um erro de deploy por uma queda do banco.

Segredos que não viajam

O .env mora em shared/ e é linkado para dentro de cada release. Ele nunca está no repositório e nunca é copiado pelo rsync:

ln -sfn /srv/minhaapp/shared/.env /srv/minhaapp/releases/NOVA/.env chown deploy:deploy /srv/minhaapp/shared/.env chmod 600 /srv/minhaapp/shared/.env

O 600 importa mais do que parece numa máquina compartilhada com outros serviços: o padrão 644 deixa qualquer usuário do sistema ler suas senhas de banco. E na parte 2 a unidade do systemd já lia esse arquivo com EnvironmentFile, então a aplicação recebe as variáveis sem que elas passem pela linha de comando, onde apareceriam no ps para qualquer um.

O mesmo vale para uploads/. Se ele mora dentro da release, o primeiro deploy apaga tudo que os usuários enviaram. Ele mora em shared/, e a release recebe um link.

Migrações e a ordem que ninguém pensa

Aqui está a parte do deploy que não tem desfazer, e o erro é sempre o mesmo: tratar código e schema como se mudassem no mesmo instante. Eles não mudam. Entre rodar a migração e trocar o symlink existe uma janela em que o código antigo está no ar com o schema novo.

Renomear uma coluna é o exemplo mais curto. Você renomeia nome para nome_completo, e o código antigo, que ainda está servindo requisições, procura nome e quebra em toda requisição até a troca terminar.

A saída é fazer toda mudança em duas partidas, o que costuma se chamar expand and contract:

deploy 1, expandir migração: adiciona nome_completo, copia os dados, mantém nome código: escreve nos dois, lê de nome_completo com fallback deploy 2, contrair (dias depois, com o deploy 1 estável) código: só usa nome_completo migração: remove nome

Em cada instante, o código que está no ar funciona com o schema que está no banco. É mais trabalho, e é a diferença entre um deploy que pode ser revertido e um que não pode.

Antes de qualquer migração destrutiva, um dump. pg_dump num arquivo com a data, antes de rodar. É a única coisa entre você e uma coluna que não volta. A parte 5 trata de backup que foi testado, que é outra história.

O banco: a conta que ficou aberta na parte 1

Na parte 1 eu medi que o Postgres ocioso custa cerca de 20 MB, e que cada conexão ociosa acrescenta uns 2,5 MB. Ficou faltando o outro número, o que aparece quando as conexões estão trabalhando.

O work_mem é a quantidade de memória que uma operação de ordenação ou hash pode usar antes de derramar para disco. Ele não é um orçamento global. Ele é por operação, por conexão. Medi com 20 conexões simultâneas ordenando 1,5 milhão de linhas cada, mudando só esse parâmetro:

work_memociosopico com 20 ordenações
4 MB (padrão de fábrica)8,9 MB429,8 MB
64 MB12,7 MB3006,7 MB

Mesmas conexões, mesma consulta, sete vezes mais memória. O valor de 64 MB encostou no teto de 3 GB que eu tinha dado ao contêiner, então o número real é pelo menos aquele.

Numa VPS de 2 GB, work_mem = 64MB com vinte ordenações simultâneas não deixa o site lento. Ele mata a máquina. E é exatamente o ajuste que aparece em todo guia de "otimize seu Postgres", escrito para servidores dedicados.

Os números que cabem em 2 GB

shared_buffers = 256MB # ~12% da RAM. O padrão de 128MB também serve work_mem = 4MB # por operação, por conexão. Suba com medo maintenance_work_mem = 64MB # só para VACUUM e CREATE INDEX, um de cada vez max_connections = 30 # e um pool na frente effective_cache_size = 1GB # não aloca nada, só orienta o planejador

A linha do max_connections é a que mais assusta quem vem de plataforma gerenciada, porque 30 parece pouco. Não é, porque conexão não é usuário. Uma conexão atende uma consulta por vez, em milissegundos. O que você precisa é de um pool do lado da aplicação, com um limite baixo, e cada requisição pega emprestado e devolve.

Se a sua pilha faz processo por requisição, como o PHP-FPM da parte 2, cada filho abre a própria conexão, e aí o limite de conexões e o pm.max_children são o mesmo número. Se você tem oito filhos, oito conexões, e a conta fecha sozinha.

O script inteiro

Juntando tudo, um deploy completo cabe em vinte linhas. Ele roda na sua máquina, não no servidor:

#!/usr/bin/env bash set -euo pipefail SRV=deploy@servidor APP=/srv/minhaapp REL=$(date +%Y-%m-%d-%H%M%S) npm ci && npm run build # build aqui, não lá rsync -az --delete \ --exclude '.git' --exclude 'node_modules' --exclude '.env*' \ ./ "$SRV:$APP/releases/$REL/" ssh "$SRV" bash -euo pipefail < $APP/shared/dumps/pre-$REL.sql npm run migrate --prefix releases/$REL ln -sfn $APP/releases/$REL $APP/current.novo mv -T $APP/current.novo $APP/current # atômico sudo systemctl restart minhaapp sudo systemctl reload nginx ls -1t releases | tail -n +6 | xargs -r -I{} rm -rf releases/{} REMOTO echo "publicado: \$REL"

Não tem ferramenta, não tem agente, não tem YAML. O set -euo pipefail na primeira linha é o que faz isso ser seguro: no primeiro comando que falhar, o script para, e ele para antes do mv -T. Um deploy que quebra no meio deixa uma release incompleta em disco e o current apontando para a versão antiga, que continua funcionando.

E o único sudo necessário são as duas últimas linhas de serviço, que você libera pontualmente no sudoers em vez de dar sudo geral para o usuário de deploy.

O checklist

itemcomo conferir
a troca é atômicagrep "mv -T" no script
dá para voltarls releases/ mostra mais de uma
o build não roda no servidorou roda com MemoryMax
segredos em shared/ls -l, esperado 600
uploads sobrevivem ao deploypublique e confira um arquivo antigo
dump antes da migraçãols shared/dumps/
max_connections cabemultiplique por 2,5 MB e compare
o script para no primeiro erroset -euo pipefail na linha 2

O que vem na parte 5

Tudo está no ar e você consegue publicar. Falta a parte que só dói depois, e que é a diferença entre um projeto que sobrevive um ano e um que não.

A parte 5 fecha a série: backup que você já restaurou pelo menos uma vez, porque backup não testado não é backup; rotação de log, que é a forma número um de uma caixa pequena morrer; monitoramento barato, o que vale e o que é enfeite nessa escala; cron e a execução que começa antes da anterior terminar; a ordem de precedência dos quatro caches quando você publicou e continua vendo o antigo; e a pergunta final, que é quando 2 GB deixa de bastar e qual é o próximo passo real.

kelvin@cluxnei:~/blog$