Publicando aplicações .NET como containers sem Dockerfile

Publicar aplicações .NET como containers sem Dockerfile é a forma mais simples de empacotar uma API e levá-la para qualquer servidor. Eu sempre mostro bastante infraestrutura no Azure, e vocês me pedem abordagens diferentes para auto-hospedar aplicações .NET, especialmente em ambientes controlados, como VPSs de baixo custo.

A estratégia mais simples, previsível e portátil é publicar essas aplicações como imagens Docker. Assim, elas rodam de forma consistente em qualquer ambiente compatível.

Tradicionalmente, esse processo sempre envolveu criar e manter um Dockerfile. No entanto, nas versões mais recentes do SDK do .NET, essa etapa deixou de ser obrigatória.

Hoje, você gera imagens de container diretamente a partir do próprio dotnet publish, sem escrever uma única linha de Dockerfile. Neste artigo, vamos percorrer o fluxo completo, do projeto local até a execução da imagem a partir de um registry.

Cenário da aplicação

Para exemplificar o processo, considerei uma aplicação .NET minimalista. Ela tem apenas o necessário para validar o empacotamento:

  • Uma API com um único endpoint HTTP.
  • Retorno da data e hora atual em UTC.
  • Exposição da documentação OpenAPI.
  • Uma interface simples de visualização com o Scalar UI.

O objetivo não é a complexidade da aplicação, mas sim demonstrar o fluxo de empacotamento e publicação. Veja como fica o Program.cs de um projeto chamado TimeApi:

using Scalar.AspNetCore;

var builder = WebApplication.CreateBuilder(args);

// Gera o documento OpenAPI da API
builder.Services.AddOpenApi();

var app = builder.Build();

// Expõe o documento OpenAPI e a interface do Scalar
app.MapOpenApi();
app.MapScalarApiReference();

// Retorna a data e hora atual do servidor em UTC
app.MapGet("/time", () => new { utcNow = DateTime.UtcNow });

app.Run();

Ao executar a aplicação localmente com dotnet run, um simples GET /time retorna o horário atual do servidor. Dessa forma, confirmamos que a API está funcional antes de pensar em containers.

Se você ainda não domina esse estilo de API, o artigo sobre Minimal APIs no ASP.NET explica os fundamentos. Já a parte de documentação aparece em detalhes no guia de OpenAPI, Swagger e Scalar na prática.

Containers com Dockerfile na abordagem tradicional

Historicamente, empacotar aplicações .NET em containers sempre passou pela criação de um Dockerfile. Ferramentas como o Visual Studio facilitam esse processo com algumas opções prontas:

  • O menu Add → Container Support.
  • A escolha do sistema operacional, normalmente Linux.
  • A geração automática de um Dockerfile.

O Dockerfile gerado normalmente define a imagem base do runtime do .NET e as portas HTTP e HTTPS. Além disso, ele descreve o processo de build e publish e o comando de inicialização da aplicação.

Um Dockerfile típico de várias etapas para a nossa API ficaria parecido com este:

# Etapa de build com o SDK
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
COPY . .
RUN dotnet publish TimeApi.csproj -c Release -o /app/publish

# Etapa final apenas com o runtime do ASP.NET Core
FROM mcr.microsoft.com/dotnet/aspnet:8.0
WORKDIR /app
COPY --from=build /app/publish .
EXPOSE 8080
ENTRYPOINT ["dotnet", "TimeApi.dll"]

Essa abordagem funciona bem, especialmente em soluções com múltiplos projetos. Contudo, ela adiciona um arquivo extra para manter e uma complexidade desnecessária para projetos simples.

Além disso, o Dockerfile vira mais um ponto de possível divergência entre ambientes. Basta alguém atualizar a versão do .NET no projeto e esquecer de atualizar a imagem base para o problema aparecer.

Containers sem Dockerfile com dotnet publish

O SDK do .NET passou a oferecer suporte nativo à geração de imagens diretamente no processo de publicação. Ou seja, o próprio dotnet publish compila a aplicação, empacota o resultado e cria uma imagem de container válida.

Tudo isso acontece sem Dockerfile. Segundo a documentação oficial sobre como conteinerizar um app .NET com dotnet publish, o SDK cria a imagem sozinho, sem depender do Docker para isso.

Na prática, o Docker ou o Podman só são necessários se você quiser rodar a imagem localmente. Por padrão, porém, o SDK envia a imagem gerada para o runtime de containers local, por isso o Docker precisa estar em execução nesse caso.

A história dos containers sem Dockerfile no SDK

A Microsoft anunciou o recurso de containers sem Dockerfile em agosto de 2022, e ele chegou com o SDK 7.0.100, ainda dependente do pacote Microsoft.NET.Build.Containers. Em seguida, o SDK 7.0.200 trouxe autenticação em registries, builds para outras arquiteturas e melhor suporte a containers sem root.

Nas versões seguintes, o recurso passou a fazer parte do próprio SDK. Assim, no .NET 8 ele se consolidou como o caminho natural para projetos web, junto com mudanças importantes que afetam a forma como você executa a imagem.

  • Porta padrão 8080: as imagens do ASP.NET Core passaram a escutar na porta 8080, e não mais na 80.
  • Usuário sem privilégios: as imagens Linux incluem o usuário app, que o SDK usa por padrão ao gerar a imagem.
  • Tag padrão latest: quando você não informa uma tag, a imagem recebe latest, e não mais a versão do projeto.

Publicando como container

O processo consiste em executar o comando de publicação informando o sistema operacional de destino e o tipo de publicação como container:

# Publica a API como imagem de container Linux x64
dotnet publish --os linux --arch x64 /t:PublishContainer

O parâmetro --os linux define o Linux como sistema de destino, enquanto --arch x64 define a arquitetura. Já o alvo PublishContainer instrui o SDK a gerar a imagem ao final da publicação.

O resultado é uma imagem cujo nome corresponde ao nome do projeto .NET. Após a publicação, basta listar as imagens locais para ver a imagem recém-gerada, pronta para execução:

docker images

Para projetos ASP.NET Core, o SDK escolhe automaticamente a imagem base mcr.microsoft.com/dotnet/aspnet. Além disso, ele usa como tag da imagem base a versão numérica do TargetFramework do projeto.

Configurando a imagem no csproj

Você pode controlar quase tudo pelo arquivo de projeto, usando propriedades do MSBuild. Em geral, o que você configuraria no Dockerfile tem uma propriedade equivalente.

<PropertyGroup>
  <!-- Nome da imagem gerada -->
  <ContainerRepository>time-api</ContainerRepository>
  <!-- Tags aplicadas à imagem -->
  <ContainerImageTags>1.0.0;latest</ContainerImageTags>
  <!-- Variante Alpine da imagem base, menor que a padrão -->
  <ContainerFamily>alpine</ContainerFamily>
</PropertyGroup>

Com essa configuração, a imagem passa a se chamar time-api e recebe duas tags. Por sua vez, o ContainerFamily troca a imagem base pela variante Alpine, que costuma ser menor.

Existe, porém, uma limitação importante. O SDK não executa comandos RUN, então instalar pacotes do sistema operacional exige uma imagem base customizada ou, nesse caso, o bom e velho Dockerfile.

Gerando containers sem Dockerfile em um arquivo tar.gz

Nem sempre você quer enviar a imagem direto para o Docker local. Por isso, o SDK também consegue gravar os containers sem Dockerfile em um arquivo compactado, usando a propriedade ContainerArchiveOutputPath:

# Gera a imagem como arquivo tar.gz, sem precisar de um daemon em execução
dotnet publish --os linux --arch x64 /t:PublishContainer \
  -p ContainerArchiveOutputPath=./images/time-api.tar.gz

Esse comando não exige nenhum runtime de containers ativo na máquina. Além disso, o arquivo gerado pode passar por ferramentas de análise de segurança antes de seguir para o registry.

Depois da análise, basta carregar o arquivo no Docker com docker load -i ./images/time-api.tar.gz. Em seguida, a imagem aparece na lista local como qualquer outra.

Imagens para várias arquiteturas

Muitas VPSs de baixo custo rodam em processadores ARM. Nesse caso, você pode trocar o --arch x64 por --arch arm64 e gerar a imagem para essa arquitetura, mesmo trabalhando em uma máquina x64.

A partir dos SDKs 8.0.405 e 9.0.102, o SDK também combina várias arquiteturas em uma única imagem. Para isso, basta listar os identificadores de runtime na propriedade ContainerRuntimeIdentifiers:

<PropertyGroup>
  <!-- Os RIDs do container precisam fazer parte de RuntimeIdentifiers -->
  <RuntimeIdentifiers>linux-x64;linux-arm64</RuntimeIdentifiers>
  <ContainerRuntimeIdentifiers>linux-x64;linux-arm64</ContainerRuntimeIdentifiers>
</PropertyGroup>

Com essa configuração, o SDK publica a aplicação para cada arquitetura e junta tudo em um índice de imagens OCI. Dessa forma, o mesmo nome de imagem funciona tanto em servidores x64 quanto em servidores ARM.

Executando a imagem localmente

Depois de criada, a imagem funciona exatamente como qualquer outro container Docker. Você mapeia as portas e escolhe entre o modo interativo e o modo detached:

# Executa o container em segundo plano, mapeando a porta 8080
docker run -d -p 8080:8080 --name time-api time-api

# Testa o endpoint exposto pela API
curl http://localhost:8080/time

Lembre-se de que, a partir do .NET 8, a aplicação escuta na porta 8080 dentro do container. Por isso, mapear a porta 80 do container resulta em conexão recusada.

Ao iniciar o container e acessar o endpoint, a aplicação responde corretamente, o que confirma o sucesso do empacotamento. Ferramentas como o Docker Desktop também facilitam a visualização do container em execução, das portas expostas e do status da aplicação.

Se você ainda não tem o Docker configurado, o guia de instalação, configuração e primeiros passos com Docker cobre esse passo a passo.

Enviando containers sem Dockerfile para um registry

Gerar a imagem localmente é apenas parte do fluxo. Para executá-la em servidores externos, você precisa publicá-la em um container registry.

Existem diversas opções no mercado:

  • Docker Hub.
  • Azure Container Registry.
  • Amazon ECR.
  • GitHub Container Registry (GHCR).

Neste fluxo, utilizei o GitHub Container Registry. Ele oferece armazenamento gratuito para imagens públicas, uma cota gratuita para imagens privadas e integração nativa com os repositórios do GitHub.

Vale destacar que os containers sem Dockerfile seguem o mesmo padrão de qualquer imagem. Portanto, o SDK publica em qualquer registry compatível com a API HTTP V2 do Docker Registry.

Autenticação no GitHub Container Registry

Para publicar imagens no GHCR, primeiro crie um Personal Access Token (classic) no GitHub. Em seguida, conceda ao token as permissões necessárias:

  • write:packages, para enviar imagens.
  • read:packages, para baixar imagens.
  • As permissões de repositório, para vincular o pacote ao código.

Com o token criado, faça o login no registry com docker login. Prefira a entrada padrão, porque assim o token não fica exposto no histórico da linha de comando:

# Lê o token de uma variável de ambiente e envia pela entrada padrão
echo $CR_PAT | docker login ghcr.io -u USERNAME --password-stdin

Troque USERNAME pelo seu usuário do GitHub. Afinal, o namespace das imagens no GHCR segue o formato ghcr.io/USERNAME/IMAGE_NAME.

Publicação avançada com metadados

Durante a publicação como container, você também pode informar parâmetros extras para enriquecer a imagem. Os mais úteis aqui são o nome do repositório do container e a URL do repositório de código-fonte, incluída como metadado da imagem.

# Publica definindo o nome da imagem e a origem do código
dotnet publish --os linux --arch x64 /t:PublishContainer \
  -p ContainerRepository=USERNAME/time-api \
  -p RepositoryUrl=https://github.com/USERNAME/time-api \
  -p PublishRepositoryUrl=true

Com PublishRepositoryUrl ativado e o Source Link presente no build, o SDK grava o label org.opencontainers.image.source com a URL do repositório. Esse label é fundamental para o GitHub conectar automaticamente a imagem ao repositório correspondente e exibir a origem do código na página do pacote.

Como esse comportamento depende do Source Link, a forma mais previsível é deixar o label explícito no csproj:

<ItemGroup>
  <!-- Vincula a imagem ao repositório no GitHub -->
  <ContainerLabel Include="org.opencontainers.image.source" Value="https://github.com/USERNAME/time-api" />
</ItemGroup>

Após a publicação, o comando docker inspect confirma a presença dos metadados e dos labels configurados. Dessa forma, você valida tudo antes de enviar a imagem.

Versionamento e envio da imagem

O fluxo final consiste em criar uma tag para a imagem local e associá-la ao namespace do GHCR. Depois disso, basta executar o docker push:

# Associa a imagem local ao namespace do GHCR com uma versão
docker tag USERNAME/time-api:latest ghcr.io/USERNAME/time-api:1.0.0

# Envia a imagem para o registry
docker push ghcr.io/USERNAME/time-api:1.0.0

Após o envio, a imagem fica disponível no registry e aparece na aba Packages do GitHub, já vinculada ao repositório de origem. Por padrão, o GitHub cria o pacote como privado, e você pode alterar a visibilidade nas configurações dele.

Também existe um atalho para os containers sem Dockerfile. Ao informar -p ContainerRegistry=ghcr.io no dotnet publish, o próprio SDK envia a imagem ao registry, sem passar pelo Docker local.

Para quem quer automatizar esse envio em pipelines, o artigo sobre GitHub Packages e GitHub Actions mostra como o ecossistema do GitHub ajuda na distribuição de artefatos.

Execução a partir do registry

Em qualquer ambiente com Docker instalado, como uma VPS, o processo se resume a dois comandos. Primeiro você baixa a imagem publicada e, em seguida, inicia o container:

# Baixa a imagem publicada no GHCR
docker pull ghcr.io/USERNAME/time-api:1.0.0

# Inicia o container em segundo plano
docker run -d -p 8080:8080 ghcr.io/USERNAME/time-api:1.0.0

Se a imagem for privada, faça o docker login no servidor antes do pull. Nesse caso, um token apenas com read:packages já resolve, o que reduz o risco caso o servidor seja comprometido.

Mesmo removendo completamente a imagem local, o Docker consegue baixá-la do registry e executá-la normalmente. Isso comprova a portabilidade do modelo de containers sem Dockerfile.

Vantagens dos containers sem Dockerfile

Depois de percorrer o fluxo inteiro, fica mais fácil enxergar os ganhos. O primeiro deles é a imagem base, porque o SDK escolhe a imagem correta a partir do TargetFramework do projeto.

Ou seja, quando você atualiza a versão do .NET no csproj, a imagem base acompanha a mudança. Com isso, aquele descompasso clássico entre o projeto e o Dockerfile simplesmente deixa de existir.

Outro ganho está na segurança. Como vimos, os containers sem Dockerfile rodam por padrão com o usuário app, sem privilégios de root, o que reduz o impacto de uma eventual falha na aplicação.

Além disso, a configuração mora no mesmo lugar que o restante do projeto. Nome, tags, labels e variáveis de ambiente ficam no csproj, versionados junto com o código e revisados no mesmo pull request.

Por fim, o mesmo comando funciona na sua máquina e no pipeline de integração contínua. Assim, você gera containers sem Dockerfile com um único dotnet publish, sem scripts extras para manter.

Quando containers sem Dockerfile não bastam

A publicação de containers sem Dockerfile resolve a maioria dos cenários de APIs e serviços. No entanto, alguns casos ainda pedem um Dockerfile tradicional.

  • Pacotes do sistema operacional: se a aplicação depende de bibliotecas nativas, você precisa de comandos RUN.
  • Builds complexos: soluções com etapas de build fora do .NET, como compilação de front-end, ficam mais claras no Dockerfile.
  • Padrões corporativos: algumas empresas exigem Dockerfiles revisados e imagens base próprias em todos os projetos.

Mesmo nesses casos, existe um meio-termo. Você pode criar uma imagem base customizada com as dependências e apontá-la na propriedade ContainerBaseImage, mantendo a publicação pelo SDK.

Conclusão

A possibilidade de publicar aplicações .NET como containers sem Dockerfile representa uma evolução significativa na experiência de desenvolvimento e distribuição. Com ela, você reduz a complexidade e mantém menos arquivos no repositório.

Além disso, o processo de publicação fica padronizado e integrado diretamente ao SDK do .NET. Para aplicações simples e médias, especialmente APIs e serviços, essa estratégia se mostra extremamente eficiente.

Em cenários mais complexos, o Dockerfile ainda pode ser necessário, mas deixa de ser um requisito padrão. Esse modelo também abre caminho para estratégias ainda mais interessantes, como a auto-hospedagem de aplicações .NET em VPSs, que aprofunda o controle sobre custos e infraestrutura.

FAQ

Preciso ter o Docker instalado para gerar a imagem?

Não para gerar, porque o SDK do .NET cria a imagem sozinho. O Docker ou o Podman só são necessários para executar a imagem localmente ou quando o destino é o runtime local.

Qual versão do .NET suporta containers sem Dockerfile?

O recurso surgiu no SDK 7.0.100 e ficou integrado ao SDK a partir do .NET 8. Nas versões atuais, basta usar o alvo PublishContainer no dotnet publish.

Por que minha API não responde na porta 80 dentro do container?

A partir do .NET 8, as imagens do ASP.NET Core escutam na porta 8080. Mapeie a porta 8080 do container ou defina a variável ASPNETCORE_HTTP_PORTS.

Como vincular a imagem ao repositório no GitHub?

Adicione o label org.opencontainers.image.source com a URL do repositório. Você pode fazer isso com PublishRepositoryUrl ou com um item ContainerLabel no csproj.

Consigo instalar pacotes do sistema na imagem gerada pelo SDK?

Não diretamente, porque o SDK não executa comandos RUN. A alternativa é usar uma imagem base customizada em ContainerBaseImage ou voltar ao Dockerfile.