kelvin@cluxnei:~/blog$ cat 2gb/04-o-deploy.md
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
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:
- Caches compilados que guardam caminhos e listas de pacotes da sua
máquina. Um cache de contêiner de injeção de dependência, um cache de
descoberta de plugins, um cache de rotas. Ele lista um pacote de
desenvolvimento, produção instalou sem as dependências de desenvolvimento, e a
aplicação sobe com
Class not foundem cima de um arquivo que nem deveria estar ali. node_modulesinteiro, compilado para a sua arquitetura e para a sua versão de Node, que pode não ser a do servidor.- O
.envlocal, apontando para o seu banco, com as suas chaves. .git, com todo o histórico e, em muitos repositórios, com segredos que foram commitados e depois removidos mas continuam nos objetos.- Arquivos de build de ontem, que ninguém limpou e que o servidor vai servir de novo.
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:
| passo | pico de memória | tempo | resultado |
|---|---|---|---|
npm install, 4 dependências de dev | 406,4 MB | 8,7 s | 67 MB em disco |
| webpack, 401 módulos, 1,7 MB de fonte | 438,4 MB | 17,0 s | bundle 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:
- 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.
- Buildar no servidor, com teto. Se precisa mesmo, envolva o build num
systemd-runcomMemoryMax. 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. - 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_mem | ocioso | pico com 20 ordenações |
|---|---|---|
| 4 MB (padrão de fábrica) | 8,9 MB | 429,8 MB |
| 64 MB | 12,7 MB | 3006,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 <
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
| item | como conferir |
|---|---|
| a troca é atômica | grep "mv -T" no script |
| dá para voltar | ls releases/ mostra mais de uma |
| o build não roda no servidor | ou roda com MemoryMax |
segredos em shared/ | ls -l, esperado 600 |
| uploads sobrevivem ao deploy | publique e confira um arquivo antigo |
| dump antes da migração | ls shared/dumps/ |
max_connections cabe | multiplique por 2,5 MB e compare |
| o script para no primeiro erro | set -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$