Docker para Devs: do Dockerfile ao docker-compose em Produção
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
.dockerignore
Cada 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 1
O 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 orquestr
Leia o artigo completo em https://www.vivodecodigo.com.br/infra/docker-dockerfile-docker-compose-producao-guia-pratico