Deploy de app Node.js na VPS com Docker

Deploy de aplicação Node.js na VPS com Docker garante que o ambiente de produção é idêntico ao de desenvolvimento, sem surpresas com versões de pacotes ou configuração do SO. Com Nginx como proxy reverso e HTTPS via Let's Encrypt, a aplicação fica acessível pelo domínio em menos de 10 minutos.

Dockerfile para Node.js em produção

O Dockerfile de produção usa multi-stage build para separar o ambiente de build do de execução, imagem final menor e sem dependências de desenvolvimento:

dockerfile
# Dockerfile
# Stage 1: instalação de dependências
FROM node:22-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production

# Stage 2: build (para TypeScript ou bundlers)
FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

# Stage 3: imagem de execução (mínima)
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production

# Usuário não-root para segurança
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
COPY --from=deps --chown=appuser:appgroup /app/node_modules ./node_modules
COPY --from=build --chown=appuser:appgroup /app/dist ./dist
COPY --from=build --chown=appuser:appgroup /app/package.json .

USER appuser
EXPOSE 3000
CMD ["node", "dist/index.js"]

docker-compose.yml com Nginx e PostgreSQL

Stack completo para uma aplicação Node.js com banco de dados e proxy reverso:

yaml
services:
  app:
    build: .
    restart: always
    environment:
      NODE_ENV: production
      DATABASE_URL: postgresql://${DB_USER}:${DB_PASSWORD}@postgres:5432/${DB_NAME}
      PORT: 3000
    depends_on:
      postgres:
        condition: service_healthy
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://localhost:3000/health"]
      interval: 30s
      timeout: 10s
      retries: 3
    networks:
      - app_network

  postgres:
    image: postgres:16-alpine
    restart: always
    environment:
      POSTGRES_USER: ${DB_USER}
      POSTGRES_PASSWORD: ${DB_PASSWORD}
      POSTGRES_DB: ${DB_NAME}
    volumes:
      - pg_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${DB_USER}"]
      interval: 10s
      retries: 5
    networks:
      - app_network

  nginx:
    image: nginx:alpine
    restart: always
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
      - certbot_conf:/etc/letsencrypt
      - certbot_www:/var/www/certbot
    networks:
      - app_network

volumes:
  pg_data:
  certbot_conf:
  certbot_www:

networks:
  app_network:

Endpoint /health para healthcheck

O healthcheck do Docker precisa de um endpoint que responda 200 quando a aplicação está pronta para receber tráfego:

typescript
// src/routes/health.ts (ou .js)
import { Router } from 'express'
import { db } from '../database'

const router = Router()

router.get('/health', async (req, res) => {
  try {
    // Verificar conexão com banco (opcional mas recomendado)
    await db.query('SELECT 1')
    res.json({ status: 'ok', uptime: process.uptime() })
  } catch (err) {
    res.status(503).json({ status: 'error', message: 'Database unreachable' })
  }
})

export default router

Pipeline de deploy via SSH

Automatize o deploy com um script que envia o código e reinicia os containers:

bash
#!/bin/bash
# deploy.sh — execute no seu computador local

VPS_USER=deploy
VPS_HOST=IP_DA_VPS
APP_DIR=/opt/minha-app

# 1. Sincronizar arquivos (exceto node_modules e .env)
rsync -avz --exclude='node_modules' --exclude='.env' --exclude='.git' \
  ./ $VPS_USER@$VPS_HOST:$APP_DIR/

# 2. Na VPS: rebuild e restart
ssh $VPS_USER@$VPS_HOST "
  cd $APP_DIR
  docker compose build app
  docker compose up -d --no-deps app
  docker compose ps
"

echo "Deploy concluído!"

Nginx para proxy reverso da API Node.js

Configuração Nginx com keep-alive e timeout adequados para APIs Node.js:

nginx
# nginx.conf
upstream nodejs_app {
    server app:3000;
    keepalive 64;
}

server {
    listen 80;
    server_name api.seudominio.com.br;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl;
    http2 on;
    server_name api.seudominio.com.br;

    ssl_certificate /etc/letsencrypt/live/api.seudominio.com.br/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/api.seudominio.com.br/privkey.pem;

    location / {
        proxy_pass http://nodejs_app;
        proxy_http_version 1.1;
        proxy_set_header Connection "";
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_read_timeout 60s;
        proxy_connect_timeout 10s;
    }
}

Não quer configurar manualmente?

Implante o VPS em menos de 5 minutos com a Runstack. Infraestrutura da OPEN Datacenter, com servidores em São Paulo.

Perguntas frequentes

Docker: portabilidade total, isolamento, fácil rollback de versão, funciona igual em dev e prod. PM2 no host: menor overhead de memória (~50 MB por processo), mais simples para projetos pequenos. Para múltiplas aplicações na mesma VPS ou equipe com mais de uma pessoa: Docker. Para projeto pessoal simples: PM2 é suficiente.

Com docker compose up -d --no-deps app, o Docker para o container antigo e inicia o novo, há um breve downtime de ~2 segundos. Para zero downtime real: use dois containers com round-robin no Nginx upstream, ou implemente rolling update com Docker Swarm. Para apps simples, 2 segundos de downtime em horário de baixo tráfego é aceitável.

Sempre use uma versão LTS (Long-Term Support), em 2025, Node.js 22 LTS. Versões LTS têm suporte por 3 anos; versões Current por 6 meses. Evite a tag :latest, especifique a versão exata (FROM node:22-alpine) para garantir builds reproduzíveis.

Para Prisma: docker compose exec app npx prisma migrate deploy. Para Sequelize: docker compose exec app npx sequelize-cli db:migrate. Adicione ao script de deploy antes de reiniciar o container. Para deploys automatizados, considere um container init que roda as migrações e sai antes do container principal iniciar.

Nunca commite node_modules. O Docker instala as dependências durante o build (npm ci no Dockerfile). O cache de camadas do Docker faz isso de forma eficiente, npm ci só roda novamente quando package.json ou package-lock.json mudam.