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}}':
| Imagen | Imagen final de la app (medida) | Notas |
|---|---|---|
mcr.microsoft.com/dotnet/aspnet:10.0 | 219 MB | Debian, compatibilidad más amplia |
mcr.microsoft.com/dotnet/aspnet:10.0-alpine | 115 MB | Alpine, musl libc — verificar dependencias nativas |
mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled | 118 MB | Ubuntu 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 appEl patrón clásico que todavía encontrarás en la mayoría de los tutoriales —
RUN addgroup --system appgroup && adduser --system --ingroup appgroup appuser —
ya 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.mdAgrega **/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 | Sí — BusyBox proporciona wget |
aspnet:10.0-noble-chiseled | Imposible — CMD 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 1En 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ón | Valor | Significado |
|---|---|---|
--interval | 30s | Verificar cada 30 segundos |
--timeout | 5s | Fallar la verificación si no hay respuesta en 5 segundos |
--start-period | 15s | Período de gracia mientras la app se inicializa |
--retries | 3 | Marcar 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:latestEl 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=mypassworddocker-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 base | Imagen final de la app | Relativo |
|---|---|---|
aspnet:10.0 (Debian) | 219 MB | 1,00x |
aspnet:10.0-alpine | 115 MB | 0,53x |
aspnet:10.0-noble-chiseled | 118 MB | 0,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.

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:latestIntegra 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ón | Elección Recomendada | Razón |
|---|---|---|
| Build multi-etapa | Siempre | Mantiene el SDK fuera de la imagen de producción |
| Caché de capas | Copiar .csproj primero, luego código fuente | Evita re-ejecutar dotnet restore en cambios de código |
| Imagen base | Chiseled para prod, Debian para dev | Menor superficie de ataque; Debian para mayor compatibilidad |
| Usuario sin root | Siempre | Reduce el impacto de un contenedor comprometido |
| HEALTHCHECK | Siempre | Requerido para la condición depends_on de Docker Compose |
| .dockerignore | Siempre | Acelera builds, previene filtración de secretos |
ASPNETCORE_URLS | http://+:8080 | Puerto no privilegiado, binding explícito |
| Escaneo de seguridad | Docker Scout en CI | Detecta 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.