Docker para Node.js: O Setup de Produção Que Ninguém Conta
Multi-stage builds, usuários non-root, health checks, gestão de secrets e otimização de tamanho de imagem. Os padrões Docker que uso em todo deploy Node.js de produção.
A maioria dos Dockerfiles de Node.js em produção são ruins. Não "ligeiramente subótimos" ruins. Quero dizer rodando como root, entregando imagens de 600MB com devDependencies embutidas, sem health checks, e secrets hardcoded em variáveis de ambiente que qualquer pessoa com docker inspect pode ler.
Eu sei porque fui eu que escrevi esses Dockerfiles. Por anos. Funcionavam, então nunca questionei. Até que um dia uma auditoria de segurança sinalizou nosso container rodando como PID 1 root com acesso de escrita ao sistema de arquivos inteiro, e percebi que "funciona" e "pronto para produção" são patamares muito diferentes.
Este é o setup Docker que agora uso para todo projeto Node.js. Não é teórico. Ele roda os serviços por trás deste site e de vários outros que mantenho. Cada padrão aqui existe porque eu me queimei com a alternativa ou vi alguém se queimar.
Por Que Seu Dockerfile Atual Provavelmente Está Errado#
Deixa eu adivinhar como é o seu Dockerfile:
FROM node:20
WORKDIR /app
COPY . .
RUN npm install
EXPOSE 3000
CMD ["node", "server.js"]Este é o "hello world" dos Dockerfiles. Funciona. Também tem pelo menos cinco problemas que vão te prejudicar em produção.
Rodando como Root#
Por padrão, a imagem Docker node roda como root. Isso significa que o processo da sua aplicação tem privilégios de root dentro do container. Se alguém explorar uma vulnerabilidade na sua app — um bug de path traversal, um SSRF, uma dependência com backdoor — terá acesso root ao sistema de arquivos do container, podendo modificar binários, instalar pacotes e potencialmente escalar privilégios dependendo da configuração do runtime do container.
"Mas containers são isolados!" Parcialmente. Escapes de container são reais. CVE-2024-21626, CVE-2019-5736 — são breakouts de container do mundo real. Rodar como non-root é uma medida de defesa em profundidade. Não custa nada e fecha toda uma classe de ataques.
Instalando devDependencies em Produção#
npm install sem flags instala tudo. Seus test runners, linters, ferramentas de build, type checkers — tudo na sua imagem de produção. Isso infla sua imagem em centenas de megabytes e aumenta sua superfície de ataque. Cada pacote adicional é outra vulnerabilidade potencial que o Trivy ou Snyk vai sinalizar.
Copiando Tudo#
COPY . . copia todo o diretório do projeto para a imagem. Isso inclui .git (que pode ser enorme), arquivos .env (que contêm secrets), node_modules (que você vai reinstalar de qualquer forma), arquivos de teste, documentação, configs de CI — tudo.
Sem Health Checks#
Sem uma instrução HEALTHCHECK, o Docker não tem ideia se sua aplicação está realmente servindo tráfego. O processo pode estar rodando mas travado, sem memória, ou preso em um loop infinito. O Docker vai reportar o container como "running" porque o processo não saiu. Seu load balancer continua enviando tráfego para um container zumbi.
Sem Estratégia de Cache de Camadas#
Copiar tudo antes de instalar dependências significa que mudar uma única linha de código fonte invalida o cache do npm install. Cada build reinstala todas as dependências do zero. Em um projeto com dependências pesadas, são 2-3 minutos desperdiçados por build.
Vamos corrigir tudo isso.
Multi-Stage Builds: O Maior Ganho#
Multi-stage builds são a mudança de maior impacto que você pode fazer em um Dockerfile Node.js. O conceito é simples: use um estágio para compilar sua aplicação, depois copie apenas os artefatos necessários em uma imagem final limpa e mínima.
Veja a diferença na prática:
# Estágio único: ~600MB
FROM node:20
WORKDIR /app
COPY . .
RUN npm install
CMD ["node", "server.js"]
# Multi-stage: ~150MB
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-alpine AS runner
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./
CMD ["node", "dist/server.js"]O estágio builder tem tudo: Node.js completo, npm, ferramentas de build, código fonte, devDependencies. O estágio runner tem apenas o necessário em runtime. O estágio builder é descartado inteiramente — não vai parar na imagem final.
Comparações Reais de Tamanho#
Medi estes em um projeto real de API Express.js com cerca de 40 dependências:
| Abordagem | Tamanho da Imagem |
|---|---|
node:20 + npm install | 1.1 GB |
node:20-slim + npm install | 420 MB |
node:20-alpine + npm ci | 280 MB |
| Multi-stage + alpine + apenas deps de produção | 150 MB |
| Multi-stage + alpine + deps podadas | 95 MB |
Isso é uma redução de 10x da abordagem ingênua. Imagens menores significam pulls mais rápidos, deploys mais rápidos e menos superfície de ataque.
Por Que Alpine?#
O Alpine Linux usa musl libc em vez de glibc, e não inclui cache de gerenciador de pacotes, documentação ou a maioria dos utilitários que você encontraria em uma distribuição Linux padrão. A imagem base node:20-alpine tem cerca de 50MB comparada a 350MB para node:20-slim e mais de 1GB para o node:20 completo.
A contrapartida é que alguns pacotes npm com bindings nativos (como bcrypt, sharp, canvas) precisam ser compilados contra musl. Na maioria dos casos, simplesmente funciona — o npm vai baixar o binário pré-compilado correto. Se tiver problemas, você pode instalar dependências de build no estágio builder:
FROM node:20-alpine AS builder
RUN apk add --no-cache python3 make g++
# ... resto do buildEssas ferramentas de build existem apenas no estágio builder. Não estão na sua imagem final.
O Dockerfile de Produção Completo#
Aqui está o Dockerfile que uso como ponto de partida para todo projeto Node.js. Cada linha é intencional.
# ============================================
# Estágio 1: Instalar dependências
# ============================================
FROM node:20-alpine AS deps
# Segurança: criar diretório de trabalho antes de qualquer coisa
WORKDIR /app
# Instalar dependências baseado no lockfile
# Copiar APENAS arquivos de pacote primeiro — isso é crítico para cache de camadas
COPY package.json package-lock.json ./
# ci é melhor que install: é mais rápido, estrito e reproduzível
# --omit=dev exclui devDependencies deste estágio
RUN npm ci --omit=dev
# ============================================
# Estágio 2: Compilar a aplicação
# ============================================
FROM node:20-alpine AS builder
WORKDIR /app
# Copiar arquivos de pacote e instalar TODAS as dependências (incluindo dev)
COPY package.json package-lock.json ./
RUN npm ci
# AGORA copiar código fonte — mudanças aqui não invalidam o cache do npm ci
COPY . .
# Compilar a aplicação (TypeScript compile, Next.js build, etc.)
RUN npm run build
# ============================================
# Estágio 3: Runner de produção
# ============================================
FROM node:20-alpine AS runner
# Adicionar labels para metadados da imagem
LABEL maintainer="your-email@example.com"
LABEL org.opencontainers.image.source="https://github.com/yourorg/yourrepo"
# Segurança: instalar dumb-init para tratamento adequado de sinais PID 1
RUN apk add --no-cache dumb-init
# Segurança: definir NODE_ENV antes de qualquer coisa
ENV NODE_ENV=production
# Segurança: usar usuário non-root
# A imagem node já inclui um usuário 'node' (uid 1000)
USER node
# Criar diretório da app com propriedade do usuário node
WORKDIR /app
# Copiar dependências de produção do estágio deps
COPY --from=deps --chown=node:node /app/node_modules ./node_modules
# Copiar aplicação compilada do estágio builder
COPY --from=deps --chown=node:node /app/package.json ./
COPY --from=builder --chown=node:node /app/dist ./dist
# Expor a porta (apenas documentação — não publica)
EXPOSE 3000
# Health check: curl não está disponível no alpine, usar node
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD node -e "require('http').get('http://localhost:3000/health', (r) => { process.exit(r.statusCode === 200 ? 0 : 1) })"
# Usar dumb-init como PID 1 para tratar sinais adequadamente
ENTRYPOINT ["dumb-init", "--"]
# Iniciar a aplicação
CMD ["node", "dist/server.js"]Deixe-me explicar as partes que não são óbvias.
Por Que Três Estágios em Vez de Dois?#
O estágio deps instala apenas dependências de produção. O estágio builder instala tudo (incluindo devDependencies) e compila a app. O estágio runner copia deps de produção do deps e código compilado do builder.
Por que não instalar deps de produção no estágio builder? Porque o estágio builder tem devDependencies misturadas. Você teria que rodar npm prune --production após o build, o que é mais lento e menos confiável do que ter uma instalação limpa de dependências de produção.
Por Que dumb-init?#
Quando você roda node server.js em um container, o Node.js se torna PID 1. PID 1 tem comportamento especial no Linux: não recebe handlers de sinais padrão. Se você enviar SIGTERM para o container (que é o que docker stop faz), o Node.js como PID 1 não vai tratar por padrão. O Docker espera 10 segundos, depois envia SIGKILL, que termina o processo imediatamente sem nenhuma limpeza — sem graceful shutdown, sem fechar conexões de banco de dados, sem finalizar requisições em andamento.
dumb-init age como PID 1 e encaminha sinais adequadamente para sua aplicação. Seu processo Node.js recebe SIGTERM como esperado e pode encerrar graciosamente:
// server.js
const server = app.listen(3000);
process.on('SIGTERM', () => {
console.log('SIGTERM recebido, encerrando graciosamente');
server.close(() => {
console.log('Servidor HTTP fechado');
// Fechar conexões de banco, flush de logs, etc.
process.exit(0);
});
});Uma alternativa é a flag --init no docker run, mas embutir na imagem significa que funciona independentemente de como o container é iniciado.
O Arquivo .dockerignore#
Isso é tão importante quanto o próprio Dockerfile. Sem ele, COPY . . envia tudo para o daemon Docker:
# .dockerignore
node_modules
npm-debug.log*
.git
.gitignore
.env
.env.*
!.env.example
Dockerfile
docker-compose*.yml
.dockerignore
README.md
LICENSE
.github
.vscode
.idea
coverage
.nyc_output
*.test.ts
*.test.js
*.spec.ts
*.spec.js
__tests__
test
tests
docs
.husky
.eslintrc*
.prettierrc*
tsconfig.json
jest.config.*
vitest.config.*
Cada arquivo no .dockerignore é um arquivo que não será enviado ao contexto de build, não vai parar na sua imagem e não vai invalidar seu cache de camadas quando alterado.
Cache de Camadas: Pare de Esperar 3 Minutos Por Build#
O Docker constrói imagens em camadas. Cada instrução cria uma camada. Se uma camada não mudou, o Docker usa a versão em cache. Mas aqui está o detalhe crítico: se uma camada muda, todas as camadas subsequentes são invalidadas.
Por isso a ordem das instruções importa enormemente.
A Ordem Errada#
COPY . .
RUN npm ciToda vez que você muda qualquer arquivo — uma única linha em um único arquivo fonte — o Docker vê que a camada COPY . . mudou. Ele invalida essa camada e tudo depois, incluindo npm ci. Você reinstala todas as dependências a cada mudança de código.
A Ordem Correta#
COPY package.json package-lock.json ./
RUN npm ci
COPY . .Agora npm ci só roda quando package.json ou package-lock.json muda. Se você só mudou código fonte, o Docker reutiliza a camada npm ci em cache. Em um projeto com 500+ dependências, isso economiza 60-120 segundos por build.
Cache Mount para npm#
O Docker BuildKit suporta cache mounts que persistem o cache do npm entre builds:
RUN --mount=type=cache,target=/root/.npm \
npm ci --omit=devIsso mantém o cache de download do npm entre builds. Se uma dependência já foi baixada em um build anterior, o npm usa a versão em cache em vez de baixar novamente. Isso é especialmente útil em CI onde você builda frequentemente.
Para usar BuildKit, defina a variável de ambiente:
DOCKER_BUILDKIT=1 docker build -t myapp .Ou adicione à configuração do daemon Docker:
{
"features": {
"buildkit": true
}
}Usando ARG para Cache Busting#
Às vezes você precisa forçar uma camada a recompilar. Por exemplo, se você está puxando uma tag latest de um registry e quer garantir que pega a versão mais nova:
ARG CACHE_BUST=1
RUN npm ciCompile com um valor único para invalidar o cache:
docker build --build-arg CACHE_BUST=$(date +%s) -t myapp .Use isso com moderação. O objetivo do cache é velocidade — só invalide o cache quando tiver um motivo.
Gestão de Secrets: Pare de Colocar Secrets no Seu Dockerfile#
Este é um dos erros mais comuns e perigosos. Vejo constantemente:
# NUNCA FAÇA ISSO
ENV DATABASE_URL=postgres://user:password@db:5432/myapp
ENV API_KEY=sk-live-abc123def456Variáveis de ambiente definidas com ENV em um Dockerfile são embutidas na imagem. Qualquer pessoa que baixar a imagem pode vê-las com docker inspect ou docker history. Elas também são visíveis em cada camada depois de definidas. Mesmo que você as "desdefina" depois, elas existem no histórico de camadas.
Os Três Níveis de Secrets#
1. Secrets em tempo de build (Docker BuildKit)
Se você precisa de secrets durante o build (como um token de registry npm privado), use a flag --secret do BuildKit:
# syntax=docker/dockerfile:1
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
# Montar o secret em tempo de build — nunca é armazenado na imagem
RUN --mount=type=secret,id=npmrc,target=/app/.npmrc \
npm ci
COPY . .
RUN npm run buildCompile com:
docker build --secret id=npmrc,src=$HOME/.npmrc -t myapp .O arquivo .npmrc está disponível durante o comando RUN mas nunca é commitado em nenhuma camada da imagem. Não aparece no docker history ou docker inspect.
2. Secrets em runtime via variáveis de ambiente
Para secrets que sua aplicação precisa em runtime, passe-os ao iniciar o container:
docker run -d \
-e DATABASE_URL="postgres://user:pass@db:5432/myapp" \
-e API_KEY="sk-live-abc123" \
myappOu com um arquivo env:
docker run -d --env-file .env.production myappEstes são visíveis via docker inspect no container em execução, mas não estão embutidos na imagem. Qualquer pessoa que baixar a imagem não recebe os secrets.
3. Docker secrets (Swarm / Kubernetes)
Para gestão adequada de secrets em ambientes orquestrados:
# docker-compose.yml (modo Swarm)
version: "3.8"
services:
api:
image: myapp:latest
secrets:
- db_password
- api_key
secrets:
db_password:
external: true
api_key:
external: trueO Docker monta secrets como arquivos em /run/secrets/<nome_do_secret>. Sua aplicação lê do sistema de arquivos:
import { readFileSync } from "fs";
function getSecret(name) {
try {
return readFileSync(`/run/secrets/${name}`, "utf8").trim();
} catch {
// Fallback para variável de ambiente para desenvolvimento local
return process.env[name.toUpperCase()];
}
}
const dbPassword = getSecret("db_password");Esta é a abordagem mais segura porque secrets nunca aparecem em variáveis de ambiente, listagens de processos ou saída de inspeção de containers.
Arquivos .env e Docker#
Nunca inclua arquivos .env na sua imagem Docker. Seu .dockerignore deve excluí-los (é por isso que listamos .env e .env.* anteriormente). Para desenvolvimento local com docker-compose, monte-os em runtime:
services:
api:
env_file:
- .env.localHealth Checks: Deixe o Docker Saber Que Sua App Realmente Funciona#
Um health check diz ao Docker se sua aplicação está funcionando corretamente. Sem um, o Docker só sabe se o processo está rodando — não se está realmente capaz de atender requisições.
A Instrução HEALTHCHECK#
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD node -e "require('http').get('http://localhost:3000/health', (r) => { process.exit(r.statusCode === 200 ? 0 : 1) })"Vou detalhar os parâmetros:
--interval=30s: Verificar a cada 30 segundos--timeout=10s: Se a verificação demorar mais de 10 segundos, considerar falha--start-period=40s: Dar 40 segundos para a app iniciar antes de contar falhas--retries=3: Marcar como unhealthy após 3 falhas consecutivas
Por Que Não Usar curl?#
O Alpine não inclui curl por padrão. Você poderia instalá-lo (apk add --no-cache curl), mas isso adiciona outro binário à sua imagem mínima. Usar Node.js diretamente significa zero dependências adicionais.
Para health checks ainda mais leves, você pode usar um script dedicado:
// healthcheck.js
const http = require("http");
const options = {
hostname: "localhost",
port: 3000,
path: "/health",
timeout: 5000,
};
const req = http.request(options, (res) => {
process.exit(res.statusCode === 200 ? 0 : 1);
});
req.on("error", () => process.exit(1));
req.on("timeout", () => {
req.destroy();
process.exit(1);
});
req.end();COPY --chown=node:node healthcheck.js ./
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD ["node", "healthcheck.js"]O Endpoint de Health#
Sua aplicação precisa de um endpoint /health para a verificação consultar. Não retorne apenas 200 — verifique de fato se sua app está saudável:
app.get("/health", async (req, res) => {
const checks = {
uptime: process.uptime(),
timestamp: Date.now(),
status: "ok",
};
try {
// Verificar conexão com banco de dados
await db.query("SELECT 1");
checks.database = "connected";
} catch (err) {
checks.database = "disconnected";
checks.status = "degraded";
}
try {
// Verificar conexão Redis
await redis.ping();
checks.redis = "connected";
} catch (err) {
checks.redis = "disconnected";
checks.status = "degraded";
}
const statusCode = checks.status === "ok" ? 200 : 503;
res.status(statusCode).json(checks);
});Um status "degraded" com 503 diz ao orquestrador para parar de rotear tráfego para esta instância enquanto ela se recupera, mas não necessariamente aciona um reinício.
Por Que Health Checks Importam para Orquestradores#
Docker Swarm, Kubernetes e até docker-compose com restart: always usam health checks para tomar decisões:
- Load balancers param de enviar tráfego para containers unhealthy
- Rolling updates esperam o novo container estar healthy antes de parar o antigo
- Orquestradores podem reiniciar containers que se tornam unhealthy
- Pipelines de deploy podem verificar se um deploy teve sucesso
Sem health checks, um rolling deployment pode matar o container antigo antes do novo estar pronto, causando downtime.
docker-compose para Desenvolvimento#
Seu ambiente de desenvolvimento deve ser o mais próximo possível de produção, mas com a conveniência de hot reload, debuggers e feedback instantâneo. Aqui está o setup docker-compose que uso para desenvolvimento:
# docker-compose.dev.yml
services:
app:
build:
context: .
dockerfile: Dockerfile.dev
args:
NODE_VERSION: "20"
ports:
- "3000:3000"
- "9229:9229" # Debugger Node.js
volumes:
# Montar código fonte para hot reload
- .:/app
# Volume anônimo para preservar node_modules da imagem
# Impede que o node_modules do host sobreponha o do container
- /app/node_modules
environment:
- NODE_ENV=development
- DATABASE_URL=postgres://postgres:devpassword@db:5432/myapp_dev
- REDIS_URL=redis://redis:6379
env_file:
- .env.local
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
command: npm run dev
db:
image: postgres:16-alpine
ports:
- "5432:5432"
environment:
POSTGRES_DB: myapp_dev
POSTGRES_USER: postgres
POSTGRES_PASSWORD: devpassword
volumes:
# Volume nomeado para dados persistentes entre reinícios de container
- pgdata:/var/lib/postgresql/data
# Scripts de inicialização
- ./scripts/init-db.sql:/docker-entrypoint-initdb.d/init.sql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
ports:
- "6379:6379"
volumes:
- redisdata:/data
command: redis-server --appendonly yes
# Opcional: UI de admin do banco de dados
adminer:
image: adminer:latest
ports:
- "8080:8080"
depends_on:
- db
volumes:
pgdata:
redisdata:Padrões Chave de Desenvolvimento#
Volume mounts para hot reload: O volume mount .:/app mapeia seu código fonte local no container. Quando você salva um arquivo, a mudança é imediatamente visível dentro do container. Combinado com um dev server que observa mudanças (como nodemon ou tsx --watch), você tem feedback instantâneo.
O truque do node_modules: O volume anônimo - /app/node_modules garante que o container use seu próprio node_modules (instalado durante o build da imagem) em vez do node_modules do host. Isso é crítico porque módulos nativos compilados no macOS não vão funcionar dentro de um container Linux.
Dependências de serviços: depends_on com condition: service_healthy garante que o banco de dados está realmente pronto antes da sua app tentar conectar. Sem a condição de health check, depends_on só espera o container iniciar — não o serviço dentro dele estar pronto.
Volumes nomeados: pgdata e redisdata persistem entre reinícios de container. Sem volumes nomeados, você perderia seu banco de dados toda vez que rodasse docker-compose down.
O Dockerfile de Desenvolvimento#
Seu Dockerfile de desenvolvimento é mais simples que o de produção:
# Dockerfile.dev
ARG NODE_VERSION=20
FROM node:${NODE_VERSION}-alpine
WORKDIR /app
# Instalar todas as dependências (incluindo devDependencies)
COPY package*.json ./
RUN npm ci
# Código fonte é montado via volume, não copiado
# Mas ainda precisamos dele para o build inicial
COPY . .
EXPOSE 3000 9229
CMD ["npm", "run", "dev"]Sem multi-stage build, sem otimização de produção. O objetivo é iteração rápida, não imagens pequenas.
Docker Compose de Produção#
O docker-compose de produção é uma besta diferente. Aqui está o que uso:
# docker-compose.prod.yml
services:
app:
image: ghcr.io/yourorg/myapp:${TAG:-latest}
restart: unless-stopped
ports:
- "3000:3000"
environment:
- NODE_ENV=production
env_file:
- .env.production
deploy:
resources:
limits:
cpus: "1.0"
memory: 512M
reservations:
cpus: "0.25"
memory: 128M
replicas: 2
healthcheck:
test: ["CMD", "node", "-e", "require('http').get('http://localhost:3000/health', r => process.exit(r.statusCode === 200 ? 0 : 1))"]
interval: 30s
timeout: 10s
start_period: 40s
retries: 3
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
networks:
- internal
- web
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
db:
image: postgres:16-alpine
restart: unless-stopped
volumes:
- pgdata:/var/lib/postgresql/data
environment:
POSTGRES_DB: myapp
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASSWORD}
deploy:
resources:
limits:
cpus: "1.0"
memory: 1G
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER}"]
interval: 10s
timeout: 5s
retries: 5
networks:
- internal
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
redis:
image: redis:7-alpine
restart: unless-stopped
command: >
redis-server
--appendonly yes
--maxmemory 256mb
--maxmemory-policy allkeys-lru
volumes:
- redisdata:/data
deploy:
resources:
limits:
cpus: "0.5"
memory: 512M
networks:
- internal
logging:
driver: "json-file"
options:
max-size: "5m"
max-file: "3"
volumes:
pgdata:
driver: local
redisdata:
driver: local
networks:
internal:
driver: bridge
web:
external: trueO Que é Diferente do Desenvolvimento#
Política de reinício: unless-stopped reinicia o container automaticamente se ele crashar, a menos que você o tenha parado explicitamente. Isso trata o cenário de "crash às 3 da manhã". A alternativa always também reiniciaria containers que você parou intencionalmente, o que geralmente não é o que você quer.
Limites de recursos: Sem limites, um memory leak na sua app Node.js consumirá toda a RAM disponível no host, potencialmente matando outros containers ou o próprio host. Defina limites baseados no uso real da sua aplicação mais alguma margem:
# Monitorar uso real para definir limites apropriados
docker stats --format "table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}"Configuração de logging: Sem max-size e max-file, os logs do Docker crescem sem limite. Já vi servidores de produção ficarem sem espaço em disco por causa de logs Docker. json-file com rotação é a solução mais simples. Para logging centralizado, troque para o driver fluentd ou gelf:
logging:
driver: "fluentd"
options:
fluentd-address: "localhost:24224"
tag: "myapp.{{.Name}}"Isolamento de rede: A rede internal é acessível apenas para serviços neste stack compose. O banco de dados e Redis não são expostos ao host ou outros containers. Apenas o serviço app está conectado à rede web, que seu reverse proxy (Nginx, Traefik) usa para rotear tráfego.
Sem mapeamento de portas para bancos de dados: Note que db e redis não têm ports na config de produção. Eles só são acessíveis via a rede Docker interna. Em desenvolvimento, os expomos para usar ferramentas locais (pgAdmin, Redis Insight). Em produção, não há razão para serem acessíveis de fora da rede Docker.
Next.js Específico: O Output Standalone#
O Next.js tem uma otimização Docker embutida que muitas pessoas não conhecem: o modo de output standalone. Ele rastreia os imports da sua aplicação e copia apenas os arquivos necessários para rodar — sem node_modules necessário (as dependências são bundleadas).
Habilite-o no next.config.ts:
// next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
};
export default nextConfig;Isso muda a saída do build drasticamente. Em vez de precisar do diretório node_modules inteiro, o Next.js produz um server.js autocontido em .next/standalone/ que inclui apenas as dependências que realmente usa.
O Dockerfile de Produção Next.js#
Este é o Dockerfile que uso para projetos Next.js, baseado no exemplo oficial da Vercel mas com hardening de segurança:
# ============================================
# Estágio 1: Instalar dependências
# ============================================
FROM node:20-alpine AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
# ============================================
# Estágio 2: Compilar a aplicação
# ============================================
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Desabilitar telemetria do Next.js durante o build
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
# ============================================
# Estágio 3: Runner de produção
# ============================================
FROM node:20-alpine AS runner
WORKDIR /app
RUN apk add --no-cache dumb-init
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
# Usuário non-root
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# Copiar assets públicos
COPY --from=builder /app/public ./public
# Configurar o diretório de output standalone
# Aproveita automaticamente output traces para reduzir tamanho da imagem
# https://nextjs.org/docs/advanced-features/output-file-tracing
RUN mkdir .next
RUN chown nextjs:nodejs .next
# Copiar o servidor standalone e arquivos estáticos
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD node -e "require('http').get('http://localhost:3000/api/health', (r) => { process.exit(r.statusCode === 200 ? 0 : 1) })"
ENTRYPOINT ["dumb-init", "--"]
CMD ["node", "server.js"]Comparação de Tamanho para Next.js#
| Abordagem | Tamanho da Imagem |
|---|---|
node:20 + node_modules completo + .next | 1.4 GB |
node:20-alpine + node_modules completo + .next | 600 MB |
node:20-alpine + output standalone | 120 MB |
O output standalone é transformador. Uma imagem de 1.4 GB se torna 120 MB. Deploys que levavam 90 segundos para pull agora levam 10 segundos.
Tratamento de Arquivos Estáticos#
O modo standalone do Next.js não inclui a pasta public nem os assets estáticos de .next/static. Você precisa copiá-los explicitamente (o que fazemos no Dockerfile acima). Em produção, você tipicamente quer um CDN na frente deles:
// next.config.ts
const nextConfig: NextConfig = {
output: "standalone",
assetPrefix: process.env.CDN_URL || undefined,
};Se você não está usando um CDN, o Next.js serve arquivos estáticos diretamente. O servidor standalone lida com isso bem — você só precisa garantir que os arquivos estão no lugar certo (o que nosso Dockerfile garante).
Sharp para Otimização de Imagens#
O Next.js usa sharp para otimização de imagens. Na imagem de produção baseada em Alpine, você precisa garantir que o binário correto está disponível:
# No estágio runner, antes de trocar para usuário non-root
RUN apk add --no-cache --virtual .sharp-deps vips-devOu melhor, instale como dependência de produção e deixe o npm lidar com o binário específico da plataforma:
npm install sharpA imagem node:20-alpine funciona com o binário pré-compilado linux-x64-musl do sharp. Nenhuma configuração especial necessária na maioria dos casos.
Scan de Imagens e Segurança#
Construir uma imagem pequena com um usuário non-root é um bom começo, mas não é suficiente para cargas de trabalho sérias em produção. Veja como ir além.
Trivy: Escaneie Suas Imagens#
Trivy é um scanner abrangente de vulnerabilidades para imagens de container. Rode-o no seu pipeline de CI:
# Instalar trivy
brew install aquasecurity/trivy/trivy # macOS
# ou
apt-get install trivy # Debian/Ubuntu
# Escanear sua imagem
trivy image myapp:latestExemplo de saída:
myapp:latest (alpine 3.19.1)
=============================
Total: 0 (UNKNOWN: 0, LOW: 0, MEDIUM: 0, HIGH: 0, CRITICAL: 0)
Node.js (node_modules/package-lock.json)
=========================================
Total: 2 (UNKNOWN: 0, LOW: 0, MEDIUM: 1, HIGH: 1, CRITICAL: 0)
┌──────────────┬────────────────┬──────────┬────────┬───────────────┐
│ Library │ Vulnerability │ Severity │ Status │ Fixed Version │
├──────────────┼────────────────┼──────────┼────────┼───────────────┤
│ semver │ CVE-2022-25883 │ HIGH │ fixed │ 7.5.4 │
│ word-wrap │ CVE-2023-26115 │ MEDIUM │ fixed │ 1.2.4 │
└──────────────┴────────────────┴──────────┴────────┴───────────────┘
Integre no CI para falhar builds em vulnerabilidades críticas:
# .github/workflows/docker.yml
- name: Build image
run: docker build -t myapp:${{ github.sha }} .
- name: Scan image
uses: aquasecurity/trivy-action@master
with:
image-ref: myapp:${{ github.sha }}
exit-code: 1
severity: CRITICAL,HIGH
ignore-unfixed: trueSistema de Arquivos Somente Leitura#
Você pode rodar containers com um sistema de arquivos root somente leitura. Isso impede um atacante de modificar binários, instalar ferramentas ou escrever scripts maliciosos:
docker run --read-only \
--tmpfs /tmp \
--tmpfs /app/.next/cache \
myapp:latestOs mounts --tmpfs fornecem diretórios temporários com escrita onde sua aplicação legitimamente precisa escrever (arquivos temporários, caches). Todo o resto é somente leitura.
No docker-compose:
services:
app:
image: myapp:latest
read_only: true
tmpfs:
- /tmp
- /app/.next/cacheRemover Todas as Capabilities#
Capabilities do Linux são permissões granulares que substituem o modelo tudo-ou-nada de root. Por padrão, containers Docker recebem um subconjunto de capabilities. Você pode remover todas:
docker run --cap-drop=ALL myapp:latestSe sua aplicação precisa vincular a uma porta abaixo de 1024, você precisaria de NET_BIND_SERVICE. Mas como estamos usando a porta 3000 com um usuário non-root, não precisamos de nenhuma capability:
services:
app:
image: myapp:latest
cap_drop:
- ALL
security_opt:
- no-new-privileges:trueno-new-privileges impede o processo de ganhar privilégios adicionais através de binários setuid/setgid. É uma medida de defesa em profundidade que não custa nada.
Fixar o Digest da Imagem Base#
Em vez de usar node:20-alpine (que é um alvo móvel), fixe em um digest específico:
FROM node:20-alpine@sha256:abcdef123456...Obtenha o digest com:
docker inspect --format='{{index .RepoDigests 0}}' node:20-alpineIsso garante que seus builds são 100% reproduzíveis. A contrapartida é que você não recebe automaticamente patches de segurança da imagem base. Use Dependabot ou Renovate para automatizar atualizações de digest:
# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: docker
directory: "/"
schedule:
interval: weeklyIntegração CI/CD: Juntando Tudo#
Aqui está um workflow completo do GitHub Actions que compila, escaneia e faz push de uma imagem Docker:
# .github/workflows/docker.yml
name: Build and Push Docker Image
on:
push:
branches: [main]
pull_request:
branches: [main]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
security-events: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Container Registry
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=sha
type=ref,event=branch
type=semver,pattern={{version}}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Scan image with Trivy
uses: aquasecurity/trivy-action@master
with:
image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:sha-${{ github.sha }}
exit-code: 1
severity: CRITICAL,HIGH
ignore-unfixed: true
- name: Upload Trivy results
if: always()
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: trivy-results.sarifCache BuildKit no CI#
As linhas cache-from: type=gha e cache-to: type=gha,mode=max usam o cache do GitHub Actions como cache de camadas Docker. Isso significa que seus builds no CI se beneficiam de cache de camadas entre execuções. O primeiro build leva 5 minutos; builds subsequentes com apenas mudanças de código levam 30 segundos.
Armadilhas Comuns e Como Evitá-las#
O Conflito node_modules Dentro da Imagem vs Host#
Se você montar o diretório do seu projeto em volume no container (-v .:/app), o node_modules do host sobrepõe o do container. Módulos nativos compilados no macOS não vão funcionar no Linux. Sempre use o truque do volume anônimo:
volumes:
- .:/app
- /app/node_modules # preserva o node_modules do containerTratamento de SIGTERM em Projetos TypeScript#
Se você está rodando TypeScript com tsx ou ts-node em desenvolvimento, o tratamento de sinais funciona normalmente. Mas em produção, se está usando o JavaScript compilado com node, certifique-se de que a saída compilada preserva os handlers de sinais. Algumas ferramentas de build otimizam código "não utilizado".
Limites de Memória e Node.js#
O Node.js não respeita automaticamente limites de memória do Docker. Se seu container tem limite de 512MB de memória, o Node.js ainda vai tentar usar seu tamanho de heap padrão (cerca de 1.5 GB em sistemas 64-bit). Defina o max old space size:
CMD ["node", "--max-old-space-size=384", "dist/server.js"]Deixe cerca de 25% de margem entre o limite de heap do Node.js e o limite de memória do container para memória não-heap (buffers, código nativo, etc.).
Ou use a flag de detecção automática:
ENV NODE_OPTIONS="--max-old-space-size=384"Problemas de Fuso Horário#
O Alpine usa UTC por padrão. Se sua aplicação depende de um fuso horário específico:
RUN apk add --no-cache tzdata
ENV TZ=America/New_YorkMas o melhor: escreva código agnóstico de fuso horário. Armazene tudo em UTC. Converta para horário local apenas na camada de apresentação.
Build Arguments vs Variáveis de Ambiente#
ARGestá disponível apenas durante o build. Não persiste na imagem final (a menos que você copie paraENV).ENVpersiste na imagem e está disponível em runtime.
# Configuração em tempo de build
ARG NODE_VERSION=20
FROM node:${NODE_VERSION}-alpine
# Configuração em runtime
ENV PORT=3000
# ERRADO: Isso torna o secret visível na imagem
ARG API_KEY
ENV API_KEY=${API_KEY}
# CORRETO: Passe secrets em runtime
# docker run -e API_KEY=secret myappMonitoramento em Produção#
Seu setup Docker não está completo sem observabilidade. Aqui está um stack de monitoramento mínimo mas eficaz:
# docker-compose.monitoring.yml
services:
prometheus:
image: prom/prometheus:latest
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml
- prometheus_data:/prometheus
ports:
- "9090:9090"
networks:
- internal
grafana:
image: grafana/grafana:latest
volumes:
- grafana_data:/var/lib/grafana
ports:
- "3001:3000"
environment:
- GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_PASSWORD}
networks:
- internal
volumes:
prometheus_data:
grafana_data:Exponha métricas da sua app Node.js usando prom-client:
import { collectDefaultMetrics, Registry, Histogram } from "prom-client";
const register = new Registry();
collectDefaultMetrics({ register });
const httpRequestDuration = new Histogram({
name: "http_request_duration_seconds",
help: "Duração de requisições HTTP em segundos",
labelNames: ["method", "route", "status_code"],
buckets: [0.01, 0.05, 0.1, 0.5, 1, 5],
registers: [register],
});
// Middleware
app.use((req, res, next) => {
const end = httpRequestDuration.startTimer();
res.on("finish", () => {
end({ method: req.method, route: req.route?.path || req.path, status_code: res.statusCode });
});
next();
});
// Endpoint de métricas
app.get("/metrics", async (req, res) => {
res.set("Content-Type", register.contentType);
res.end(await register.metrics());
});O Checklist#
Antes de enviar uma app Node.js containerizada para produção, verifique:
- Usuário non-root — Container roda como usuário non-root
- Multi-stage build — devDependencies e ferramentas de build não estão na imagem final
- Base Alpine — Usando uma imagem base mínima
- .dockerignore —
.git,.env,node_modules, testes excluídos - Cache de camadas —
package.jsoncopiado antes do código fonte - Health check — Instrução HEALTHCHECK no Dockerfile
- Tratamento de sinais —
dumb-initou--initpara tratamento adequado de SIGTERM - Sem secrets na imagem — Sem
ENVcom valores sensíveis no Dockerfile - Limites de recursos — Limites de memória e CPU definidos no compose/orquestrador
- Rotação de logs — Driver de logging configurado com tamanho máximo
- Scan de imagem — Trivy ou equivalente no pipeline de CI
- Versões fixadas — Versões de imagem base e dependências fixadas
- Limites de memória —
--max-old-space-sizedefinido para heap do Node.js
A maioria destes é configuração única. Faça uma vez, crie um template, e todo novo projeto começa com um container pronto para produção desde o primeiro dia.
Docker não é complicado. Mas a distância entre um Dockerfile que "funciona" e um pronto para produção é maior do que a maioria das pessoas pensa. Os padrões neste guia fecham essa distância. Use-os, adapte-os, e pare de fazer deploy de containers root com imagens de 1GB sem health checks. Seu eu do futuro — aquele que é acordado às 3 da manhã — vai agradecer.