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
COPYdefine o cache do Docker. Copiepackage.jsonantes 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ê envianode_moduleslocal,.gite arquivos temporários para o daemon do Docker.- Teste a imagem final com
docker runantes 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.
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.
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.
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.
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:
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.
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
- Rode docker images para conferir o tamanho da imagem final
- Execute docker run -p 3000:3000 minha-imagem e teste a aplicação localmente
- Verifique com docker inspect se não há camadas desnecessárias
- Confirme que variáveis sensíveis não ficaram hardcoded na imagem
- 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
- Suba o projeto com o Dockerfile para o GitHub
- Crie um novo serviço na Guara Cloud e conecte o repositório
- A plataforma detecta o Dockerfile e inicia o build
- Configure as variáveis de ambiente pelo painel
- 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:
| Stack | Sem multi-stage | Com multi-stage | Diferença |
|---|---|---|---|
| Node.js + TypeScript | 1.1GB | 95MB | 91% menor |
| Next.js (App Router) | 1.4GB | 180MB | 87% menor |
| Python + FastAPI | 890MB | 210MB | 76% menor |
| Go | 750MB | 18MB | 98% 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.