TL;DR — Resumo Rápido
Builds multi-stage do Docker reduzem o tamanho da imagem e eliminam ferramentas de build da produção, melhorando a segurança em Node.js, Go e .NET.
Imagens Docker de produção que contêm compiladores, frameworks de teste e gigabytes de dependências de desenvolvimento são um passivo. Elas desaceleram a inicialização de contêineres, consomem armazenamento desnecessário em registros e expõem uma superfície de ataque maior a possíveis exploits. Os builds multi-stage do Docker resolvem isso permitindo compilar e testar dentro de um ambiente de build completo e depois copiar apenas os artefatos finalizados para uma imagem de runtime leve e mínima — tudo a partir de um único Dockerfile. Este guia cobre builds multi-stage para Node.js, Go e .NET, incluindo estratégias de cache, endurecimento de segurança e integração com GitHub Actions.
Pré-requisitos
- Docker Engine 24+ instalado (
docker --version) - Familiaridade básica com Dockerfiles (
FROM,RUN,COPY,CMD) - Uma aplicação funcionando em Node.js, Go ou .NET (exemplos fornecidos)
- BuildKit do Docker habilitado (padrão no Docker 23+; defina
DOCKER_BUILDKIT=1para versões anteriores) - Opcional: fluxo de trabalho do GitHub Actions para integração CI/CD
O Problema com Builds de Estágio Único
Um Dockerfile típico de Node.js sem builds multi-stage fica assim:
FROM node:22
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
CMD ["node", "dist/server.js"]
Esta imagem contém o ambiente de desenvolvimento completo do Node.js: npm, cadeias de compilação nativas, o diretório node_modules incluindo devDependencies, arquivos de código-fonte e utilitários de teste. A imagem final pode facilmente ultrapassar 1,2 GB para uma aplicação modesta. Cada camada é enviada para o registro, baixada em cada implantação e verificada em busca de CVEs em todas essas ferramentas que você nunca executa em produção.
Como Funcionam os Builds Multi-Stage
Builds multi-stage usam múltiplas instruções FROM em um único Dockerfile. Cada FROM inicia um novo estágio com um sistema de arquivos limpo. Você nomeia estágios com AS e os referencia em instruções COPY --from=<estágio> subsequentes:
# Estágio 1: ambiente de build
FROM node:22 AS builder
WORKDIR /app
COPY . .
RUN npm ci && npm run build
# Estágio 2: runtime de produção
FROM node:22-alpine AS production
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
CMD ["node", "dist/server.js"]
O Docker constrói todos os estágios sequencialmente, mas mantém apenas o estágio final na imagem de saída. Cada ferramenta, arquivo temporário e artefato de build de estágios anteriores é descartado. O flag --from=builder no COPY acessa o sistema de arquivos do estágio nomeado para extrair caminhos específicos.
Build Multi-Stage para Node.js
Aqui está um Dockerfile multi-stage pronto para produção para uma aplicação TypeScript Node.js:
# ── Estágio 1: instalar todas as dependências ──────────────────
FROM node:22-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci --frozen-lockfile
# ── Estágio 2: compilar TypeScript ────────────────────────────
FROM node:22-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
# ── Estágio 3: runtime de produção ────────────────────────────
FROM node:22-alpine AS production
ENV NODE_ENV=production
WORKDIR /app
# Criar usuário não root
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
COPY --from=deps /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
# Remover devDependencies do node_modules
RUN npm prune --production
USER appuser
EXPOSE 8080
CMD ["node", "dist/server.js"]
O padrão de três estágios separa as responsabilidades com clareza. O estágio deps instala tudo, incluindo devDependencies. O estágio builder compila TypeScript para JavaScript. O estágio production começa do zero, copia apenas a saída compilada e node_modules, elimina devDependencies e executa como usuário não root. A imagem resultante tem aproximadamente 180 MB versus 1,2 GB para um build de estágio único.
Build Multi-Stage para Go
Go é um candidato ideal para builds multi-stage porque o compilador produz um binário estaticamente vinculado que pode ser executado em praticamente qualquer sistema de arquivos Linux:
# ── Estágio 1: compilar ───────────────────────────────────────
FROM golang:1.22-alpine AS builder
WORKDIR /build
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \
go mod download
COPY . .
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build -ldflags="-s -w" -o /app/server ./cmd/server
# ── Estágio 2: runtime mínimo ─────────────────────────────────
FROM gcr.io/distroless/static-debian12 AS production
COPY --from=builder /app/server /server
USER nonroot:nonroot
EXPOSE 8080
ENTRYPOINT ["/server"]
CGO_ENABLED=0 produz um binário completamente estático sem dependências de bibliotecas compartilhadas. Os -ldflags="-s -w" removem a tabela de símbolos e informações de depuração, reduzindo o tamanho do binário em 20-30%. A imagem final usa distroless/static-debian12, que contém apenas o mínimo necessário para executar um binário estático — sem shell, sem gerenciador de pacotes, sem /bin/sh. O resultado é uma imagem de 10-20 MB versus 600 MB do estágio de build.
Build Multi-Stage para .NET
O .NET separa claramente o SDK (para construir) do runtime (para executar). A imagem de runtime tem aproximadamente um quarto do tamanho da imagem SDK:
# ── Estágio 1: restaurar pacotes NuGet ────────────────────────
FROM mcr.microsoft.com/dotnet/sdk:9.0-alpine AS restore
WORKDIR /src
COPY *.sln .
COPY src/**/*.csproj ./
RUN --mount=type=cache,target=/root/.nuget/packages \
dotnet restore
# ── Estágio 2: build e publicação ─────────────────────────────
FROM restore AS builder
COPY . .
RUN --mount=type=cache,target=/root/.nuget/packages \
dotnet publish src/MyApp/MyApp.csproj \
-c Release \
-o /app/publish \
--no-restore
# ── Estágio 3: runtime de produção ────────────────────────────
FROM mcr.microsoft.com/dotnet/aspnet:9.0-alpine AS production
WORKDIR /app
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
COPY --from=builder /app/publish .
USER appuser
EXPOSE 8080
ENTRYPOINT ["dotnet", "MyApp.dll"]
Uma API típica do .NET 9 reduz de 1,1 GB (imagem SDK) para aproximadamente 130 MB (imagem runtime Alpine).
Estratégias de Cache
Os caches de montagem do BuildKit (--mount=type=cache) são a otimização mais impactante para velocidade de reconstrução. Ao contrário do cache de camadas do Docker, os caches de montagem persistem entre builds sem serem incluídos na imagem:
# npm
RUN --mount=type=cache,target=/root/.npm \
npm ci
# Go — cachear downloads de módulos e cache de build separadamente
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
go build ./...
# NuGet
RUN --mount=type=cache,target=/root/.nuget/packages \
dotnet restore
A ordem das camadas importa igualmente. Copie apenas os arquivos de manifesto de dependências (package.json, go.mod, *.csproj) antes de copiar o código-fonte. O Docker invalida o cache para todas as camadas após a primeira camada modificada:
# Correto: copiar manifestos primeiro, código-fonte depois
COPY package*.json ./
RUN npm ci
COPY src/ ./src/
RUN npm run build
Comparação de Tamanho de Imagens
| Aplicação | Imagem Estágio Único | Imagem Multi-Stage | Redução |
|---|---|---|---|
| Node.js TypeScript API | 1.240 MB | 185 MB | 85% |
| Serviço REST em Go | 612 MB | 12 MB | 98% |
| API ASP.NET Core .NET 9 | 1.080 MB | 130 MB | 88% |
| App Python Flask | 945 MB | 180 MB | 81% |
| Java Spring Boot (JRE) | 880 MB | 250 MB | 72% |
Cenário do Mundo Real
Você está construindo e implantando uma API REST Node.js através do GitHub Actions. Atualmente, cada execução de CI leva 8 minutos para construir uma imagem de 1,2 GB. Com builds multi-stage e cache de camadas do BuildKit, você pode reduzir isso para menos de 2 minutos em execuções com cache.
name: Build and Deploy
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v6
with:
context: .
push: true
target: production
cache-from: type=registry,ref=ghcr.io/${{ github.repository }}:cache
cache-to: type=registry,ref=ghcr.io/${{ github.repository }}:cache,mode=max
Armadilhas e Casos Extremos
Os valores ARG não cruzam limites de estágio. Se você definir ARG VERSION=1.0 antes do primeiro FROM, ele está disponível globalmente. Se definido dentro de um estágio, só existe naquele estágio. Redeclare ARG em cada estágio que precisar dele.
O contexto de build é compartilhado entre todos os estágios. Use um arquivo .dockerignore para excluir node_modules, .git e fixtures de teste do contexto para acelerar a transferência e evitar que arquivos sensíveis apareçam em camadas intermediárias.
O flag --target para o build em um estágio nomeado. Use-o para testar estágios individuais:
docker build --target builder -t myapp:debug .
docker run --rm -it myapp:debug sh
Solução de Problemas
A imagem final ainda contém arquivos de código-fonte — Você esqueceu de usar COPY --from=builder e está usando um COPY . . simples no estágio de produção. Revise cada COPY no seu estágio de produção.
O binário Go falha com “exec format error” no Alpine — Você compilou com CGO_ENABLED=1 mas usou um estágio sem runtime C. Defina CGO_ENABLED=0 explicitamente, ou mude o estágio de runtime para alpine:3.19.
Os caches de montagem não persistem no CI — --mount=type=cache é um recurso local do BuildKit. No CI, use a estratégia de cache de registro (cache-from/cache-to).
O tamanho da imagem não diminuiu significativamente — Verifique que seu estágio de produção não copia diretórios desnecessários. Execute docker history myimage:latest para ver quais camadas contribuem mais para o tamanho.
Resumo
- Builds de estágio único incluem compiladores, ferramentas de desenvolvimento e código-fonte na imagem final — builds multi-stage eliminam tudo isso
- Use
COPY --from=<estágio>para copiar apenas artefatos compilados para uma imagem base de runtime mínima - Apps Node.js reduzem 85%, apps Go reduzem 98% e apps .NET reduzem 88% com builds multi-stage
- Monte caches de gerenciador de pacotes com
--mount=type=cachepara acelerar reconstruções incrementais sem inflar a imagem - Copie manifestos de dependências antes do código-fonte para maximizar a reutilização do cache de camadas do Docker
- Execute contêineres como usuários não root e use sistemas de arquivos somente leitura quando possível
- No GitHub Actions, use
cache-from/cache-tocom cache de registro para compartilhar camadas do BuildKit entre execuções - Use
--targetpara construir e depurar estágios específicos sem executar o build completo