Como criar um Dockerfile menor e mais seguro
Imagem base certa, multi-stage build, usuário não-root e camadas otimizadas: como deixar seu Dockerfile menor e mais seguro na prática.
Você escreve um Dockerfile que funciona, faz o build, sobe a imagem — e descobre que ela tem 1,2 GB, roda como root e carrega um compilador C inteiro que nunca será usado em produção. Pior: o scanner de segurança da pipeline aponta 80 CVEs herdadas de pacotes que você nem sabia que estavam ali. O container funciona, mas é lento para baixar, caro de armazenar e uma porta aberta esperando alguém entrar.
A boa notícia é que enxugar e blindar um Dockerfile não exige reescrever a aplicação. São poucas decisões — qual imagem base usar, como separar build de runtime, sob qual usuário rodar — que, somadas, derrubam o tamanho em 80% e cortam a superfície de ataque drasticamente. Este guia mostra como fazer isso na prática, com exemplos reais, e quando cada técnica realmente compensa.
Por que o tamanho e a segurança andam juntos
Pode parecer que reduzir tamanho e aumentar segurança são metas separadas, mas são o mesmo movimento visto de dois ângulos. Cada pacote dentro da imagem é, ao mesmo tempo, peso no disco e linha de código que pode ter uma vulnerabilidade. Um apt-get install build-essential que você esqueceu de remover não só engorda a imagem em centenas de megabytes — ele entrega ao atacante um gcc, um make e bibliotecas de sistema prontas para compilar um exploit dentro do container.
A regra mental é simples: tudo que não é necessário para rodar a aplicação não deveria estar na imagem final. Ferramentas de build, código-fonte, arquivos de teste, caches de gerenciador de pacotes, documentação — nada disso roda em produção. Quanto menor a imagem, menos coisa existe para dar errado.
Se você ainda está montando seu primeiro container e quer entender os conceitos de imagem, camada e container antes de otimizar, vale ler o guia Docker para iniciantes — aqui o foco é assumir que você já tem um Dockerfile funcionando e quer torná-lo enxuto e seguro.
Comece pela imagem base certa
A escolha da imagem base define o piso do seu tamanho e da sua superfície de ataque. Uma node:20 completa traz Debian inteiro, com bash, curl, git e dezenas de utilitários. A variante node:20-slim corta boa parte disso. A node:20-alpine usa Alpine Linux com musl libc e fica em torno de 50 MB contra ~400 MB da completa.
# Pesada: Debian completo, ~1 GB com dependências
FROM node:20
# Enxuta: ~200 MB
FROM node:20-slim
# Mínima: ~50 MB de base (cuidado com musl libc)
FROM node:20-alpine
Alpine é tentadora, mas tem uma pegadinha: ela usa musl em vez de glibc, e algumas dependências nativas (drivers de banco, bibliotecas que dependem de binários pré-compilados) quebram ou exigem recompilação. Para a maioria dos projetos web -slim é o melhor equilíbrio entre tamanho e compatibilidade. Reserve Alpine para quando você medir e confirmar que vale o megabyte economizado.
No extremo da segurança estão as imagens distroless do Google: elas contêm só o runtime da linguagem e sua aplicação — sem shell, sem gerenciador de pacotes, sem ls. Se um atacante invadir, não há nem um sh para executar comandos. O custo é debugar ficar mais difícil, já que você não consegue abrir um terminal dentro do container.
Separe build de runtime com multi-stage
Esta é, de longe, a técnica de maior impacto. A ideia é usar uma imagem gorda, cheia de ferramentas, só para compilar/transpilar a aplicação — e depois copiar apenas o resultado final para uma imagem limpa e mínima. As camadas do estágio de build são descartadas; só o último estágio vira a imagem publicada.
# Estágio 1: build — tem todas as ferramentas
FROM node:20 AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Estágio 2: runtime — limpo e mínimo
FROM node:20-slim AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=build /app/dist ./dist
CMD ["node", "dist/server.js"]
Repare no que aconteceu: o devDependencies, o TypeScript, o código-fonte e o cache do npm ficaram todos no estágio build e nunca chegam à imagem final. O --from=build copia exclusivamente a pasta dist/. Em projetos compilados como Go ou Rust o ganho é ainda mais brutal: você compila um binário estático no primeiro estágio e o copia para um FROM scratch (imagem literalmente vazia), terminando com uma imagem de poucos megabytes.
Não rode como root
Por padrão, o processo dentro do container roda como root (UID 0). Se houver uma falha na aplicação que permita execução de código, o atacante já começa com privilégios máximos dentro do container — e, dependendo da configuração do host, isso pode escalar para fora dele. Rodar como usuário não-privilegiado é o princípio do menor privilégio aplicado ao container.
FROM node:20-slim AS runtime
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
# Cria um usuário sem privilégios e transfere a posse dos arquivos
RUN groupadd -r app && useradd -r -g app app \
&& chown -R app:app /app
USER app
CMD ["node", "dist/server.js"]
Imagens oficiais como node já trazem um usuário node pronto — nesse caso basta USER node. O ponto crítico é garantir que o usuário tenha permissão de leitura nos arquivos da aplicação (e de escrita só onde for estritamente necessário, como diretórios de upload ou cache).
Otimize camadas e cache
Cada instrução RUN, COPY e ADD cria uma camada. A ordem importa porque o Docker reaproveita o cache enquanto nada mudou — então coloque o que muda menos no topo. O erro clássico é copiar o código-fonte inteiro antes de instalar dependências: qualquer mudança de uma linha invalida o cache e força um npm ci do zero.
# Errado: muda 1 linha de código → reinstala tudo
COPY . .
RUN npm ci
# Certo: dependências só reinstalam se package.json mudar
COPY package*.json ./
RUN npm ci
COPY . .
Também encadeie comandos relacionados em um único RUN e limpe o cache na mesma camada. Limpar em uma camada posterior não adianta — a camada que adicionou os arquivos continua lá, somando ao tamanho final.
# A limpeza precisa estar no MESMO RUN da instalação
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates \
&& rm -rf /var/lib/apt/lists/*
Use .dockerignore e fixe versões
O .dockerignore funciona como o .gitignore: impede que arquivos entrem no contexto de build enviado ao daemon. Sem ele, um COPY . . pode arrastar node_modules local, a pasta .git inteira, arquivos .env com segredos e logs. Isso engorda a imagem, lentifica o build e — no caso do .env — é um vazamento de segurança direto.
node_modules
.git
.env
*.log
dist
coverage
Dockerfile
.dockerignore
Sobre versões: evite FROM node:latest. A tag latest é um alvo móvel — o build de hoje pode diferir do de amanhã, quebrando reprodutibilidade. Fixe a versão (node:20.11-slim) ou, no limite de segurança, fixe o digest SHA256 da imagem para garantir o byte exato.
Perguntas frequentes
Como reduzir o tamanho de uma imagem Docker?
As três alavancas de maior impacto são: trocar a base por uma variante -slim ou -alpine, adotar multi-stage build para descartar ferramentas de compilação, e limpar caches de pacote na mesma camada em que foram criados. Um .dockerignore bem feito e a remoção de devDependencies em produção completam o pacote. Juntas, essas medidas costumam levar uma imagem de 1 GB para a casa dos 100-200 MB.
O que é multi-stage build e quando usar?
É um Dockerfile com mais de um FROM, onde estágios anteriores servem só para compilar/preparar artefatos e o estágio final copia apenas o resultado. Use sempre que houver uma etapa de build (transpilação de TypeScript, compilação de Go/Rust/Java, bundling de front-end). Para uma aplicação interpretada simples sem etapa de build, o ganho é menor, mas ainda ajuda a separar dependências de dev das de produção.
Por que não devo rodar o container como root?
Porque viola o princípio do menor privilégio. Se a aplicação for comprometida, um processo root dentro do container tem muito mais poder de causar dano e de tentar escapar para o host. Criar um usuário dedicado com USER é uma linha de defesa barata que reduz significativamente o impacto de uma invasão.
Alpine é sempre a melhor escolha para imagens pequenas?
Não. Alpine é a menor, mas usa musl libc em vez de glibc, o que pode quebrar dependências nativas e exigir recompilação, além de ter comportamentos sutis diferentes (DNS, timezones). Para muitos casos -slim entrega 80% do ganho de tamanho com 100% da compatibilidade. Meça antes de adotar Alpine como padrão.
O que levar deste guia
Imagem pequena e imagem segura são o mesmo objetivo: tirar da imagem final tudo que não roda em produção. Comece pela base certa (-slim resolve a maioria), separe build de runtime com multi-stage para deixar ferramentas e código-fonte para trás, crie um usuário não-root e ordene as camadas para o cache trabalhar a seu favor. Nenhuma dessas mudanças exige tocar na lógica da aplicação — são ajustes no Dockerfile que pagam dividendos em cada docker pull, cada deploy e cada relatório de segurança. Aplique uma de cada vez, meça o resultado com docker images, e veja o número cair.
- 01 O que é um UUID? Formato, versões e quando usar UUID é um identificador de 128 bits sem coordenação central. Conheça o formato, as 8 versões (v1 a v8), NIL UUID, e quando usar cada versão em bancos e APIs.
- 02 UTF-8 e problemas de encoding: por que acentos quebram e como resolver O que é UTF-8, code points, mojibake e por que "configuração" vira "configuração" — o mecanismo explicado ponta a ponta.