O Setup de VPS Que Realmente Funciona: Node.js, PM2, Nginx e Deploys Sem Downtime
O setup exato de deploy em VPS que eu uso em produção — hardening de Ubuntu, PM2 em cluster mode, Nginx como reverse proxy, SSL e um script de deploy que nunca me deixou na mao. Sem teoria, so o que funciona.
Este blog roda em uma VPS de $10/mes. Não Vercel, não AWS, não um cluster Kubernetes gerenciado por uma equipe de seis. Uma única maquina Ubuntu com Nginx, PM2 e um script bash que faz deploy em menos de 30 segundos.
Ja tentei os outros caminhos. Usei Vercel (otimo ate você precisar de cron jobs, WebSockets persistentes ou simplesmente controle). Usei AWS (otimo se você gosta de passar metade do dia em politicas IAM). Sempre acabo de volta em uma VPS.
Mas aqui esta o problema: todo tutorial de "deploy em VPS" na internet para no caminho feliz. Eles mostram como instalar o Node.js e rodar node server.js e chamam isso de produção. Então seu servidor leva brute-force no SSH, seu processo morre as 3 da manha porque ninguém configurou um gerenciador de processos e seu certificado SSL expirou três meses atrás.
Este e o guia que eu gostaria de ter tido. Tudo aqui foi testado em batalha — esse setup exato serve a pagina que você esta lendo agora.
Comece Pela Seguranca, Não Pelo Código#
Antes de sequer pensar em Node.js, blinde a maquina. Instancias de VPS recem-criadas são alvos. Bots automatizados comecam a bater na sua porta SSH minutos após o provisionamento.
Crie um Usuario Não-Root#
adduser deploy
usermod -aG sudo deployConfigure Autenticação por Chave SSH#
Na sua maquina local:
ssh-copy-id deploy@your-server-ipDepois desabilite autenticação por senha completamente:
sudo nano /etc/ssh/sshd_configPasswordAuthentication no
PermitRootLogin nosudo systemctl restart sshdSe você pular isso, vai ver milhares de tentativas de login falhas nos seus logs de autenticação em poucos dias. Isso não e paranoia — e terca-feira na internet pública.
Firewall Com UFW#
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow OpenSSH
sudo ufw allow 'Nginx Full'
sudo ufw enableSo isso. Quatro regras. Apenas trafego SSH e web passam.
Fail2Ban#
sudo apt install fail2ban -y
sudo cp /etc/fail2ban/jail.conf /etc/fail2ban/jail.localEdite /etc/fail2ban/jail.local:
[sshd]
enabled = true
port = ssh
filter = sshd
logpath = /var/log/auth.log
maxretry = 3
bantime = 3600
findtime = 600sudo systemctl enable fail2ban
sudo systemctl start fail2banTrês tentativas de SSH falhas e você e banido por uma hora. Ja vi o Fail2Ban bloquear centenas de IPs em um único dia. Funciona.
Atualizacoes de Seguranca Automaticas#
sudo apt install unattended-upgrades -y
sudo dpkg-reconfigure -plow unattended-upgradesSeu servidor agora vai auto-instalar patches de seguranca. Uma coisa a menos para esquecer.
Node.js: Use NVM, Não apt#
Vejo isso em todo tutorial: sudo apt install nodejs. Não faca isso.
Os repos de pacotes do Ubuntu enviam versoes antigas do Node.js. Ate o PPA do NodeSource fica atrasado. E quando você precisa alternar entre Node 20 e Node 22 para projetos diferentes, esta preso.
NVM resolve isso:
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash
source ~/.bashrc
nvm install --lts
nvm alias default lts/*Agora verifique:
node -v # v22.x.x ou qualquer LTS atual
npm -vA dica não obvia: quando você instala pacotes globais com NVM (como PM2), eles ficam vinculados aquela versão do Node. Se você trocar de versão com nvm use, seus globais desaparecem. Defina seu padrão e mantenha-o no servidor:
nvm alias default 22Isso me mordeu exatamente uma vez. Uma vez foi o suficiente.
PM2: O Gerenciador de Processos Que Vale Cada Centavo#
PM2 e a diferença entre "deployado" e "pronto para produção." Ele lida com gerenciamento de processos, clustering, rotacao de logs, auto-restart em crashes e scripts de inicializacao. De graca.
Instale e Configure#
npm install -g pm2O Arquivo de Configuração Ecosystem#
Não inicie apps com flags de CLI. Use um arquivo ecosystem.config.js. E versionado, reproduzivel e autodocumentado.
// ecosystem.config.js
module.exports = {
apps: [
{
name: "akousa",
script: "node_modules/.bin/next",
args: "start -p 3002",
cwd: "/var/www/akousa.net",
instances: 2,
exec_mode: "cluster",
max_memory_restart: "500M",
env: {
NODE_ENV: "production",
PORT: 3002,
},
// Shutdown gracioso
kill_timeout: 5000,
listen_timeout: 10000,
wait_ready: false,
// Logging
log_date_format: "YYYY-MM-DD HH:mm:ss Z",
error_file: "/var/log/pm2/akousa-error.log",
out_file: "/var/log/pm2/akousa-out.log",
merge_logs: true,
// Auto-restart em caso de falha
autorestart: true,
max_restarts: 10,
min_uptime: "10s",
// Nao use watch em producao
watch: false,
},
],
};Deixe-me explicar as escolhas que importam:
instances: 2 em vez de "max": Em uma VPS pequena com 1-2 cores, "max" parece inteligente, mas vai criar processos que disputam recursos durante builds. Duas instancias te dao reloads sem downtime com espaço de sobra. Em uma maquina com 4+ cores, claro, use "max".
exec_mode: "cluster": Isso e o que habilita reloads sem downtime. Sem cluster mode, pm2 reload e apenas um restart chique. Com cluster mode, o PM2 reinicia instancias uma por vez — seu app nunca fica totalmente offline.
max_memory_restart: "500M": Seu app Next.js tem um vazamento de memoria? O PM2 vai reinicia-lo antes que ele mate seu servidor por OOM. Isso me salvou de alertas as 2 da manha mais de uma vez.
kill_timeout: 5000: Da ao seu app 5 segundos para finalizar requests em andamento antes do PM2 mata-lo a força. O padrão (1600ms) e muito agressivo para apps com conexoes de banco de dados.
watch: false: Ja vi pessoas deixarem watch: true em produção. O PM2 então reinicia o app toda vez que um arquivo de log muda. Seu app entra em loop de restart. Não faca isso.
Script de Inicializacao#
Faca o PM2 sobreviver a reboots:
pm2 startup systemd
# Copie e execute o comando que ele retorna
pm2 saveIsso gera um serviço systemd. Após um reboot do servidor, seu app volta automaticamente. Teste — reinicie seu servidor e verifique. Não assuma.
Rotacao de Logs#
Logs vao comer seu disco eventualmente. Instale o modulo de rotacao:
pm2 install pm2-logrotate
pm2 set pm2-logrotate:max_size 50M
pm2 set pm2-logrotate:retain 7
pm2 set pm2-logrotate:compress true50MB maximo por arquivo, mantenha 7 arquivos rotacionados, comprima os antigos. Sem isso, ja vi /var/log encher um disco de 25GB em três semanas em um app com trafego moderado.
Nginx: O Reverse Proxy Que Faz Mais Do Que Você Imagina#
"Por que não expor o Node.js diretamente na porta 80?"
Porque o Nginx lida com coisas que o Node.js não deveria gastar ciclos: terminacao SSL, servir arquivos estaticos, compressao gzip, buffering de requests, limites de conexão e tratamento gracioso de clientes lentos. E escrito em C e construído especificamente para isso.
Instale#
sudo apt install nginx -yA Configuração#
# /etc/nginx/sites-available/akousa.net
upstream node_app {
server 127.0.0.1:3002;
keepalive 64;
}
server {
listen 80;
listen [::]:80;
server_name akousa.net www.akousa.net;
# Redireciona todo HTTP para HTTPS
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name akousa.net www.akousa.net;
# SSL (gerenciado pelo Certbot — essas linhas sao adicionadas automaticamente)
ssl_certificate /etc/letsencrypt/live/akousa.net/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/akousa.net/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
# Headers de seguranca
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
# Compressao Gzip
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_min_length 256;
gzip_types
text/plain
text/css
text/javascript
application/javascript
application/json
application/xml
image/svg+xml
application/wasm;
# Configuracoes de proxy
location / {
proxy_pass http://node_app;
proxy_http_version 1.1;
# Headers
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Suporte a WebSocket (caso voce precise)
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
# Timeouts — generosos mas nao infinitos
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
# Buffering — deixe o Nginx lidar com clientes lentos
proxy_buffering on;
proxy_buffer_size 16k;
proxy_buffers 4 32k;
proxy_busy_buffers_size 64k;
}
# Assets estaticos do Next.js — deixe o Nginx servir diretamente
location /_next/static/ {
alias /var/www/akousa.net/.next/static/;
expires 365d;
access_log off;
add_header Cache-Control "public, immutable";
}
# Arquivos estaticos publicos
location /static/ {
alias /var/www/akousa.net/public/static/;
expires 30d;
access_log off;
}
# Bloqueia acesso a arquivos dot
location ~ /\. {
deny all;
access_log off;
log_not_found off;
}
}Habilite:
sudo ln -s /etc/nginx/sites-available/akousa.net /etc/nginx/sites-enabled/
sudo rm /etc/nginx/sites-enabled/default
sudo nginx -t
sudo systemctl reload nginxSempre execute nginx -t antes de recarregar. Uma vez eu subi uma config quebrada e derrubei o site porque pulei a verificação de sintaxe. Os cinco caracteres nginx -t teriam me economizado trinta minutos de debugging em panico.
Coisas que a maioria dos tutoriais não inclui nessa config:
Bloco upstream com keepalive 64: O Nginx reutiliza conexoes com seu backend Node.js em vez de abrir uma nova conexão TCP para cada request. Isso importa sob carga.
proxy_buffering on: O Nginx le a resposta inteira do Node.js na memoria, depois envia para o cliente na velocidade que o cliente aguenta. Sem isso, um cliente lento em uma conexão 3G prende seu worker Node.js.
Servir _next/static/ diretamente: Esses são assets hashados e imutaveis. Deixe o Nginx servi-los do disco com um header de cache de 365 dias. Seus processos Node.js não deveriam perder tempo com isso.
SSL em Cinco Minutos#
O Let's Encrypt resolveu SSL. Se você ainda esta pagando por certificados em 2026, pare.
sudo apt install certbot python3-certbot-nginx -y
sudo certbot --nginx -d akousa.net -d www.akousa.netO Certbot vai pedir seu email, aceitar os Termos de Serviço e automaticamente modificar sua config do Nginx para incluir as diretivas SSL. So isso.
Verifique a Auto-Renovacao#
O Certbot instala um timer systemd que verifica duas vezes por dia e renova certificados dentro de 30 dias da expiracao:
sudo systemctl list-timers | grep certbotTeste se a renovacao funciona:
sudo certbot renew --dry-runSe o dry run passar, você nunca mais vai pensar em SSL. Se falhar, geralmente e porque a porta 80 esta bloqueada (verifique suas regras do UFW) ou o Nginx não esta rodando.
Uma coisa que me pegou: se você configurou o Nginx antes de rodar o Certbot, certifique-se de que seu server block esta escutando na porta 80 sem o redirect HTTPS primeiro. O Certbot precisa alcancar a porta 80 para o desafio HTTP-01. Depois que o Certbot rodar com sucesso, então adicione o redirect.
O Script de Deploy#
Este e o script que roda toda vez que eu faço push para produção. Sem plataforma de CI/CD, sem GitHub Actions. Apenas SSH e bash.
#!/bin/bash
# deploy.sh — deploy com (quase) zero downtime
set -euo pipefail
APP_DIR="/var/www/akousa.net"
APP_NAME="akousa"
LOG_FILE="/var/log/deploy.log"
HEALTH_URL="http://localhost:3002"
MAX_RETRIES=10
RETRY_INTERVAL=3
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_FILE"
}
log "=== Deploy iniciado ==="
cd "$APP_DIR"
# Puxa o codigo mais recente
log "Puxando ultimas alteracoes..."
git pull origin main 2>&1 | tee -a "$LOG_FILE"
# Instala dependencias
log "Instalando dependencias..."
npm install --legacy-peer-deps 2>&1 | tee -a "$LOG_FILE"
# Build
log "Buildando aplicacao..."
rm -rf .next
npm run build 2>&1 | tee -a "$LOG_FILE"
if [ $? -ne 0 ]; then
log "ERRO: Build falhou. Abortando deploy."
exit 1
fi
# Reload PM2 (zero-downtime em cluster mode)
log "Recarregando PM2..."
pm2 reload "$APP_NAME" 2>&1 | tee -a "$LOG_FILE"
pm2 save 2>&1 | tee -a "$LOG_FILE"
# Health check com retries
log "Executando health check..."
for i in $(seq 1 $MAX_RETRIES); do
HTTP_CODE=$(curl -s -o /dev/null -w '%{http_code}' "$HEALTH_URL" 2>/dev/null || echo "000")
if [ "$HTTP_CODE" = "200" ]; then
log "Health check passou (HTTP $HTTP_CODE)"
log "=== Deploy concluido com sucesso ==="
exit 0
fi
log "Tentativa de health check $i/$MAX_RETRIES (HTTP $HTTP_CODE). Tentando novamente em ${RETRY_INTERVAL}s..."
sleep $RETRY_INTERVAL
done
log "ERRO: Health check falhou apos $MAX_RETRIES tentativas"
log "Revertendo para o estado anterior do PM2..."
pm2 restart "$APP_NAME" 2>&1 | tee -a "$LOG_FILE"
exit 1Torne executavel:
chmod +x deploy.shFaca deploy da sua maquina local:
ssh root@your-server-ip "bash /var/www/akousa.net/deploy.sh"Decisoes chave neste script:
set -euo pipefail: O script encerra imediatamente em qualquer erro. Sem isso, um npm install falho continua silenciosamente para o passo de build, e você recebe um erro criptico que desperdiça 20 minutos para debugar.
rm -rf .next antes do build: O Next.js tem um cache de build que ocasionalmente produz saida desatualizada. Fui mordido por isso uma vez — uma pagina mostrava conteúdo antigo apesar do código fonte ter sido atualizado. Apagar o diretorio de build adiciona talvez 15 segundos ao build, mas garante saida fresca.
pm2 reload em vez de pm2 restart: Essa e a parte do zero-downtime. Em cluster mode, reload faz um rolling restart — levanta novas instancias com o código atualizado, espera elas ficarem prontas, depois encerra graciosamente as antigas. Em nenhum momento zero instancias estao rodando.
Health check com retries: O Next.js leva alguns segundos para aquecer após o restart. O script espera ate 30 segundos (10 retries x 3 segundos), verificando se o app responde com HTTP 200. Se não responder, algo esta errado e você precisa saber imediatamente — não descobrir por um usuario.
Rollback em caso de falha: Se o health check falhar após todas as tentativas, o script reinicia o PM2 (que carrega o último estado salvo). Não e um rollback perfeito, mas e melhor do que deixar o servidor em estado quebrado.
Quando as Coisas Quebram as 2 da Manha#
Aqui esta o que eu realmente debuguei nesse setup exato:
"O site esta fora do ar"#
Primeiros comandos a executar:
pm2 status
pm2 logs akousa --lines 50
sudo systemctl status nginx
sudo tail -50 /var/log/nginx/error.logNove em cada dez vezes, pm2 logs te diz imediatamente o que aconteceu. Uma variavel de ambiente faltando, uma conexão de banco de dados falha ou uma promise rejection não tratada.
"A memoria continua crescendo"#
pm2 monitIsso te da um dashboard ao vivo de CPU e memoria por processo. Se a memoria sobe constantemente sem estabilizar, você tem um vazamento. A configuração max_memory_restart no seu ecosystem config e sua rede de seguranca — o PM2 vai reiniciar o processo antes que ele derrube o servidor.
Para investigacao mais profunda:
pm2 describe akousaIsso mostra uptime, contagem de restarts e snapshots de memoria. Se você ve 47 restarts nas ultimas 24 horas, essa e sua pista.
"Certificado SSL expirou"#
sudo certbot certificatesLista todos os certificados com suas datas de expiracao. Se a auto-renovacao falhou:
sudo certbot renew --force-renewal
sudo systemctl reload nginx"Espaço em disco cheio"#
df -h
du -sh /var/log/*
pm2 flushpm2 flush limpa todos os arquivos de log do PM2 imediatamente. Se você não configurou rotacao de logs (eu avisei), e aqui que você sente a dor.
O Comando Que Eu Rodo Toda Manha#
ssh deploy@akousa.net "pm2 status && df -h / && uptime"Três coisas em uma linha: meus processos estao rodando, meu disco esta ok, o servidor esta sobrecarregado. Leva dois segundos. Pega problemas antes dos usuarios.
O Que a Maioria dos Guias Não Te Conta#
Seu passo de build e sua maior vulnerabilidade. Em uma VPS de 1GB de RAM, npm run build para um app Next.js pode consumir 800MB+ de memoria. Se o PM2 esta rodando seu app em duas instancias durante o build, você vai ter OOM. Solucoes: use um arquivo de swap (pelo menos 2GB), ou pare o app durante builds e aceite alguns segundos de downtime. Eu uso swap.
sudo fallocate -l 2G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab--legacy-peer-deps no seu comando de install e um code smell, não uma solução. Eu uso porque alguns pacotes na minha arvore de dependências não atualizaram seus ranges de peer dependency. A cada poucos meses eu tento remover. Um dia vai funcionar. Ate la, eu entrego.
Teste seu script de deploy do zero. Clone seu repo em um servidor novo e execute cada passo manualmente. A quantidade de problemas "funciona na minha maquina" escondidos em scripts de deploy e constrangedora. Encontrei três problemas no meu quando fiz isso — pacotes globais faltando, permissoes de arquivo erradas e um caminho que so existia por causa de um setup manual anterior.
Coloque o IP do seu servidor no seu SSH config. Pare de digitar endereços IP:
# ~/.ssh/config
Host akousa
HostName 69.62.66.94
User deploy
IdentityFile ~/.ssh/id_ed25519Agora ssh akousa e tudo que você precisa. Coisas pequenas se acumulam.
O Checklist Completo#
Antes de dizer que esta pronto:
- Usuario não-root com acesso sudo
- Apenas autenticação por chave SSH, senha desabilitada
- UFW habilitado com apenas portas necessarias abertas
- Fail2Ban protegendo SSH
- Atualizacoes de seguranca automaticas habilitadas
- Node.js instalado via NVM
- PM2 rodando seu app em cluster mode
- Script de startup do PM2 configurado (sobrevive a reboot)
- Rotacao de logs do PM2 instalada
- Nginx como reverse proxy com headers adequados
- SSL via Let's Encrypt com auto-renovacao
- Script de deploy com health checks
- Arquivo de swap configurado (para folga no build)
- Testado: reinicie o servidor e verifique se tudo volta
Esse último item e o que as pessoas pulam. Não seja essa pessoa. Reinicie o servidor, espere 60 segundos e verifique se seu app esta no ar. Se não estiver, seus scripts de startup estao mal configurados e você vai descobrir no pior momento possível.
Isso E "Enterprise-Grade"?#
Não. E esse e o ponto.
Esse setup serve este blog de forma confiavel por menos de $10/mes. O deploy leva 30 segundos com um único comando. Eu entendo cada peca. Quando algo quebra, sei exatamente onde procurar.
Eu poderia usar Docker? Claro. Poderia usar Kubernetes? Tecnicamente. Poderia montar um pipeline completo de CI/CD com ambientes de staging e deploys canary? Com certeza.
Mas aprendi que a melhor infraestrutura e aquela que você realmente entende, consegue debugar as 2 da manha e não custa mais do que o projeto ganha. Para um site pessoal, um MVP de SaaS ou uma startup pequena — esse e o setup.
Entregue primeiro. Escale quando precisar. E sempre, sempre, teste seu script de deploy em um servidor novo.