Mi guía para escribir Dockerfiles que no me den vergüenza
Mi primer Dockerfile ocupaba 2.1 GB. No exagero. Usaba una imagen base de Ubuntu completa, instalaba todo lo que se me ocurría por las dudas, y copiaba el código sin pensar en las capas. Hoy mis imágenes de producción pesan entre 60 y 120 MB. Este artículo es ese camino condensado.
El Dockerfile: instrucciones para construir una imagen
Un Dockerfile es un archivo de texto con instrucciones que Docker ejecuta en orden para construir una imagen. Cada instrucción crea una nueva capa. Las capas se cachean, lo que hace que los builds sucesivos sean rápidos — si sabés ordenarlas bien.
Las instrucciones esenciales
# Instrucciones que uso en casi todos mis Dockerfiles:
FROM # imagen base (siempre es la primera instrucción)
WORKDIR # directorio de trabajo dentro del contenedor
COPY # copia archivos del host al contenedor
RUN # ejecuta un comando durante el build
ENV # variables de entorno
EXPOSE # documenta el puerto que usa el contenedor
ENTRYPOINT # comando principal del contenedor
CMD # argumentos por defecto del entrypoint
Mi primer Dockerfile real: una API .NET 8
Cuando tuve que dockerizar mi primera API .NET en el trabajo, empecé con algo que funcionaba pero era terrible. Luego aprendí a hacerlo bien.
La versión ingenua (no hagas esto)
# ❌ Dockerfile ingenuo - imagen de 2GB+
FROM ubuntu:22.04
RUN apt-get update && apt-get install -y wget curl git vim dotnet-sdk-8.0
WORKDIR /app
COPY . .
RUN dotnet build -c Release
EXPOSE 80
CMD ["dotnet", "run", "--project", "MiApi"]
Problemas de esta versión: imagen base enorme, herramientas de desarrollo en producción, SDK completo en runtime, dotnet run en lugar de un ejecutable publicado, y sin usuario no-root.
La versión con multi-stage build (la forma correcta)
# ✅ Dockerfile con multi-stage build - imagen de ~80MB
# STAGE 1: Build
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
# Copiar solo el .csproj primero (aprovecha cache de capas)
COPY ["src/MiApi/MiApi.csproj", "src/MiApi/"]
RUN dotnet restore "src/MiApi/MiApi.csproj"
# Ahora copiar el resto del código
COPY . .
WORKDIR "/src/src/MiApi"
RUN dotnet build "MiApi.csproj" -c Release -o /app/build
# STAGE 2: Publish
FROM build AS publish
RUN dotnet publish "MiApi.csproj" -c Release -o /app/publish /p:UseAppHost=false
# STAGE 3: Runtime (imagen final - solo lo necesario)
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS final
WORKDIR /app
# Usuario no-root
RUN adduser --disabled-password --gecos "" appuser
USER appuser
COPY --from=publish /app/publish .
EXPOSE 80
ENTRYPOINT ["dotnet", "MiApi.dll"]
La magia del multi-stage: la imagen final solo contiene el runtime de ASP.NET y el binario publicado. Todo el SDK, las herramientas de build y el código fuente quedan afuera. El resultado: de 2.1 GB a 82 MB.
Cómo funcionan las capas (y por qué importa el orden)
Cada instrucción RUN, COPY o ADD crea una capa nueva. Docker cachea cada capa y solo reconstruye las que cambiaron y todas las que vienen después. Esta regla cambia completamente cómo ordenás las instrucciones.
# ❌ Orden incorrecto: cualquier cambio en el código invalida el restore
COPY . . # capa 1 - se invalida con cada cambio
RUN dotnet restore # capa 2 - se repite innecesariamente
# ✅ Orden correcto: restore se cachea hasta que cambie el .csproj
COPY ["MiApi.csproj", "."] # capa 1 - solo cambia si el proyecto cambia
RUN dotnet restore # capa 2 - se cachea
COPY . . # capa 3 - cambia con el código
RUN dotnet build -c Release # capa 4 - solo se repite si hay cambios
Buenas prácticas que aplico en todos mis Dockerfiles
1. Siempre usar imágenes base específicas, nunca :latest
# ❌ Impredecible en producción
FROM node:latest
FROM python:latest
# ✅ Reproducible y auditable
FROM node:20.11.0-alpine3.19
FROM python:3.12.2-slim-bookworm
2. Preferir imágenes Alpine o Slim
# node:20-alpine: ~55 MB
# node:20-slim: ~180 MB
# node:20: ~1.1 GB
FROM node:20-alpine
3. Combinar comandos RUN para reducir capas
# ❌ Tres capas separadas
RUN apt-get update
RUN apt-get install -y curl
RUN rm -rf /var/lib/apt/lists/*
# ✅ Una sola capa, sin cache de apt
RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*
4. El .dockerignore es tan importante como el .gitignore
# .dockerignore
**/bin/
**/obj/
**/.git/
**/node_modules/
**/*.log
.env
.env.local
docker-compose*.yml
README.md
5. Nunca hardcodear secretos en el Dockerfile
# ❌ NUNCA - queda grabado en la capa para siempre
ENV DB_PASSWORD=mipassword123
RUN curl -H "Authorization: Bearer TOKEN_SECRETO" https://api.ejemplo.com
# ✅ Pasar en runtime como variables de entorno
docker run -e DB_PASSWORD=$DB_PASSWORD mi-imagen
Comandos esenciales para construir y gestionar imágenes
# Construir imagen
docker build -t mi-api:1.0.0 .
docker build -t mi-api:1.0.0 -f Dockerfile.prod . # Dockerfile específico
docker build --no-cache -t mi-api:latest . # Forzar rebuild completo
# Etiquetar
docker tag mi-api:1.0.0 mi-api:latest
docker tag mi-api:1.0.0 miregistry.local/mi-api:1.0.0
# Ver capas y tamaño
docker history mi-api:1.0.0
docker images mi-api
# Limpiar imágenes no usadas
docker image prune -a # elimina todas las imágenes sin contenedor activo
El resultado: de 2.1 GB a 82 MB
Con el multi-stage build, una imagen base correcta, el orden de capas optimizado y el .dockerignore configurado, pasé de imágenes que tardaban 8 minutos en buildearse y ocupaban gigabytes, a imágenes que buildean en 90 segundos (o 20 segundos con cache) y pesan menos de 100 MB. En producción, eso se traduce en deploys más rápidos, menos espacio en el registry y menor superficie de ataque.
← Artículo anterior: Arquitectura interna de Docker | Serie Docker Completo | Próximo: Ciclo de vida de contenedores →
← Artículo anterior: Por dentro del motor: entendiendo la arquitectura de Docker | Serie Docker Completo | Próximo: docker run y todo lo que nadie te explica del ciclo de vida de un contenedor →

Dejar un comentario
¿Quieres unirte a la conversación?Siéntete libre de contribuir!