//JorgenHoc
← Todos los artículos
.NET HostingPor Jorge CalderónActualizado 16 min read

.NET Docker Container — Dockerfile Listo para Producción

Construye imágenes Docker de nivel productivo para aplicaciones .NET 10: builds multi-etapa, usuarios sin privilegios de root, health checks, caché de capas, comparación de tamaños de imagen y docker-compose para desarrollo local.

#dotnet#docker#devops#cloud

Desplegar una aplicación .NET 10 en Docker es sencillo, pero hacerlo correctamente — imagen pequeña, sin SDK en producción, usuario sin root, health checks — requiere decisiones deliberadas. Esta guía recorre cada capa de un Dockerfile listo para producción y explica el razonamiento detrás de cada decisión.

Por Qué Usar Builds Multi-Etapa

Un Dockerfile ingenuo de una sola etapa copia el SDK en la imagen final. La imagen del SDK de .NET 10 pesa 846 MB sin comprimir (medido con docker image inspect). El código de tu aplicación puede ser de 5 MB. Los builds multi-etapa resuelven esto usando el SDK solo durante la compilación, luego copiando la salida compilada a una imagen de runtime liviana.

# SIN multi-etapa — envía todo el 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"]

Con un build multi-etapa, la imagen final usa aspnet:10.0 (219 MB medidos) o alternativas más pequeñas, y no contiene ninguna herramienta de compilación — lo que también reduce la superficie de ataque.

Elegir una Imagen Base

La imagen base de runtime es la palanca más importante para el tamaño de la imagen. Microsoft publica varias variantes. Los tamaños de abajo están medidos, no citados — la misma API mínima construida sobre cada base, y luego docker image inspect --format '{{.Size}}':

ImagenImagen final de la app (medida)Notas
mcr.microsoft.com/dotnet/aspnet:10.0219 MBDebian, compatibilidad más amplia
mcr.microsoft.com/dotnet/aspnet:10.0-alpine115 MBAlpine, musl libc — verificar dependencias nativas
mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled118 MBUbuntu Chiseled, sin shell, sin gestor de paquetes

Fíjate en lo que dicen las mediciones: chiseled ya no es una ventaja de tamaño frente a Alpine — en .NET 10 sale 3 MB más grande. Las comparaciones antiguas (incluida una versión anterior de este artículo) ponían chiseled en la mitad del tamaño de Alpine; esa brecha se cerró.

💡

La verdadera ventaja de chiseled hoy es la superficie de ataque, no el tamaño: sin shell, sin gestor de paquetes, nada que un atacante pueda ejecutar tras comprometer el proceso. Elígelo por esa razón. Si necesitas hacer exec en contenedores de producción para depurar, esa misma propiedad te va a estorbar.

⚠️

Alpine usa musl libc en lugar de glibc. Algunos paquetes NuGet que envuelven bibliotecas nativas (por ejemplo, ciertas librerías de criptografía o procesamiento de imágenes) se bloquearán en tiempo de ejecución en Alpine. Realiza pruebas exhaustivas antes de comprometerte con Alpine en producción.

Para los ejemplos a continuación se usa la variante Debian para maximizar la compatibilidad, con los equivalentes de Alpine y Chiseled indicados donde corresponde.

Dockerfile Completo para Producción

Este es el Dockerfile completo y listo para copiar para una aplicación ASP.NET Core 10. Una versión ejecutable — este Dockerfile más las variantes Alpine y chiseled y el script que midió los tamaños de arriba — está en samples/dotnet-docker-container.

# ── Etapa 1: restore ──────────────────────────────────────────────────────────
# Paso de restore separado para que Docker pueda cachear la capa hasta que cambien *.csproj.
# Esto evita re-descargar todos los paquetes NuGet en cada cambio de código fuente.
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS restore
WORKDIR /src
 
# Copiar solo los archivos de proyecto primero — esta capa se cachea mientras los .csproj
# no cambien, incluso si el resto del código fuente cambia.
COPY ["src/MyApp/MyApp.csproj", "src/MyApp/"]
COPY ["src/MyApp.Infrastructure/MyApp.Infrastructure.csproj", "src/MyApp.Infrastructure/"]
 
RUN dotnet restore "src/MyApp/MyApp.csproj"
 
# ── Etapa 2: build ────────────────────────────────────────────────────────────
FROM restore AS build
WORKDIR /src
 
# Ahora copiar todo — esta capa cambia frecuentemente, pero restore ya está cacheado
COPY . .
 
RUN dotnet build "src/MyApp/MyApp.csproj" \
    -c Release \
    --no-restore \
    -o /app/build
 
# ── Etapa 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        # deshabilitar el apphost nativo — no es necesario en contenedores
 
# ── Etapa 4: imagen final de runtime ──────────────────────────────────────────
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final
 
WORKDIR /app
 
# Copiar la salida publicada solo desde la etapa de publish
# El SDK, el código fuente y los artefactos de compilación nunca llegan a esta imagen
COPY --from=publish /app/publish .
 
# Indicarle a ASP.NET Core que escuche en el puerto 8080 (no privilegiado, buena práctica)
# ASPNETCORE_URLS sobreescribe los bindings predeterminados https/5000+5001
ENV ASPNETCORE_URLS=http://+:8080
ENV ASPNETCORE_ENVIRONMENT=Production
 
EXPOSE 8080
 
# Sin root: seleccionar el usuario `app` (UID 1654) que trae toda imagen .NET 8+.
# NO lo crees con `RUN adduser` — la imagen Debian de .NET 10 ya no incluye
# adduser/addgroup, así que ese patrón clásico rompe el build (exit 127).
USER app
 
# Deliberadamente sin HEALTHCHECK: esta imagen no contiene ni wget ni curl, así que
# el clásico `CMD wget .../health` marca como no saludable un contenedor sano.
# Consulta la sección HEALTHCHECK más abajo para ver qué funciona en cada imagen base.
ENTRYPOINT ["dotnet", "MyApp.dll"]

¿Por Qué UseAppHost=false?

El apphost nativo es un pequeño binario C que inicializa el runtime de .NET. Dentro de un contenedor siempre se llama a dotnet MyApp.dll directamente, así que generar el apphost es trabajo desperdiciado y agrega algunos KB.

Seguridad del Usuario Sin Root

Ejecutar como root dentro de un contenedor es un riesgo de seguridad bien conocido. Si el proceso es comprometido, el atacante tiene privilegios de root dentro del contenedor y un camino más sencillo para escapar hacia el host.

La solución es una línea, porque toda imagen .NET 8+ — Debian, Alpine y chiseled por igual — trae un usuario sin root llamado app (UID 1654, expuesto en la variable de entorno APP_UID):

USER app
⚠️

El patrón clásico que todavía encontrarás en la mayoría de los tutoriales — RUN addgroup --system appgroup && adduser --system --ingroup appgroup appuserya no compila en la imagen Debian de .NET 10: la base recortada eliminó por completo adduser/addgroup, así que el build falla con código de salida 127. En chiseled nunca pudiste ejecutarlo de todos modos (no hay shell). USER app funciona idéntico en las tres.

Verifica que surtió efecto en lugar de asumirlo: la app de ejemplo devuelve Environment.UserName en GET /, y las tres variantes reportan "user":"app".

Estrategia de Caché de Capas

Docker construye imágenes capa por capa y cachea cada capa. Una capa se invalida cuando su contenido cambia, y todas las capas siguientes se reconstruyen. La regla clave:

Copia los archivos que cambian raramente antes que los que cambian frecuentemente.

# Bien: los archivos de proyecto cambian con menos frecuencia que el código fuente
COPY ["src/MyApp/MyApp.csproj", "src/MyApp/"]
RUN dotnet restore                          # cacheado hasta que cambie .csproj
 
COPY . .                                    # los cambios de código fuente invalidan desde aquí
RUN dotnet build ...
# Mal: copiar todo primero significa que restore se ejecuta en cada cambio de código
COPY . .
RUN dotnet restore                          # nunca cacheado
RUN dotnet build ...

Para un monorepo con múltiples proyectos, copia primero todos los archivos .csproj, luego realiza el restore:

# Copiar todos los archivos de proyecto, preservando la estructura de directorios
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

Sin un .dockerignore, Docker envía todo el contexto de build (incluyendo bin/, obj/, .git/, resultados de pruebas, etc.) al daemon. Esto ralentiza cada build y puede hacer que artefactos obsoletos se cuelen en la imagen.

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

Agrega **/node_modules si tu solución incluye proyectos de frontend. También excluye secretos y archivos .env explícitamente — nunca deberían entrar en un contexto de build de Docker.

Instrucción HEALTHCHECK

La instrucción HEALTHCHECK le dice a Docker cómo verificar que el contenedor está funcionando. Los orquestadores como Kubernetes usan sus propias sondas, pero Docker Compose y Docker independiente dependen de esta instrucción.

Hay una trampa que los ejemplos clásicos omiten, y depende de la imagen base (comprobado en las imágenes de .NET 10, no asumido):

Imagen base¿Puede funcionar HEALTHCHECK CMD?
aspnet:10.0 (Debian)Sin herramientas — no trae ni wget ni curl; el check falla y marca como unhealthy un contenedor sano
aspnet:10.0-alpine — BusyBox proporciona wget
aspnet:10.0-noble-chiseledImposibleCMD necesita /bin/sh, y no hay shell

En Alpine, el patrón clásico funciona tal cual:

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

En Debian tienes dos opciones honestas: instalar una herramienta de sondeo en la etapa final — RUN apt-get update && apt-get install -y --no-install-recommends curl cuesta unos MB y algo de superficie de ataque — o saltarte el check a nivel de Docker y dejar que el orquestador sondee por la red. En chiseled, las sondas del orquestador son la única opción, y eso está bien: Kubernetes, ECS y Azure Container Apps sondean desde fuera del contenedor y no necesitan nada dentro de la imagen.

OpciónValorSignificado
--interval30sVerificar cada 30 segundos
--timeout5sFallar la verificación si no hay respuesta en 5 segundos
--start-period15sPeríodo de gracia mientras la app se inicializa
--retries3Marcar como no saludable después de 3 fallos consecutivos

Tu aplicación ASP.NET Core necesita un endpoint /health. Agrega el middleware de health checks integrado:

// Program.cs
builder.Services.AddHealthChecks();
 
// Mapear el endpoint de health — mantenerlo ligero, sin autenticación requerida
app.MapHealthChecks("/health");

Para un health check más completo que incluya conectividad de base de datos:

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 (¿está vivo el proceso?) de readiness (¿puede servir tráfico?)
app.MapHealthChecks("/health/live", new HealthCheckOptions
{
    Predicate = _ => false   // solo la verificación base, sin dependencias
});
 
app.MapHealthChecks("/health/ready", new HealthCheckOptions
{
    Predicate = check => check.Tags.Contains("database")
});

Variables de Entorno y Configuración

ASP.NET Core lee la configuración de variables de entorno automáticamente. Las más importantes para establecer en el Dockerfile o mediante docker run:

# Variables de entorno predeterminadas en Dockerfile (pueden sobreescribirse en tiempo de ejecución)
ENV ASPNETCORE_ENVIRONMENT=Production
ENV ASPNETCORE_URLS=http://+:8080
 
# NO incluir connection strings ni secretos en la imagen.
# Pasarlos en tiempo de ejecución mediante -e o la sección environment de docker-compose.

Sobreescribir en tiempo de ejecución:

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

El doble guion bajo __ se mapea al separador de dos puntos : en la jerarquía de appsettings.json, que es cómo ASP.NET Core resuelve las claves de configuración anidadas desde variables de entorno.

Argumentos de Build

Usa ARG para pasar valores en tiempo de build sin incluirlos en las capas de la imagen como variables de entorno (los ARGs no se persisten en la imagen 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 con:

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-Arquitectura

Construir solo para linux/amd64 funciona en la mayoría de sistemas de CI pero causa problemas de rendimiento en Apple Silicon (que es linux/arm64). Usa --platform para apuntar explícitamente, o docker buildx para imágenes multiplataforma:

# Construir para una plataforma específica
docker build --platform linux/amd64 -t myapp:latest .
 
# Construir y publicar un manifiesto multi-arch (requiere buildx)
docker buildx build \
  --platform linux/amd64,linux/arm64 \
  -t myregistry.azurecr.io/myapp:latest \
  --push \
  .

Las imágenes base .NET 10 de Microsoft ya son multi-arch, así que tu Dockerfile no necesita cambios — buildx se encarga del resto.

docker-compose para Desarrollo Local

El desarrollo local debería replicar producción lo más posible. El siguiente docker-compose.yml levanta la app con un SQL Server y una instancia de Redis:

# docker-compose.yml
version: "3.9"
 
services:
  app:
    build:
      context: .
      dockerfile: Dockerfile
      # Pasar 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 con PostgreSQL

Si prefieres PostgreSQL sobre 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:

Actualizar la connection string acordemente:

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

docker-compose.override.yml para Comodidad del Desarrollador

Separa la configuración de runtime de las comodidades de desarrollo usando un archivo override (ignorado por CI en producción):

# docker-compose.override.yml — solo usado localmente, no en CI
version: "3.9"
 
services:
  app:
    # Montar código fuente para hot reload en desarrollo
    volumes:
      - .:/src
    environment:
      # Habilitar errores detallados
      ASPNETCORE_ENVIRONMENT: Development
      # Deshabilitar redirección HTTPS en Docker local
      ASPNETCORE_HTTPS_PORT: ""

Comparación Final de Tamaño de Imagen

La misma API mínima, construida con los tres Dockerfiles de este artículo, medida con docker image inspect --format '{{.Size}}' en .NET 10:

Imagen baseImagen final de la appRelativo
aspnet:10.0 (Debian)219 MB1,00x
aspnet:10.0-alpine115 MB0,53x
aspnet:10.0-noble-chiseled118 MB0,54x

Dos cosas que vale la pena notar. Salir de Debian reduce la imagen aproximadamente a la mitad. Y Alpine y chiseled quedan ahora a 3 MB uno de otro, así que elegir entre ellos es cuestión de compatibilidad musl-vs-glibc y superficie de ataque, no de tamaño.

Solo se reportan tamaños sin comprimir, deliberadamente: el tamaño de pull del registro depende del gzip y de qué capas ya tiene en caché el host destino, así que no es una propiedad de la imagen por sí sola. Versiones anteriores de este artículo citaban "tamaños comprimidos de pull" — esos números no eran reproducibles y ya no están.

Reproduce la tabla en tu máquina con compare-image-sizes.sh — construye las tres variantes e imprime esta tabla, además de comprobar qué imágenes pueden ejecutar realmente un HEALTHCHECK.

Salida en terminal del script de comparación: tamaños sin comprimir de 219 MB para Debian, 115 MB para Alpine y 118 MB para chiseled, seguidos de dos verificaciones — solo Alpine tiene wget, chiseled no tiene shell, y los tres contenedores reportan ejecutarse como el usuario sin root app.
La salida del script en esta máquina: la tabla de tamaños más las dos verificaciones — herramientas de HEALTHCHECK por imagen, y prueba de que las tres variantes corren como el usuario app sin root.

Escaneo de Seguridad con Docker Scout

Docker Scout (incluido con Docker Desktop 4.17+) analiza tu imagen en busca de CVEs conocidos:

# Analizar la imagen local
docker scout cves myapp:latest
 
# Mostrar solo vulnerabilidades de severidad crítica y alta
docker scout cves --only-severity critical,high myapp:latest
 
# Comparar dos versiones de imagen
docker scout compare myapp:latest myapp:previous
 
# Obtener un resumen rápido
docker scout quickview myapp:latest

Integra Scout en CI para fallar builds ante vulnerabilidades críticas:

# Paso de GitHub Actions
- name: Escanear imagen en busca de vulnerabilidades
  run: |
    docker scout cves \
      --only-severity critical,high \
      --exit-code \
      myapp:${{ github.sha }}

El flag --exit-code hace que el comando salga con código no cero si se encuentran vulnerabilidades, fallando el paso de CI.

💡

Prefiere las imágenes Chiseled no solo por el tamaño sino por la seguridad: menos paquetes instalados significa menos CVEs. Una imagen Debian viene con cientos de paquetes que tu app nunca usa, cada uno una vulnerabilidad potencial.

Dockerfile Completo para Alpine

Para equipos que han validado que sus dependencias funcionan en 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 el runtime para evitar problemas de dependencia 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
 
# El mismo usuario sin root incorporado que en toda imagen .NET 8+
USER app
 
# Esto funciona específicamente en Alpine — BusyBox proporciona wget
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
    CMD wget -qO- http://localhost:8080/health || exit 1
 
ENTRYPOINT ["dotnet", "MyApp.dll"]

Resumen

DecisiónElección RecomendadaRazón
Build multi-etapaSiempreMantiene el SDK fuera de la imagen de producción
Caché de capasCopiar .csproj primero, luego código fuenteEvita re-ejecutar dotnet restore en cambios de código
Imagen baseChiseled para prod, Debian para devMenor superficie de ataque; Debian para mayor compatibilidad
Usuario sin rootSiempreReduce el impacto de un contenedor comprometido
HEALTHCHECKSiempreRequerido para la condición depends_on de Docker Compose
.dockerignoreSiempreAcelera builds, previene filtración de secretos
ASPNETCORE_URLShttp://+:8080Puerto no privilegiado, binding explícito
Escaneo de seguridadDocker Scout en CIDetecta CVEs antes de que lleguen a producción

Un Dockerfile bien elaborado es infraestructura como código de la misma manera que un archivo Terraform. Debe revisarse, versionarse y actualizarse cuando se publican nuevas imágenes base — especialmente cuando llegan parches de seguridad para el sistema operativo subyacente.

Una vez que la imagen compila limpiamente, el destino de despliegue es en gran medida intercambiable — que es el principal argumento para contenerizar. Tanto Fly.io como Render despliegan un Dockerfile directamente, así que el mismo artefacto que pruebas en local es el que corre en producción.

Lecturas adicionales

Sobre el autor

Jorge Calderón

Ingeniero de software con más de una década construyendo y operando aplicaciones .NET en producción — capas de datos con EF Core, servicios intensivos en async y despliegues en Azure y contenedores. Cada benchmark y proyecto de ejemplo de estas guías está publicado en un repositorio público de GitHub para que puedas reproducirlo.

Perfil de GitHubLinkedIn ↗Benchmarks y código de ejemplo

Artículos relacionados