Docker para Devs: do Dockerfile ao docker-compose em Produção

Conteúdo técnico toda semana
Receba artigos sobre arquitetura, padrões de projeto e engenharia de software. Direto no seu e-mail, sem enrolação.
Sem spam. Cancele a qualquer momento com 1 clique.
Neste artigo
O erro mais caro de Docker custa zero em disco e derruba tudo em runtime
Um Dockerfile de 3 linhas funciona no laptop. Em produção, a imagem pesa 1.2 GB, roda como root, não tem healthcheck e o container reinicia em loop porque o processo principal morreu sem que o orquestrador soubesse. O problema nunca é "Docker não funciona". O problema é o que você não configurou.
Este post cobre o caminho do Dockerfile mínimo até um docker-compose.yml de produção com healthchecks, limites de memória, secrets e multi-stage build. O foco é Node.js/TypeScript, mas a estrutura se aplica a qualquer runtime.
Multi-stage build: por que sua imagem não deveria passar de 200 MB
A ideia é simples: use um estágio para compilar, outro para rodar. O estágio de build carrega devDependencies, TypeScript compiler, ferramentas de lint. O estágio final copia só o artefato compilado e as dependências de produção.
# === Estágio 1: build ===
FROM node:20-alpine AS builder
WORKDIR /app
# Copia package*.json primeiro para aproveitar cache de camadas.
# Se o código mudar mas as dependências não, essa camada não rebuilda.
COPY package.json package-lock.json ./
RUN npm ci
COPY tsconfig.json ./
COPY src ./src
RUN npm run build
# === Estágio 2: produção ===
FROM node:20-alpine AS production
# Roda como usuário não-root. O user "node" já existe na imagem alpine do Node.
USER node
WORKDIR /app
COPY --from=builder --chown=node:node /app/package.json /app/package-lock.json ./
# --omit=dev instala só dependências de produção, eliminando typescript, eslint, etc.
RUN npm ci --omit=dev
COPY --from=builder --chown=node:node /app/dist ./dist
EXPOSE 3000
# node direto, sem npm start. npm start spawna um processo filho,
# e sinais como SIGTERM não propagam corretamente para o processo Node.
CMD ["node", "dist/server.js"]A diferença prática: uma imagem single-stage com node:20 pesa ~900 MB. Com multi-stage e node:20-alpine, fica entre 120 e 180 MB dependendo das dependências nativas.
| Abordagem | Imagem base | Tamanho final | devDependencies incluídas | Roda como root |
|---|---|---|---|---|
Single-stage node:20 | ~350 MB | 800-1200 MB | Sim | Sim (padrão) |
Single-stage node:20-alpine | ~50 MB | 300-500 MB | Sim | Sim (padrão) |
Multi-stage node:20-alpine | ~50 MB | 120-180 MB | Não | Não (USER node) |
.dockerignore: a linha que evita vazamento de secrets
Sem .dockerignore, o COPY . . manda tudo para o daemon: node_modules local, .env com credenciais, .git inteiro. Crie o arquivo na raiz do projeto:
node_modules
.git
.gitignore
.env
.env.*
dist
coverage
*.md
docker-compose*.yml
Dockerfile
.dockerignoreCada linha é um padrão glob. O ponto crítico é .env: se ele entra na imagem, qualquer pessoa com acesso ao registry lê suas credenciais com docker history ou extraindo camadas.
Healthcheck no Dockerfile: o container sabe que está vivo
Sem healthcheck, o Docker (e qualquer orquestrador) só sabe se o processo está rodando. Um processo que consome 100% de CPU em deadlock continua "running". O healthcheck resolve isso:
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1O endpoint /health precisa existir na aplicação. Não precisa ser sofisticado:
// src/routes/health.ts
import { Router, Request, Response } from "express";
const router = Router();
router.get("/health", (_req: Request, res: Response) => {
// Retorna 200 se o processo responde HTTP.
// Para checks mais profundos (DB, Redis), adicione verificações aqui.
res.status(200).json({ status: "ok" });
});
export { router as healthRouter };O --start-period=10s dá tempo para a aplicação inicializar antes de começar a verificar. Se sua aplicação demora mais (migrations, warm-up de cache), aumente esse valor.
docker-compose.yml para produção real
O docker-compose.yml abaixo orquestra uma API Node.js, PostgreSQL e Redis. Cada decisão está comentada:
# docker-compose.prod.yml
version: "3.9"
services:
api:
build:
context: .
dockerfile: Dockerfile
target: production
ports:
- "3000:3000"
environment:
NODE_ENV: production
DATABASE_URL: postgres://app_user:${DB_PASSWORD}@postgres:5432/app_db
REDIS_URL: redis://redis:6379
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
deploy:
resources:
limits:
# Sem limite de memória, um memory leak derruba o host inteiro.
memory: 512M
cpus: "1.0"
reservations:
memory: 256M
restart: unless-stopped
# Secrets via arquivo, não variável de ambiente.
# Variáveis de ambiente aparecem em docker inspect.
secrets:
- jwt_secret
postgres:
image: postgres:16-alpine
volumes:
- pg_data:/var/lib/postgresql/data
environment:
POSTGRES_USER: app_user
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: app_db
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app_user -d app_db"]
interval: 10s
timeout: 5s
retries: 5
deploy:
resources:
limits:
memory: 1G
restart: unless-stopped
redis:
image: redis:7-alpine
# Configura maxmemory diretamente. Sem isso, Redis consome toda RAM disponível.
command: redis-server --maxmemory 128mb --maxmemory-policy allkeys-lru
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
retries: 5
deploy:
resources:
limits:
memory: 256M
restart: unless-stopped
volumes:
pg_data:
redis_data:
secrets:
jwt_secret:
file: ./secrets/jwt_secret.txtPara subir com o arquivo específico de produção:
# O --env-file carrega variáveis como DB_PASSWORD sem expor no shell history
docker compose -f docker-compose.prod.yml --env-file .env.prod up -d --buildSecrets: variáveis de ambiente não são seguras
Variáveis de ambiente definidas em environment: aparecem em texto plano com docker inspect. Qualquer usuário com acesso ao daemon Docker lê tudo. Docker secrets resolve isso montando o valor como arquivo em /run/secrets/:
// src/config/secrets.ts
import { readFileSync } from "node:fs";
function readSecret(name: string): string {
const secretPath = `/run/secrets/${name}`;
try {
return readFileSync(secretPath, "utf-8").trim();
} catch {
// Fallback para variável de ambiente em desenvolvimento local
const envValue = process.env[name.toUpperCase()];
if (!envValue) {
throw new Error(`Secret "${name}" não encontrada em ${secretPath} nem em variáveis de ambiente`);
}
return envValue;
}
}
export const JWT_SECRET = readSecret("jwt_secret");O fallback para variável de ambiente permite rodar localmente sem montar secrets. Em produção, o arquivo em /run/secrets/ tem precedência.
O que NÃO fazer
Anti-pattern 1: rodar como root
# ERRADO: sem USER, o processo roda como root dentro do container.
# Se a aplicação tem uma vulnerabilidade de RCE, o atacante tem root.
FROM node:20-alpine
WORKDIR /app
COPY . .
RUN npm install
CMD ["npm", "start"]Versão correta:
FROM node:20-alpine
# Cria diretório com ownership do user node ANTES de mudar o usuário
WORKDIR /app
COPY --chown=node:node . .
USER node
RUN npm ci --omit=dev
CMD ["node", "dist/server.js"]Anti-pattern 2: usar npm start como CMD
npm start spawna um processo shell que spawna o processo Node. Sinais do sistema operacional (SIGTERM, SIGINT) vão para o npm, não para o Node. Resultado: o container não faz graceful shutdown. O Docker espera 10 segundos, manda SIGKILL, e conexões ativas são cortadas.
# ERRADO
CMD ["npm", "start"]
# CORRETO: node diretamente, recebe SIGTERM e pode fechar conexões
CMD ["node", "dist/server.js"]Para o graceful shutdown funcionar, a aplicação precisa escutar o sinal:
// src/server.ts
import { createServer } from "node:http";
import { app } from "./app";
const server = createServer(app);
server.listen(3000, () => {
console.log("Server listening on port 3000");
});
process.on("SIGTERM", () => {
console.log("SIGTERM received. Closing HTTP server...");
server.close(() => {
// Fecha conexões com banco, Redis, etc. antes de sair
console.log("HTTP server closed");
process.exit(0);
});
});Anti-pattern 3: depends_on sem condition
# ERRADO: depends_on sem condition só garante que o container INICIOU,
# não que o serviço dentro dele está pronto para aceitar conexões.
depends_on:
- postgres
# CORRETO: espera o healthcheck do postgres retornar "healthy"
depends_on:
postgres:
condition: service_healthySem condition: service_healthy, a API tenta conectar no PostgreSQL antes dele aceitar conexões TCP. A aplicação crasha, o restart: unless-stopped reinicia, e você tem um loop de restart até o banco ficar pronto. Funciona por acaso, mas é frágil.
Cache de camadas: a ordem das instruções importa
O Docker cacheia cada instrução como uma camada. Se uma camada muda, todas as camadas seguintes são invalidadas. A ordem correta:
COPY package.json package-lock.json ./(muda raramente)RUN npm ci(roda só quando dependências mudam)COPY src ./src(muda a cada commit)
Se você fizer COPY . . antes de npm ci, qualquer mudança em qualquer arquivo do projeto invalida o cache de npm ci. Em projetos com muitas dependências, isso transforma um build de 5 segundos em 2 minutos.
Quando docker-compose não é suficiente
Docker Compose funciona bem para: servidor único, projetos com até 3-5 serviços, ambientes de staging, aplicações internas com tráfego previsível.
Docker Compose não resolve: escalar horizontalmente a API em múltiplos hosts, zero-downtime deploy nativo (precisa de scripts auxiliares ou ferramentas como Kamal), service mesh, auto-healing distribuído.
Se você precisa de múltiplos hosts com auto-scaling, avalie Kubernetes ou alternativas mais leves como Docker Swarm (built-in, mas com ecossistema menor) ou deploy na edge com Cloudflare Workers para APIs stateless.
Para aplicações que usam monorepos com Turborepo, o Dockerfile precisa de atenção extra: o contexto de build deve ser a raiz do monorepo, e o multi-stage build deve copiar só o workspace relevante.
Se sua API usa WebSockets, configure o proxy reverso (nginx, Traefik) para suportar upgrade de conexão HTTP para WebSocket. Sem isso, conexões WebSocket falham silenciosamente.
Para migrations seguras em produção com Prisma, rode o migration como um container separado que executa e termina, não como parte do entrypoint da API. Isso evita race conditions quando múltiplas réplicas da API sobem simultaneamente.
Aplicações que implementam CQRS e Event Sourcing tipicamente têm mais serviços (command handler, event store, read model updater). Cada um vira um service no compose com seus próprios limites de recurso e healthcheck.
FAQ
Preciso de Docker em desenvolvimento local?
Depende do que você quer isolar. Para banco de dados e Redis, Docker Compose é a melhor opção: evita instalar PostgreSQL e Redis no host, e cada projeto pode ter versões diferentes. Para a aplicação em si, rodar node direto no host com hot reload (tsx, nodemon) é mais rápido que rebuildar a imagem a cada mudança. Use um docker-compose.dev.yml só com os serviços de infraestrutura.
docker compose ou docker-compose?
docker compose (com espaço) é o plugin V2, integrado ao CLI do Docker. docker-compose (com hífen) é o binário standalone V1, descontinuado desde julho de 2023. Use docker compose. Se o comando não existe, atualize o Docker Engine ou instale o plugin compose separadamente.
Como debugar um container que reinicia em loop?
# Veja os logs do container, incluindo os de execuções anteriores
docker compose -f docker-compose.prod.yml logs --tail=100 api
# Se o container morre rápido demais, sobrescreva o CMD para manter ele vivo
docker compose -f docker-compose.prod.yml run --rm api shO run --rm api sh abre um shell interativo no container com a mesma configuração (volumes, network, environment) mas sem executar o CMD. A partir daí, rode node dist/server.js manualmente e veja o erro.
Alpine ou Debian slim?
Alpine usa musl libc em vez de glibc. Pacotes nativos compilados para glibc (sharp, bcrypt, canvas) podem precisar de recompilação ou falhar. Se sua aplicação depende de pacotes com bindings nativos complexos, use node:20-slim (Debian slim, ~80 MB). Se não tem dependências nativas ou usa só pacotes JavaScript puros, Alpine é menor e funciona sem problemas.
Como atualizar a aplicação sem downtime com docker-compose?
Docker Compose não tem deploy rolling nativo. A abordagem mais simples: suba o novo container, espere o healthcheck passar, derrube o antigo. Com um script:
docker compose -f docker-compose.prod.yml up -d --build --no-deps api
# --no-deps evita recriar postgres e redis
# O healthcheck garante que o novo container está respondendo
# antes do compose remover o antigoIsso causa uma janela curta de indisponibilidade (segundos). Para zero-downtime real, coloque um load balancer (nginx, Traefik) na frente e suba a nova versão em paralelo antes de remover a antiga, ou migre para uma ferramenta como Kamal.
A posição que defendo
Docker Compose em produção é subestimado. A indústria empurra Kubernetes como resposta padrão, mas a maioria das aplicações que vi rodando em Kubernetes não precisava de Kubernetes. Se você tem um servidor, uma API, um banco e um cache, docker-compose com healthchecks, limites de recursos e secrets resolve. O custo operacional de manter um cluster Kubernetes (mesmo managed) é ordens de grandeza maior que um docker compose up -d. Comece com Compose, meça, e migre para algo maior quando os números justificarem: quando precisar de auto-scaling horizontal, deploy em múltiplas regiões ou orquestração de dezenas de serviços. Até lá, a complexidade extra é custo sem retorno.

Escrito por
Marcos Soares
Fullstack Developer · CEO da Agência Poti
Fullstack Developer e CEO da Agência Poti. Mais de 20 anos construindo arquiteturas cloud-native com React, Next.js e sistemas distribuídos. Parceiro comercial do estúdio iellou design. Fundador do Vivo de Código.
Comentários
Participe da discussão
Seja o primeiro a comentar!
Continue Aprofundando
Guias de integração relacionados
Conteúdo técnico toda semana
Receba artigos sobre arquitetura, padrões de projeto e engenharia de software. Direto no seu e-mail, sem enrolação.
Sem spam. Cancele a qualquer momento com 1 clique.


