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=1 para 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çãoImagem Estágio ÚnicoImagem Multi-StageRedução
Node.js TypeScript API1.240 MB185 MB85%
Serviço REST em Go612 MB12 MB98%
API ASP.NET Core .NET 91.080 MB130 MB88%
App Python Flask945 MB180 MB81%
Java Spring Boot (JRE)880 MB250 MB72%

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=cache para 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-to com cache de registro para compartilhar camadas do BuildKit entre execuções
  • Use --target para construir e depurar estágios específicos sem executar o build completo

Artigos Relacionados