Ir para o conteúdo
·14 min de leitura

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.

Compartilhar:X / TwitterLinkedIn

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#

bash
adduser deploy
usermod -aG sudo deploy

Configure Autenticação por Chave SSH#

Na sua maquina local:

bash
ssh-copy-id deploy@your-server-ip

Depois desabilite autenticação por senha completamente:

bash
sudo nano /etc/ssh/sshd_config
bash
PasswordAuthentication no
PermitRootLogin no
bash
sudo systemctl restart sshd

Se 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#

bash
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow OpenSSH
sudo ufw allow 'Nginx Full'
sudo ufw enable

So isso. Quatro regras. Apenas trafego SSH e web passam.

Fail2Ban#

bash
sudo apt install fail2ban -y
sudo cp /etc/fail2ban/jail.conf /etc/fail2ban/jail.local

Edite /etc/fail2ban/jail.local:

ini
[sshd]
enabled = true
port = ssh
filter = sshd
logpath = /var/log/auth.log
maxretry = 3
bantime = 3600
findtime = 600
bash
sudo systemctl enable fail2ban
sudo systemctl start fail2ban

Trê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#

bash
sudo apt install unattended-upgrades -y
sudo dpkg-reconfigure -plow unattended-upgrades

Seu 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:

bash
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:

bash
node -v  # v22.x.x ou qualquer LTS atual
npm -v

A 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:

bash
nvm alias default 22

Isso 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#

bash
npm install -g pm2

O Arquivo de Configuração Ecosystem#

Não inicie apps com flags de CLI. Use um arquivo ecosystem.config.js. E versionado, reproduzivel e autodocumentado.

javascript
// 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:

bash
pm2 startup systemd
# Copie e execute o comando que ele retorna
pm2 save

Isso 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:

bash
pm2 install pm2-logrotate
pm2 set pm2-logrotate:max_size 50M
pm2 set pm2-logrotate:retain 7
pm2 set pm2-logrotate:compress true

50MB 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#

bash
sudo apt install nginx -y

A Configuração#

nginx
# /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:

bash
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 nginx

Sempre 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.

bash
sudo apt install certbot python3-certbot-nginx -y
sudo certbot --nginx -d akousa.net -d www.akousa.net

O 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:

bash
sudo systemctl list-timers | grep certbot

Teste se a renovacao funciona:

bash
sudo certbot renew --dry-run

Se 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.

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 1

Torne executavel:

bash
chmod +x deploy.sh

Faca deploy da sua maquina local:

bash
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:

bash
pm2 status
pm2 logs akousa --lines 50
sudo systemctl status nginx
sudo tail -50 /var/log/nginx/error.log

Nove 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"#

bash
pm2 monit

Isso 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:

bash
pm2 describe akousa

Isso 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"#

bash
sudo certbot certificates

Lista todos os certificados com suas datas de expiracao. Se a auto-renovacao falhou:

bash
sudo certbot renew --force-renewal
sudo systemctl reload nginx

"Espaço em disco cheio"#

bash
df -h
du -sh /var/log/*
pm2 flush

pm2 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#

bash
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.

bash
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:

bash
# ~/.ssh/config
Host akousa
    HostName 69.62.66.94
    User deploy
    IdentityFile ~/.ssh/id_ed25519

Agora 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.