Multi-stage builds: imagens menores e mais seguras
Sua imagem de produção não precisa carregar compilador nem devDependencies — multi-stage builds descartam isso automaticamente.
Um Dockerfile de um único estágio, como os que você escreveu até aqui, faz o trabalho — mas carrega pra imagem final tudo que serviu só pra construir o app: o código-fonte não compilado, as devDependencies, o cache do gerenciador de pacotes. Nada disso é necessário pra rodar em produção, e tudo isso deixa a imagem maior e com mais superfície de ataque (mais pacotes instalados = mais CVEs em potencial).
O problema com um único estágio
FROM node:20-alpine
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN corepack enable && pnpm install --frozen-lockfile
COPY . .
RUN pnpm build
EXPOSE 3000
CMD ["pnpm", "start"]Essa imagem final contém o pnpm, todo o cache de instalação, as devDependencies (tipos do TypeScript, linters, bibliotecas de teste) e o código-fonte original — nenhuma dessas coisas é usada por pnpm start, que só precisa do resultado do build e das dependências de produção.
FROM ... AS builder — mais de um estágio, uma imagem final
Um Dockerfile pode ter várias instruções FROM, cada uma iniciando um estágio novo. Nomeando um estágio com AS nome, um estágio posterior consegue copiar arquivos específicos dele com COPY --from=nome — só os arquivos escolhidos, nada mais. Tudo que ficou no estágio anterior e não foi copiado simplesmente não existe na imagem final.
# Estágio 1: builder — tem tudo que o build precisa, mas nunca vai pra imagem final
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN corepack enable && pnpm install --frozen-lockfile
COPY . .
RUN pnpm build
# Estágio 2: runner — só o necessário pra RODAR o app
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
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"]O estágio runner nunca roda pnpm install nem tem o pnpm instalado — ele só recebe, via COPY --from=builder, exatamente os arquivos que o Next.js já preparou pra rodar sozinho (o modo standalone do Next.js empacota um server.js com só as dependências de produção que ele de fato usa).
O ganho é visível no tamanho da imagem
docker images minha-appUma imagem de 187MB em vez de 1.24GB significa pull mais rápido no deploy, menos espaço em disco no servidor e, principalmente, menos coisa instalada que pudesse ter uma vulnerabilidade — um compilador ou gerenciador de pacotes a mais na imagem final é uma ferramenta a mais que um atacante teria disponível se conseguisse rodar comandos dentro do container.
Exercício
Um SPA feito com Vite (npm run build gera arquivos estáticos em dist/) hoje é servido rodando npm run preview dentro do próprio container Node — carregando o Node inteiro só pra servir HTML/CSS/JS estático. Reescreva como multi-stage: primeiro estágio builda com Node, segundo estágio serve os arquivos estáticos com nginx:alpine (que nem precisa de Node instalado).