Voltar ao blog Tutoriais

Como otimizar Dockerfile para produção com multi-stage builds

Reduza imagens Docker de 1GB para menos de 100MB usando multi-stage builds. Guia prático com exemplos reais para Node.js, Python e Go em produção.

10 min de leitura

Por Guara Cloud Editorial

Testado com Docker 24 / Node.js 20 / Python 3.12 / Go 1.22

Já vi imagem Docker de API Node.js com 1.2GB. O desenvolvedor copiava tudo pra dentro do container, incluindo node_modules de dev, arquivos de teste e o SDK inteiro do TypeScript. A aplicação funcionava, mas cada deploy demorava minutos só pra fazer push da imagem.

Multi-stage build resolve isso. A ideia é simples: você usa um estágio para compilar e instalar dependências, e outro estágio para copiar só o que a aplicação precisa pra rodar. O resultado são imagens de 50 a 150MB em vez de 1GB.

Este guia mostra como aplicar multi-stage builds em projetos Node.js, Python e Go, com os erros mais comuns que vejo por aí.

Resposta rápida

Use FROM duas vezes no Dockerfile. O primeiro estágio (builder) instala dependências e compila o código. O segundo estágio (runner) copia apenas os artefatos necessários do builder. Para Node.js, isso significa copiar só dist/ e node_modules de produção. Para Go, o binário compilado. Para Python, o virtualenv com dependências de runtime.

Principais pontos

  • Multi-stage builds separam construção de execução. A imagem final não carrega compiladores, headers de C ou ferramentas de teste.
  • A ordem das instruções COPY define o cache do Docker. Copie package.json antes do código-fonte para reutilizar camadas de dependência.
  • Use imagens base alpine ou distroless quando possível. Menos pacotes no container significa menor superfície de ataque.
  • .dockerignore é obrigatório. Sem ele, você envia node_modules local, .git e arquivos temporários para o daemon do Docker.
  • Teste a imagem final com docker run antes de fazer push. É o jeito mais rápido de pegar problemas de permissão ou caminho errado.

Quando este tutorial se aplica

Use multi-stage builds para qualquer aplicação que precisa de uma etapa de compilação ou instalação de dependências antes de rodar. Isso inclui: APIs Node.js com TypeScript, aplicações Next.js com SSR, projetos Python com dependências compiladas (psycopg2, numpy), binários Go e aplicações Java/Kotlin.

O padrão funciona bem quando a aplicação roda como um único processo dentro do container e não precisa de ferramentas de debug em produção.

Quando não usar este fluxo

Se o container precisa de ferramentas de compilação em runtime (por exemplo, um serviço que compila código de usuário), multi-stage não ajuda porque você precisa do toolchain na imagem final. Containers de desenvolvimento local (devcontainers) também não precisam de multi-stage, já que a prioridade ali é conveniência, não tamanho da imagem.

Para aplicações estáticas (HTML/CSS/JS), você pode usar o estágio de build e depois servir com Nginx na imagem final. Funciona, mas considere se containerizar isso faz sentido ou se um CDN resolve melhor.

Antes de começar

  • Docker 20.10+ instalado localmente
  • Um projeto com package.json, requirements.txt ou go.mod
  • Familiaridade básica com comandos Docker (build, run)

1. Node.js com TypeScript: o caso mais comum

A maioria dos projetos Node.js em produção usa TypeScript. O build gera JavaScript em dist/, e é só isso que a imagem final precisa. Junto com node_modules de produção, claro.

Dockerfile para API Node.js + TypeScript
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:20-alpine AS runner
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY --from=builder /app/dist ./dist
ENV NODE_ENV=production
EXPOSE 3000
CMD ["node", "dist/index.js"]

O que está acontecendo aqui: o estágio builder instala todas as dependências (incluindo TypeScript e tipos) e compila o projeto. O estágio runner instala só as dependências de produção (--omit=dev exclui TypeScript, Jest, ESLint) e copia o diretório dist do builder.

Tamanho típico da imagem final: 80 a 120MB, dependendo de quantas dependências de produção o projeto tem.

Detalhe que muita gente erra

A linha COPY package.json package-lock.json ./ precisa vir antes do COPY . .. Isso parece óbvio, mas vejo Dockerfiles que fazem COPY . . no começo. O problema: quando qualquer arquivo do projeto muda, o Docker invalida o cache de RUN npm ci. Separando as cópias, o Docker só re-instala dependências quando package.json muda.

2. Python: compilando dependências nativas

Python tem uma complicação extra. Pacotes como psycopg2, Pillow ou numpy compilam extensões C durante a instalação. Essas extensões precisam de headers e compiladores (gcc, python3-dev) que você não quer na imagem final.

Dockerfile para API Python (FastAPI/Flask)
FROM python:3.12-slim AS builder
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
  gcc \
  libpq-dev \
  && rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
RUN pip install --no-cache-dir -r requirements.txt

FROM python:3.12-slim AS runner
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
  libpq5 \
  && rm -rf /var/lib/apt/lists/*
COPY --from=builder /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
COPY . .
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

O truque aqui é criar o virtualenv (/opt/venv) no builder e copiá-lo inteiro para o runner. O runner precisa das bibliotecas de sistema que as extensões C linkam em runtime (como libpq5 para psycopg2), mas não precisa do gcc nem dos headers.

Note a diferença: libpq-dev no builder (para compilar), libpq5 no runner (para executar). Se esquecer a lib de runtime, o import falha com um erro de shared library.

3. Go: o caso mais limpo

Go compila para um binário estático. A imagem final pode ter só esse binário e mais nada.

Dockerfile para serviço Go
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o server ./cmd/server

FROM alpine:3.19 AS runner
RUN apk add --no-cache ca-certificates
COPY --from=builder /app/server /usr/local/bin/server
EXPOSE 8080
CMD ["server"]

CGO_ENABLED=0 compila um binário puramente estático, sem dependências de C. -ldflags="-s -w" remove informações de debug e reduz o tamanho do binário em 20 a 30%.

A imagem final tem Alpine (7MB) mais o binário. Geralmente fica entre 15 e 40MB no total. Se quiser ir além, use scratch como base em vez de Alpine, mas aí você perde acesso a shell e ferramentas de debug (o que pode complicar troubleshooting).

4. Next.js: o caso com pegadinhas

Next.js merece atenção porque o build gera dois tipos de artefato: páginas estáticas e código de servidor. O .next/standalone é o que você copia para produção.

Dockerfile para Next.js com App Router
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build

FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
EXPOSE 3000
CMD ["node", "server.js"]

Para esse Dockerfile funcionar, adicione output: 'standalone' no next.config.js:

next.config.js
module.exports = {
output: 'standalone',
}

Sem output: 'standalone', o Next.js não gera o diretório standalone e o build quebra. O standalone inclui uma versão mínima do servidor Next.js junto com as dependências necessárias. Sem ele, você precisaria copiar node_modules inteiro.

Configuração essencial: .dockerignore

Sem .dockerignore, o Docker envia todo o diretório do projeto como contexto de build. Isso inclui node_modules local (que pode ter 500MB), .git (outros centenas de MB) e arquivos temporários. O build fica lento sem motivo.

.dockerignore
node_modules
.next
dist
.git
.gitignore
.env
.env.*
*.md
coverage
.vscode
.idea
docker-compose*.yml
Dockerfile

O contexto de build com .dockerignore adequadamente configurado costuma ficar entre 5 e 50MB em vez de vários GB. A diferença no tempo de build é visível.

Verificando o resultado

Depois do build, vale a pena inspecionar a imagem antes de fazer deploy:

Checklist pós-build

  1. Rode docker images para conferir o tamanho da imagem final
  2. Execute docker run -p 3000:3000 minha-imagem e teste a aplicação localmente
  3. Verifique com docker inspect se não há camadas desnecessárias
  4. Confirme que variáveis sensíveis não ficaram hardcoded na imagem
  5. Teste o health check da aplicação respondendo na porta correta

Deploy na Guara Cloud

Com o Dockerfile pronto e o .dockerignore configurado, o deploy na Guara Cloud é direto. A plataforma detecta o Dockerfile na raiz do repositório e roda o build automaticamente.

Deploy na Guara Cloud

  1. Suba o projeto com o Dockerfile para o GitHub
  2. Crie um novo serviço na Guara Cloud e conecte o repositório
  3. A plataforma detecta o Dockerfile e inicia o build
  4. Configure as variáveis de ambiente pelo painel
  5. Acompanhe o build nos logs em tempo real (2 a 5 minutos no primeiro deploy)

Deploys subsequentes são mais rápidos. O Docker reutiliza camadas que não mudaram, então se você só alterou código-fonte (sem mexer em package.json), apenas os estágios finais rodam de novo.

Problemas comuns

Problema A imagem final tem mais de 500MB
Solução Verifique se o estágio runner usa uma imagem base slim ou alpine. Confira que está copiando só os artefatos necessários, não o diretório inteiro do builder.
Problema npm ci falha com ENOENT no estágio runner
Solução O package-lock.json precisa estar no repositório. Se usa npm install em vez de npm ci, a resolução pode divergir entre estágios. Sempre use npm ci quando tiver lockfile.
Problema Erro de permissão ao rodar a aplicação no runner
Solução O Docker roda como root por padrão. Se o builder criou arquivos como root e o runner usa um usuário diferente, adicione USER node antes do CMD ou ajuste permissões com COPY --chown=node:node.
Problema O health check falha no deploy mas funciona localmente
Solução Confirme que a aplicação escuta em 0.0.0.0, não em localhost ou 127.0.0.1. No container, localhost é o loopback interno. A plataforma acessa via IP do container.
Problema Build cache não funciona, reinstala tudo toda vez
Solução A ordem das instruções COPY importa. Copie package.json e lockfile antes do código-fonte. Se COPY . . vier antes do npm ci, qualquer mudança no código invalida o cache de dependências.

Comparação de tamanhos

Números reais de projetos que testei:

StackSem multi-stageCom multi-stageDiferença
Node.js + TypeScript1.1GB95MB91% menor
Next.js (App Router)1.4GB180MB87% menor
Python + FastAPI890MB210MB76% menor
Go750MB18MB98% menor

A diferença de Go é absurda porque o binário final é completamente auto-contido. Node.js e Python sempre carregam o runtime na imagem, então existe um piso de tamanho que multi-stage não elimina.

Multi-stage build deixa o build mais lento?

O primeiro build pode ser marginalmente mais lento porque o Docker processa dois estágios. Mas com cache ativo, builds subsequentes são mais rápidos porque as camadas de dependência não mudam. O tempo de push da imagem cai drasticamente com o tamanho menor.

Posso usar multi-stage com docker-compose?

Sim. No docker-compose.yml, especifique o target do estágio que quer rodar. Para desenvolvimento, use target: builder. Para produção, use target: runner ou simplesmente não especifique (o Docker usa o último estágio).

Qual a diferença entre alpine e slim?

Alpine usa musl libc (uma implementação alternativa da libc) e tem cerca de 5MB. Slim é baseada em Debian mas sem documentação, man pages e ferramentas desnecessárias, ficando em torno de 80MB. Alpine é menor mas pode dar problema com pacotes que dependem de glibc.

Preciso de multi-stage se meu projeto é JavaScript puro sem TypeScript?

Ainda vale a pena. Mesmo sem compilação, o multi-stage permite separar node_modules de dev (testes, linters) das dependências de produção. A diferença de tamanho vem de excluir centenas de pacotes desnecessários na imagem final.

Como sei se minha imagem está segura para produção?

Rode docker scout cves minha-imagem ou trivy image minha-imagem para ver vulnerabilidades conhecidas. Imagens menores tendem a ter menos CVEs simplesmente porque têm menos pacotes instalados. Alpine e distroless saem na frente nesse aspecto.

Faça deploy da sua imagem Docker na Guara Cloud

Build automático a partir do Dockerfile, HTTPS gerenciado, logs em tempo real e cobrança em Real. Infraestrutura em São Paulo.

Começar grátis