//JorgenHoc
← Todos os artigos
.NET HostingPor Jorge CalderónAtualizado 16 min read

.NET Docker Container — Dockerfile Pronto para Produção

Construa imagens Docker de nível produtivo para aplicações .NET 10: builds multi-estágio, usuários sem root, health checks, cache de camadas, comparação de tamanhos de imagem e docker-compose para desenvolvimento local.

#dotnet#docker#devops#cloud

Implantar uma aplicação .NET 10 no Docker é simples, mas fazer isso corretamente — imagem pequena, sem SDK em produção, usuário sem root, health checks — requer decisões deliberadas. Este guia percorre cada camada de um Dockerfile pronto para produção e explica o raciocínio por trás de cada decisão.

Por Que Usar Builds Multi-Estágio

Um Dockerfile ingênuo de estágio único copia o SDK para a imagem final. A imagem do SDK do .NET 10 pesa 846 MB descomprimida (medido com docker image inspect). O código da sua aplicação pode ter 5 MB. Os builds multi-estágio resolvem isso usando o SDK apenas durante a compilação, copiando então a saída compilada para uma imagem de runtime leve.

# SEM multi-estágio — envia todo o SDK (846 MB)
FROM mcr.microsoft.com/dotnet/sdk:10.0
WORKDIR /app
COPY . .
RUN dotnet publish -c Release -o out
ENTRYPOINT ["dotnet", "out/MyApp.dll"]

Com um build multi-estágio, a imagem final usa aspnet:10.0 (219 MB medidos) ou alternativas menores, e não contém nenhuma ferramenta de compilação — o que também reduz a superfície de ataque.

Escolhendo uma Imagem Base

A imagem base de runtime é a alavanca mais importante para o tamanho da imagem. A Microsoft publica diversas variantes. Os tamanhos abaixo foram medidos, não citados — a mesma API mínima construída sobre cada base, e então docker image inspect --format '{{.Size}}':

ImagemImagem final da app (medida)Notas
mcr.microsoft.com/dotnet/aspnet:10.0219 MBDebian, compatibilidade mais ampla
mcr.microsoft.com/dotnet/aspnet:10.0-alpine115 MBAlpine, musl libc — verificar dependências nativas
mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled118 MBUbuntu Chiseled, sem shell, sem gerenciador de pacotes

Observe o que as medições dizem: chiseled já não é uma vantagem de tamanho sobre o Alpine — no .NET 10 ele sai 3 MB maior. Comparações antigas (incluindo uma versão anterior deste artigo) colocavam o chiseled na metade do tamanho do Alpine; essa diferença desapareceu.

💡

A verdadeira vantagem do chiseled hoje é a superfície de ataque, não o tamanho: sem shell, sem gerenciador de pacotes, nada que um atacante possa executar após comprometer o processo. Escolha-o por essa razão. Se você precisa fazer exec em contêineres de produção para depurar, essa mesma propriedade vai atrapalhar.

⚠️

O Alpine usa musl libc em vez de glibc. Alguns pacotes NuGet que envolvem bibliotecas nativas (por exemplo, certas bibliotecas de criptografia ou processamento de imagens) irão travar em tempo de execução no Alpine. Teste extensivamente antes de se comprometer com o Alpine em produção.

Para os exemplos abaixo, a variante Debian é usada para maximizar a compatibilidade, com os equivalentes Alpine e Chiseled indicados onde relevante.

Dockerfile Completo para Produção

Este é o Dockerfile completo e pronto para copiar para uma aplicação ASP.NET Core 10. Uma versão executável — este Dockerfile mais as variantes Alpine e chiseled e o script que mediu os tamanhos acima — está em samples/dotnet-docker-container.

# ── Estágio 1: restore ────────────────────────────────────────────────────────
# Etapa de restore separada para que o Docker possa fazer cache da camada até que *.csproj mude.
# Isso evita re-baixar todos os pacotes NuGet a cada mudança no código-fonte.
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS restore
WORKDIR /src
 
# Copiar apenas os arquivos de projeto primeiro — esta camada é cacheada enquanto os .csproj
# não mudarem, mesmo que o restante do código-fonte mude.
COPY ["src/MyApp/MyApp.csproj", "src/MyApp/"]
COPY ["src/MyApp.Infrastructure/MyApp.Infrastructure.csproj", "src/MyApp.Infrastructure/"]
 
RUN dotnet restore "src/MyApp/MyApp.csproj"
 
# ── Estágio 2: build ──────────────────────────────────────────────────────────
FROM restore AS build
WORKDIR /src
 
# Agora copiar tudo — esta camada muda frequentemente, mas o restore já está em cache
COPY . .
 
RUN dotnet build "src/MyApp/MyApp.csproj" \
    -c Release \
    --no-restore \
    -o /app/build
 
# ── Estágio 3: publish ────────────────────────────────────────────────────────
FROM build AS publish
RUN dotnet publish "src/MyApp/MyApp.csproj" \
    -c Release \
    --no-restore \
    --no-build \
    -o /app/publish \
    /p:UseAppHost=false        # desabilitar o apphost nativo — não necessário em contêineres
 
# ── Estágio 4: imagem final de runtime ────────────────────────────────────────
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final
 
WORKDIR /app
 
# Copiar a saída publicada apenas do estágio de publish
# O SDK, código-fonte e artefatos de compilação nunca chegam a esta imagem
COPY --from=publish /app/publish .
 
# Informar ao ASP.NET Core para escutar na porta 8080 (não privilegiada, boa prática)
# ASPNETCORE_URLS sobrescreve os bindings padrão https/5000+5001
ENV ASPNETCORE_URLS=http://+:8080
ENV ASPNETCORE_ENVIRONMENT=Production
 
EXPOSE 8080
 
# Sem root: selecionar o usuário `app` (UID 1654) que vem em toda imagem .NET 8+.
# NÃO o crie com `RUN adduser` — a imagem Debian do .NET 10 já não inclui
# adduser/addgroup, então esse padrão clássico quebra o build (exit 127).
USER app
 
# Deliberadamente sem HEALTHCHECK: esta imagem não contém nem wget nem curl, então
# o clássico `CMD wget .../health` marca como não saudável um contêiner saudável.
# Veja a seção HEALTHCHECK abaixo para o que realmente funciona em cada imagem base.
ENTRYPOINT ["dotnet", "MyApp.dll"]

Por Que UseAppHost=false?

O apphost nativo é um pequeno binário C que inicializa o runtime do .NET. Dentro de um contêiner você sempre chama dotnet MyApp.dll diretamente, então gerar o apphost é trabalho desperdiçado e adiciona alguns KB.

Segurança do Usuário Sem Root

Executar como root dentro de um contêiner é um risco de segurança bem conhecido. Se o processo for comprometido, o atacante tem privilégios de root dentro do contêiner e um caminho mais simples para escapar para o host.

A correção é uma linha, porque toda imagem .NET 8+ — Debian, Alpine e chiseled igualmente — traz um usuário sem root chamado app (UID 1654, exposto na variável de ambiente APP_UID):

USER app
⚠️

O padrão clássico que você ainda encontrará na maioria dos tutoriais — RUN addgroup --system appgroup && adduser --system --ingroup appgroup appuserjá não compila na imagem Debian do .NET 10: a base enxuta removeu completamente adduser/addgroup, então o build falha com código de saída 127. No chiseled você nunca pôde executá-lo de qualquer forma (não há shell). USER app funciona idêntico nas três.

Verifique que surtiu efeito em vez de assumir: a app de exemplo retorna Environment.UserName em GET /, e as três variantes reportam "user":"app".

Estratégia de Cache de Camadas

O Docker constrói imagens camada por camada e faz cache de cada camada. Uma camada é invalidada quando seu conteúdo muda, e todas as camadas seguintes são reconstruídas. A regra principal:

Copie arquivos que mudam raramente antes dos que mudam frequentemente.

# Correto: arquivos de projeto mudam menos frequentemente do que o código-fonte
COPY ["src/MyApp/MyApp.csproj", "src/MyApp/"]
RUN dotnet restore                          # cacheado até que .csproj mude
 
COPY . .                                    # mudanças no código-fonte invalidam a partir daqui
RUN dotnet build ...
# Incorreto: copiar tudo primeiro significa que o restore é executado em cada mudança de código
COPY . .
RUN dotnet restore                          # nunca cacheado
RUN dotnet build ...

Para um monorepo com múltiplos projetos, copie todos os arquivos .csproj primeiro, depois faça o restore:

# Copiar todos os arquivos de projeto, preservando a estrutura de diretórios
COPY ["Directory.Build.props", "."]
COPY ["src/MyApp/MyApp.csproj", "src/MyApp/"]
COPY ["src/MyApp.Infrastructure/MyApp.Infrastructure.csproj", "src/MyApp.Infrastructure/"]
COPY ["src/MyApp.Contracts/MyApp.Contracts.csproj", "src/MyApp.Contracts/"]
RUN dotnet restore "src/MyApp/MyApp.csproj"

.dockerignore

Sem um .dockerignore, o Docker envia todo o contexto de build (incluindo bin/, obj/, .git/, resultados de testes, etc.) para o daemon. Isso torna cada build mais lento e pode fazer com que artefatos obsoletos entrem na imagem.

# .dockerignore
**/.git
**/.gitignore
**/.vs
**/.vscode
**/bin
**/obj
**/out
**/*.user
**/*.md
**/tests
**/TestResults
Dockerfile*
docker-compose*
.dockerignore
README.md
💡

Adicione **/node_modules se sua solução inclui projetos de frontend. Também exclua segredos e arquivos .env explicitamente — eles nunca devem entrar em um contexto de build do Docker.

Instrução HEALTHCHECK

A instrução HEALTHCHECK informa ao Docker como testar se o contêiner está funcionando. Orquestradores como o Kubernetes usam suas próprias sondas, mas o Docker Compose e o Docker independente dependem dessa instrução.

Há uma pegadinha que os exemplos clássicos omitem, e ela depende da imagem base (verificado nas imagens do .NET 10, não assumido):

Imagem baseHEALTHCHECK CMD pode funcionar?
aspnet:10.0 (Debian)Sem ferramentas — não traz nem wget nem curl; o check falha e marca como unhealthy um contêiner saudável
aspnet:10.0-alpineSim — o BusyBox fornece wget
aspnet:10.0-noble-chiseledImpossívelCMD precisa de /bin/sh, e não há shell

No Alpine, o padrão clássico funciona como está:

# Somente Alpine: o BusyBox traz wget embutido
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
    CMD wget -qO- http://localhost:8080/health || exit 1

No Debian você tem duas opções honestas: instalar uma ferramenta de sondagem no estágio final — RUN apt-get update && apt-get install -y --no-install-recommends curl custa alguns MB e alguma superfície de ataque — ou pular o check no nível do Docker e deixar o orquestrador sondar pela rede. No chiseled, as sondas do orquestrador são a única opção, e isso é aceitável: Kubernetes, ECS e Azure Container Apps sondam de fora do contêiner e não precisam de nada dentro da imagem.

OpçãoValorSignificado
--interval30sVerificar a cada 30 segundos
--timeout5sFalhar a verificação se não houver resposta em 5 segundos
--start-period15sPeríodo de carência enquanto o app inicializa
--retries3Marcar como não saudável após 3 falhas consecutivas

Sua aplicação ASP.NET Core precisa de um endpoint /health. Adicione o middleware de health checks integrado:

// Program.cs
builder.Services.AddHealthChecks();
 
// Mapear o endpoint de health — mantê-lo leve, sem autenticação necessária
app.MapHealthChecks("/health");

Para um health check mais completo que inclua conectividade de banco de dados:

builder.Services.AddHealthChecks()
    .AddSqlServer(
        connectionString: builder.Configuration.GetConnectionString("DefaultConnection")!,
        name: "sql-server",
        tags: ["database", "sql"])
    .AddRedis(
        redisConnectionString: builder.Configuration.GetConnectionString("Redis")!,
        name: "redis",
        tags: ["cache"]);
 
// Separar liveness (o processo está vivo?) de readiness (pode servir tráfego?)
app.MapHealthChecks("/health/live", new HealthCheckOptions
{
    Predicate = _ => false   // apenas a verificação base, sem dependências
});
 
app.MapHealthChecks("/health/ready", new HealthCheckOptions
{
    Predicate = check => check.Tags.Contains("database")
});

Variáveis de Ambiente e Configuração

O ASP.NET Core lê a configuração de variáveis de ambiente automaticamente. As mais importantes para definir no Dockerfile ou via docker run:

# Padrões de variáveis de ambiente no Dockerfile (podem ser sobrescritos em tempo de execução)
ENV ASPNETCORE_ENVIRONMENT=Production
ENV ASPNETCORE_URLS=http://+:8080
 
# NÃO inclua connection strings ou segredos na imagem.
# Passe-os em tempo de execução via -e ou seção environment do docker-compose.

Sobrescrever em tempo de execução:

docker run -p 8080:8080 \
  -e ConnectionStrings__DefaultConnection="Server=db;Database=myapp;..." \
  -e ASPNETCORE_ENVIRONMENT=Staging \
  myapp:latest

O duplo sublinhado __ mapeia para o separador dois-pontos : na hierarquia do appsettings.json, que é como o ASP.NET Core resolve chaves de configuração aninhadas a partir de variáveis de ambiente.

Argumentos de Build

Use ARG para passar valores em tempo de build sem incluí-los nas camadas da imagem como variáveis de ambiente (os ARGs não são persistidos na imagem final):

ARG BUILD_VERSION=1.0.0
ARG GIT_COMMIT=unknown
 
LABEL org.opencontainers.image.version="${BUILD_VERSION}"
LABEL org.opencontainers.image.revision="${GIT_COMMIT}"
LABEL org.opencontainers.image.source="https://github.com/myorg/myapp"

Construir com:

docker build \
  --build-arg BUILD_VERSION=2.1.0 \
  --build-arg GIT_COMMIT=$(git rev-parse --short HEAD) \
  -t myapp:2.1.0 .

Builds Multi-Arquitetura

Construir apenas para linux/amd64 funciona na maioria dos sistemas de CI, mas causa problemas de desempenho no Apple Silicon (que é linux/arm64). Use --platform para seleção explícita, ou docker buildx para imagens multiplataforma:

# Construir para uma plataforma específica
docker build --platform linux/amd64 -t myapp:latest .
 
# Construir e publicar um manifesto multi-arch (requer buildx)
docker buildx build \
  --platform linux/amd64,linux/arm64 \
  -t myregistry.azurecr.io/myapp:latest \
  --push \
  .

As imagens base do .NET 10 da Microsoft já são multi-arch, portanto seu Dockerfile não precisa de alterações — o buildx cuida do resto.

docker-compose para Desenvolvimento Local

O desenvolvimento local deve replicar a produção o máximo possível. O seguinte docker-compose.yml levanta a aplicação com um SQL Server e uma instância de Redis:

# docker-compose.yml
version: "3.9"
 
services:
  app:
    build:
      context: .
      dockerfile: Dockerfile
      # Passar argumentos de build
      args:
        BUILD_VERSION: "dev"
    ports:
      - "8080:8080"
    environment:
      ASPNETCORE_ENVIRONMENT: Development
      ASPNETCORE_URLS: http://+:8080
      ConnectionStrings__DefaultConnection: >-
        Server=sqlserver;Database=MyApp;
        User Id=sa;Password=YourStrong!Passw0rd;
        TrustServerCertificate=True
      ConnectionStrings__Redis: redis:6379
    depends_on:
      sqlserver:
        condition: service_healthy
      redis:
        condition: service_started
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://localhost:8080/health"]
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 20s
 
  sqlserver:
    image: mcr.microsoft.com/mssql/server:2022-latest
    environment:
      SA_PASSWORD: "YourStrong!Passw0rd"
      ACCEPT_EULA: "Y"
    ports:
      - "1433:1433"
    volumes:
      - sqldata:/var/opt/mssql
    healthcheck:
      test: ["CMD", "/opt/mssql-tools/bin/sqlcmd",
             "-S", "localhost", "-U", "sa",
             "-P", "YourStrong!Passw0rd", "-Q", "SELECT 1"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 30s
 
  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"
 
volumes:
  sqldata:

Alternativa com PostgreSQL

Se você preferir PostgreSQL ao SQL Server:

  postgres:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: myapp
      POSTGRES_USER: myapp
      POSTGRES_PASSWORD: mypassword
    ports:
      - "5432:5432"
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U myapp"]
      interval: 10s
      timeout: 5s
      retries: 5
 
volumes:
  pgdata:

Atualizar a connection string adequadamente:

    environment:
      ConnectionStrings__DefaultConnection: >-
        Host=postgres;Database=myapp;
        Username=myapp;Password=mypassword

docker-compose.override.yml para Conveniência do Desenvolvedor

Separe a configuração de runtime das conveniências de desenvolvimento usando um arquivo override (ignorado pelo CI de produção):

# docker-compose.override.yml — usado apenas localmente, não no CI
version: "3.9"
 
services:
  app:
    # Montar código-fonte para hot reload no desenvolvimento
    volumes:
      - .:/src
    environment:
      # Habilitar erros detalhados
      ASPNETCORE_ENVIRONMENT: Development
      # Desabilitar redirecionamento HTTPS no Docker local
      ASPNETCORE_HTTPS_PORT: ""

Comparação Final de Tamanho de Imagem

A mesma API mínima, construída com os três Dockerfiles deste artigo, medida com docker image inspect --format '{{.Size}}' no .NET 10:

Imagem baseImagem final da appRelativo
aspnet:10.0 (Debian)219 MB1,00x
aspnet:10.0-alpine115 MB0,53x
aspnet:10.0-noble-chiseled118 MB0,54x

Duas coisas que valem nota. Sair do Debian reduz a imagem aproximadamente pela metade. E Alpine e chiseled agora ficam a 3 MB um do outro, então escolher entre eles é uma questão de compatibilidade musl-vs-glibc e superfície de ataque, não de tamanho.

Apenas tamanhos descomprimidos são reportados, deliberadamente: o tamanho de pull do registro depende do gzip e de quais camadas o host de destino já tem em cache, então não é uma propriedade da imagem por si só. Versões anteriores deste artigo citavam "tamanhos comprimidos de pull" — esses números não eram reproduzíveis e foram removidos.

Reproduza a tabela na sua máquina com compare-image-sizes.sh — ele constrói as três variantes e imprime esta tabela, além de verificar quais imagens podem realmente executar um HEALTHCHECK.

Saída no terminal do script de comparação: tamanhos descomprimidos de 219 MB para Debian, 115 MB para Alpine e 118 MB para chiseled, seguidos de duas verificações — apenas o Alpine tem wget, o chiseled não tem shell, e os três contêineres reportam executar como o usuário sem root app.
A saída do script nesta máquina: a tabela de tamanhos mais as duas verificações — ferramentas de HEALTHCHECK por imagem, e prova de que as três variantes rodam como o usuário app sem root.

Varredura de Segurança com Docker Scout

O Docker Scout (incluído com o Docker Desktop 4.17+) analisa sua imagem em busca de CVEs conhecidos:

# Analisar a imagem local
docker scout cves myapp:latest
 
# Mostrar apenas vulnerabilidades de severidade crítica e alta
docker scout cves --only-severity critical,high myapp:latest
 
# Comparar duas versões de imagem
docker scout compare myapp:latest myapp:previous
 
# Obter um resumo rápido
docker scout quickview myapp:latest

Integre o Scout no CI para falhar builds em vulnerabilidades críticas:

# Passo do GitHub Actions
- name: Varrer imagem em busca de vulnerabilidades
  run: |
    docker scout cves \
      --only-severity critical,high \
      --exit-code \
      myapp:${{ github.sha }}

O flag --exit-code faz com que o comando saia com código não zero se vulnerabilidades forem encontradas, falhando o passo do CI.

💡

Prefira imagens Chiseled não apenas pelo tamanho, mas pela segurança: menos pacotes instalados significa menos CVEs. Uma imagem Debian vem com centenas de pacotes que sua aplicação nunca usa, cada um uma vulnerabilidade potencial.

Dockerfile Completo para Alpine

Para equipes que validaram que suas dependências funcionam no Alpine:

FROM mcr.microsoft.com/dotnet/sdk:10.0-alpine AS restore
WORKDIR /src
COPY ["src/MyApp/MyApp.csproj", "src/MyApp/"]
RUN dotnet restore "src/MyApp/MyApp.csproj" \
    --runtime linux-musl-x64           # RID musl para Alpine
 
FROM restore AS build
WORKDIR /src
COPY . .
RUN dotnet build "src/MyApp/MyApp.csproj" \
    -c Release \
    --no-restore \
    -o /app/build
 
FROM build AS publish
RUN dotnet publish "src/MyApp/MyApp.csproj" \
    -c Release \
    --no-restore \
    --no-build \
    --runtime linux-musl-x64 \
    --self-contained true \             # incluir o runtime para evitar problemas de dependência musl
    /p:UseAppHost=false \
    -o /app/publish
 
FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS final
 
WORKDIR /app
COPY --from=publish /app/publish .
 
ENV ASPNETCORE_URLS=http://+:8080
EXPOSE 8080
 
# O mesmo usuário sem root embutido de toda imagem .NET 8+
USER app
 
# Isto funciona especificamente no Alpine — o BusyBox fornece wget
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
    CMD wget -qO- http://localhost:8080/health || exit 1
 
ENTRYPOINT ["dotnet", "MyApp.dll"]

Resumo

DecisãoEscolha RecomendadaMotivo
Build multi-estágioSempreMantém o SDK fora da imagem de produção
Cache de camadasCopiar .csproj primeiro, depois código-fonteEvita re-executar dotnet restore em mudanças de código
Imagem baseChiseled para prod, Debian para devMenor superfície de ataque; Debian para maior compatibilidade
Usuário sem rootSempreReduz o impacto de um contêiner comprometido
HEALTHCHECKSempreNecessário para a condição depends_on do Docker Compose
.dockerignoreSempreAcelera builds, evita vazamento de segredos
ASPNETCORE_URLShttp://+:8080Porta não privilegiada, binding explícito
Varredura de segurançaDocker Scout no CIDetecta CVEs antes de chegarem à produção

Um Dockerfile bem elaborado é infraestrutura como código da mesma forma que um arquivo Terraform. Deve ser revisado, versionado e atualizado quando novas imagens base são lançadas — especialmente quando chegam patches de segurança para o sistema operacional subjacente.

Depois que a imagem compila corretamente, o destino de deploy é em grande parte intercambiável — o que é o principal argumento para containerizar. Tanto Fly.io quanto Render fazem deploy de um Dockerfile diretamente, então o mesmo artefato que você testa localmente é o que roda em produção.

Leituras adicionais

Sobre o autor

Jorge Calderón

Engenheiro de software com mais de uma década construindo e operando aplicações .NET em produção — camadas de dados com EF Core, serviços intensivos em async e implantações em Azure e contêineres. Todos os benchmarks e projetos de exemplo destes guias estão publicados em um repositório público no GitHub para que você possa reproduzi-los.

Perfil no GitHubLinkedIn ↗Benchmarks e código de exemplo

Artigos relacionados