Um Dockerfile ingênuo para .NET produz imagens de 500MB+ com o SDK completo, ferramentas de build, arquivos temporários e vulnerabilidades desnecessárias. Com multi-stage build e algumas técnicas de otimização, você chega a imagens abaixo de 80MB, sem SDK em produção e rodando como non-root. Este artigo mostra como.
O Dockerfile básico — e por que não usar em produção
# ❌ NÃO faça isso em produção
FROM mcr.microsoft.com/dotnet/sdk:8.0
WORKDIR /app
COPY . .
RUN dotnet publish -c Release -o /publish
EXPOSE 8080
ENTRYPOINT ["dotnet", "/publish/MyApp.dll"]
Problemas desta abordagem: imagem de ~900MB com todo o SDK, ferramentas de build, código-fonte, arquivos de projeto e todos os pacotes NuGet em cache. Todo COPY . . invalida o cache mesmo que apenas um arquivo de código tenha mudado.
Multi-stage build completo e otimizado
# ============================================================
# Estágio 1: Restore das dependências (camada de cache)
# ============================================================
FROM mcr.microsoft.com/dotnet/sdk:8.0-alpine AS restore
WORKDIR /src
# Copia APENAS os arquivos de projeto e solution primeiro
# Isso faz o restore ser cacheado enquanto o código não muda os .csproj
COPY ["src/Api/Api.csproj", "src/Api/"]
COPY ["src/Application/Application.csproj", "src/Application/"]
COPY ["src/Domain/Domain.csproj", "src/Domain/"]
COPY ["src/Infrastructure/Infrastructure.csproj", "src/Infrastructure/"]
COPY ["MyApp.sln", "./"]
# Restore é cacheado enquanto os .csproj não mudam
RUN dotnet restore --runtime linux-musl-x64
# ============================================================
# Estágio 2: Build e publish
# ============================================================
FROM restore AS build
# Agora copia o código-fonte (invalida cache apenas quando código muda)
COPY src/ src/
RUN dotnet publish src/Api/Api.csproj \
--configuration Release \
--runtime linux-musl-x64 \
--self-contained false \
--no-restore \
--output /publish \
/p:PublishSingleFile=false \
/p:DebugType=None \
/p:DebugSymbols=false \
/p:UseAppHost=false
# ============================================================
# Estágio 3: Imagem de produção (sem SDK, sem código-fonte)
# ============================================================
FROM mcr.microsoft.com/dotnet/aspnet:8.0-alpine AS production
# Instalar dependências de ICU para globalização (necessário para Alpine)
RUN apk add --no-cache icu-libs
ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false \
ASPNETCORE_URLS=http://+:8080 \
DOTNET_RUNNING_IN_CONTAINER=true \
# Desabilita telemetria (LGPD / privacidade)
DOTNET_CLI_TELEMETRY_OPTOUT=1 \
ASPNETCORE_ENVIRONMENT=Production
# ============================================================
# Segurança: Non-root user
# ============================================================
RUN addgroup -g 1001 -S appgroup && \
adduser -u 1001 -S appuser -G appgroup
WORKDIR /app
# Copia apenas o output compilado do estágio de build
COPY --from=build --chown=appuser:appgroup /publish .
# Cria diretório para arquivos temporários da aplicação
RUN mkdir -p /app/tmp && chown appuser:appgroup /app/tmp
# Troca para non-root ANTES do EXPOSE/ENTRYPOINT
USER appuser
EXPOSE 8080
# Health check nativo do Docker
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1
ENTRYPOINT ["dotnet", "Api.dll"]
Variante com imagem Distroless (ainda menor e mais segura)
# Distroless: sem shell, sem package manager, sem utilitários Linux
# Superfície de ataque mínima
FROM gcr.io/distroless/dotnet8-aspnet:nonroot AS production-distroless
WORKDIR /app
COPY --from=build /publish .
ENV ASPNETCORE_URLS=http://+:8080
# :nonroot tag já usa user 65532
USER 65532:65532
EXPOSE 8080
ENTRYPOINT ["/app/Api"] # Requer PublishSingleFile=true ou runtime já embutido
Distroless não tem shell — isso significa que docker exec -it container bash não funciona. Para debug emergencial, use imagens :debug que incluem BusyBox.
Cache de camadas: a técnica que mais acelera o build
O Docker reusa camadas cacheadas da última build. A ordem das instruções no Dockerfile determina o aproveitamento do cache. A regra é: mude o que muda menos primeiro, o que muda mais por último.
# ✅ Cache otimizado — na prática, 80% das builds aproveitam cache de restore
COPY ["src/Api/Api.csproj", "src/Api/"] # Muda raramente
COPY ["src/Application/Application.csproj", ...] # Muda raramente
RUN dotnet restore # Cacheado até mudar um .csproj
COPY src/ src/ # Muda a cada commit
RUN dotnet publish ... # Invalidado só quando código muda
# ❌ Cache quebrado — qualquer mudança de código refaz o restore inteiro
COPY . .
RUN dotnet restore
RUN dotnet publish
BuildKit e cache de NuGet entre builds
# syntax=docker/dockerfile:1
# Usar BuildKit com mount de cache (evita re-baixar pacotes NuGet)
FROM mcr.microsoft.com/dotnet/sdk:8.0-alpine AS restore
WORKDIR /src
COPY ["src/Api/Api.csproj", "src/Api/"]
# ... outros .csproj
# Cache do NuGet montado como volume — persiste entre builds na mesma máquina
RUN --mount=type=cache,target=/root/.nuget/packages \
dotnet restore --runtime linux-musl-x64
COPY src/ src/
RUN --mount=type=cache,target=/root/.nuget/packages \
dotnet publish src/Api/Api.csproj \
--configuration Release \
--no-restore \
--output /publish
# Habilitar BuildKit (já padrão no Docker 23+)
export DOCKER_BUILDKIT=1
# Build com cache ativo
docker build \
--build-arg BUILDKIT_INLINE_CACHE=1 \
--cache-from myapp:latest \
-t myapp:$(git rev-parse --short HEAD) \
-t myapp:latest \
.
Secrets no build: nunca em variáveis de ambiente
# ❌ NUNCA faça isso — a chave fica visível em `docker history`
ARG NUGET_AUTH_TOKEN
ENV NUGET_AUTH_TOKEN=$NUGET_AUTH_TOKEN
RUN dotnet restore
# ✅ Use BuildKit secrets — não ficam na imagem nem no histórico
# syntax=docker/dockerfile:1
RUN --mount=type=secret,id=nuget_token \
NUGET_AUTH_TOKEN=$(cat /run/secrets/nuget_token) \
dotnet restore
# Passar o secret no build (nunca aparece na imagem final)
docker build \
--secret id=nuget_token,src=~/.config/nuget/token \
.
Análise de vulnerabilidades com Trivy
# Instalar Trivy (scanner de CVE open-source da Aqua Security)
brew install aquasecurity/trivy/trivy # macOS
# ou
apt-get install trivy # Debian/Ubuntu
# Escanear imagem antes de fazer push
trivy image myapp:latest
# Modo strict — falha o pipeline se houver CRITICAL ou HIGH
trivy image \
--exit-code 1 \
--severity CRITICAL,HIGH \
--ignore-unfixed \
myapp:latest
# Gerar relatório SARIF para GitHub Security
trivy image \
--format sarif \
--output trivy-results.sarif \
myapp:latest
docker-compose para desenvolvimento local
# docker-compose.yml
services:
api:
build:
context: .
dockerfile: Dockerfile
target: build # Para dev, use o estágio de build (tem hot-reload)
ports:
- "8080:8080"
volumes:
# Hot-reload em desenvolvimento
- ./src:/src:ro
environment:
- ASPNETCORE_ENVIRONMENT=Development
- DOTNET_USE_POLLING_FILE_WATCHER=1
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: myapp
POSTGRES_USER: myapp
POSTGRES_PASSWORD: devpassword
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U myapp"]
interval: 5s
timeout: 5s
retries: 10
redis:
image: redis:7-alpine
ports:
- "6379:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
volumes:
postgres_data:
GitHub Actions completo com cache e scan
# .github/workflows/docker.yml
name: Build and Push Docker Image
on:
push:
branches: [main]
pull_request:
branches: [main]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
security-events: write # Para upload do SARIF
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GHCR
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=sha,prefix=sha-
type=ref,event=branch
type=semver,pattern={{version}}
- name: Build image
uses: docker/build-push-action@v5
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
# Exportar localmente para scan (não faz push ainda)
load: ${{ github.event_name == 'pull_request' }}
- name: Scan com Trivy
uses: aquasecurity/trivy-action@master
with:
image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:sha-${{ github.sha }}
format: sarif
output: trivy-results.sarif
severity: CRITICAL,HIGH
exit-code: 1
ignore-unfixed: true
- name: Upload resultado do Trivy para GitHub Security
uses: github/codeql-action/upload-sarif@v3
if: always() # Upload mesmo se o scan falhar
with:
sarif_file: trivy-results.sarif
Comparação de tamanhos de imagem
| Abordagem | Tamanho | CVEs típicas | Non-root |
|---|---|---|---|
| SDK completo (ubuntu) | ~900MB | Muitas | Não |
| aspnet:8.0 (ubuntu) | ~215MB | Moderadas | Não |
| aspnet:8.0-alpine | ~105MB | Poucas | ✅ (configurado) |
| distroless/dotnet8-aspnet | ~80MB | Mínimas | ✅ (built-in) |
Checklist de produção
- Multi-stage obrigatório: SDK nunca vai para produção
- Non-root user: UID/GID > 1000, nunca root
- Cache de camadas:
.csprojantes do código-fonte - Alpine ou Distroless: superfície de ataque mínima
- Trivy no pipeline: bloqueia push se CRITICAL/HIGH não corrigido
- BuildKit secrets: nenhum token/senha em ARG ou ENV
- HEALTHCHECK: Docker e Kubernetes detectam containers travados
- Imagem imutável: nunca instale pacotes no container em runtime
Quer um review do seu Dockerfile e pipeline de CI/CD? Na Neryx fazemos auditoria de segurança e performance de containers como parte da nossa consultoria DevOps. Entre em contato.