Ir para o conteúdo
Infra

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

Marcos Soares
Atualizado em 
11 minutos de leitura
Ilustracao 3D de containers empilhados em vidro fosco com luz violeta representando Docker multi-stage build
Ouça este artigo
0:00Docker 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.

Dockerfile
# === 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.

AbordagemImagem baseTamanho finaldevDependencies incluídasRoda como root
Single-stage node:20~350 MB800-1200 MBSimSim (padrão)
Single-stage node:20-alpine~50 MB300-500 MBSimSim (padrão)
Multi-stage node:20-alpine~50 MB120-180 MBNãoNã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:

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

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

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

YAML
# 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.txt

Para subir com o arquivo específico de produção:

Bash
# 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 --build

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

TypeScript
// 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

Dockerfile
# 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:

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

Dockerfile
# 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:

TypeScript
// 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

YAML
# 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_healthy

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

  1. COPY package.json package-lock.json ./ (muda raramente)
  2. RUN npm ci (roda só quando dependências mudam)
  3. 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?

Bash
# 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 sh

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

Bash
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 antigo

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

Marcos Soares

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.