Guía práctica para crear Dockerfiles desde cero
¿Qué problema resuelve Docker?
Imagina que desarrollas una aplicación en tu portátil y funciona perfectamente. Cuando tu compañero intenta ejecutarla en su máquina, aparecen errores por versiones distintas de librerías, configuraciones faltantes o diferencias en el sistema operativo. Docker elimina este conflicto al empaquetar tu aplicación junto con todas sus dependencias en una unidad portable denominada contenedor.
| Enfoque tradicional | Con Docker |
|---|---|
| Instalar dependencias manualmente en cada equipo | Todo viene incluido dentro del contenedor |
| Las máquinas virtuales consumen varios GB y tardan en arrancar | Los contenedores pesan decenas de MB y se lanzan en segundos |
Conceptos fundamentales
Antes de escribir un Dockerfile, es necesario entender cuatro elementos clave:
- Imagen: Una plantilla de solo lectura que contiene el sistema operativo base, las herramientas instaladas y tu código. Piensa en ella como una fotografía congelada de un entorno funcional.
- Contenedor: Una instancia en ejecución creada a partir de una imagen. Puedes tener múltiples contenedores basados en la misma imagen.
- Dockerfile: Un archivo de texto con instrucciones secuenciales que describe paso a paso cómo construir una imagen personalizada.
- Registro de imágenes: Un repositorio remoto (como Docker Hub) donde se almacenan y comparten imágenes.
La relación entre estos elementos es análoga a un receta de cocina: el Dockerfile es la receta, la imagen es el plato terminado listo para servir, y el contenedor es cada porción que se sirve individualmente.
Estructura básica de un Dockerfile
# Seleccionamos una imagen base ligera de Debian
FROM debian:bookworm-slim
# Metadatos opcionales del mantenedor
LABEL maintainer="equipo@midominio.com"
# Definimos variables de entorno accesibles durante la construcción y ejecución
ENV RUTA_APP=/opt/miapp
ENV DEBIAN_FRONTEND=noninteractive
# Establecemos el directorio de trabajo
WORKDIR ${RUTA_APP}
# Transferimos archivos desde la máquina host hacia la imagen
COPY configuracion.yml ./
COPY codigo/ ./codigo/
# Ejecutamos comandos dentro de la imagen (cada RUN genera una capa)
RUN apt-get update -qq && \
apt-get install -y --no-install-recommends curl ca-certificates && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
# Declaramos qué puerto usará la aplicación internamente
EXPOSE 8080
# Definimos el comando de inicio
CMD ["./codigo/arrancar.sh"]
Instrucciones esenciales del Dockerfile
FROM — Seleccionar la imagen base
Toda imagen comienza con un FROM. Puedes elegir entre distintas variantes según tus necesidades:
# Variante ligera recomendada para producción
FROM node:20-bookworm-slim
# Variante aún más reducida usando Alpine Linux
FROM python:3.12-alpine
# Imagen genérica cuando necesitas un sistema completo
FROM ubuntu:24.04
RUN — Ejecutar comandos durante la construcción
Cada instrucción RUN genera una nueva capa en la imagen. Para minimizar el tamaño final, conviene agrupar operaciones relacionadas en una sola instrucción:
# Enfoque ineficiente: múltiples capas innecesarias
RUN apt-get update
RUN apt-get install -y nginx
RUN rm -rf /var/lib/apt/lists/*
# Enfoque recomendado: una sola capa optimizada
RUN apt-get update -qq && \
apt-get install -y --no-install-recommends nginx && \
rm -rf /var/lib/apt/lists/*
COPY y ADD — Transferir archivos
COPY es la opción preferida para copiar archivos del contexto de construcción hacia la imagen. ADD realiza lo mismo pero añade funcionalidades extra como descomprimir archivos tar automáticamente, lo cual puede generar comportamientos inespeardos y por ello se desaconseja su uso habitual.
# Copiar un archivo específico
COPY mi_configuracion.ini /etc/aplicacion/
# Copiar todo el contenido del directorio actual
COPY . /opt/servicio/
# ADD descomprime automáticamente (usar con precaución)
ADD recursos.tar.gz /opt/recursos/
CMD y ENTRYPOINT — Definir el proceso principal
CMD establece el comando por defecto que se ejecuta al iniciar el contenedor, pero puede ser sobreescrito al lanzar el contenedor con docker run. ENTRYPOINT fija un ejecutable que no se sobreescribe; los argumentos de CMD se le pasan como parámetros.
# Configuración combinada recomendada
ENTRYPOINT ["/usr/bin/python3"]
CMD ["servidor.py"]
# Al ejecutar: docker run mi-imagen → ejecuta python3 servidor.py
# Al ejecutar: docker run mi-imagen verificador.py → ejecuta python3 verificador.py
EXPOSE — Declarar puertos
EXPOSE es únicamente documentación. Indica qué puertos pretende usar la aplicación, pero no realiza ningún mapeo real. Para acceder al puerto desde fuera, debes usar el parámetro -p al ejecutar el contenedor.
EXPOSE 8080
# Mapeo real: docker run -p 8080:8080 mi-imagen
Ejemplo completo: aplicación web con FastAPI (Python)
Estructura del proyecto
api-fastapi/
├── main.py
├── dependencias.txt
└── Dockerfile
main.py
from fastapi import FastAPI
aplicacion = FastAPI()
@aplicacion.get("/")
async def inicio():
return {"mensaje": "API funcionando correctamente"}
@aplicacion.get("/saludo/{nombre}")
async def saludar(nombre: str):
return {"saludo": f"Hola, {nombre}!"}
if __name__ == "__main__":
import uvicorn
uvicorn.run(aplicacion, host="0.0.0.0", port=8000)
dependencias.txt
fastapi==0.111.0
uvicorn==0.30.1
Dockerfile
# Imagen base de Python optimizada
FROM python:3.12-slim
# Evitar archivos .pyc y forzar salida directa de logs
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
# Crear y usar un directorio dedicado
WORKDIR /srv/app
# Instalar dependencias primero (mejor aprovechamiento de caché)
COPY dependencias.txt .
RUN pip install --no-cache-dir --quiet -r dependencias.txt
# Copiar el código fuente
COPY main.py .
# Declarar el puerto de escucha
EXPOSE 8000
# Lanzar la aplicación con uvicorn
CMD ["uvicorn", "main:aplicacion", "--host", "0.0.0.0", "--port", "8000"]
Construcción y ejecución
# Crear la imagen
docker build -t api-fastapi .
# Iniciar el contenedor
docker run -d -p 8000:8000 --nombre mi-api api-fastapi
# Verificar que funciona
curl http://localhost:8000/
Ejemplo completo: servidor web con Hono (TypeScript)
Dockerfile
# Fase 1: construir el proyecto TypeScript
FROM denoland/deno:2.1.4 AS compilador
WORKDIR /proyecto
COPY deno.json deno.lock* ./
COPY src/ ./src/
RUN deno cache src/index.ts
# Fase 2: imagen de ejecución reducida
FROM denoland/deno:2.1.4
WORKDIR /srv
COPY --from=compilador /proyecto .
EXPOSE 4000
USER deno
CMD ["run", "--allow-net", "--allow-env", "src/index.ts"]
Este ejemplo incluye un usuario no-root (USER deno) para mejorar la seguridad, y utiliza multi-etapa para separar la fase de compilación de la de ejecución.
Ejemplo completo: aplicación Go con compilación multi-etapa
servidor.go
package main
import (
"fmt"
"net/http"
)
func principal(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Servidor Go activo!")
}
func main() {
http.HandleFunc("/", principal)
fmt.Println("Escuchando en puerto 5500...")
http.ListenAndServe(":5500", nil)
}
Dockerfile
# === Etapa de compilación ===
FROM golang:1.23-alpine AS compilador
WORKDIR /fuente
COPY go.mod go.sum ./
RUN go mod download
COPY servidor.go .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o ejecutable servidor.go
# === Etapa de ejecución (imagen mínima) ===
FROM scratch
COPY --from=compilador /fuente/ejecutable /ejecutable
EXPOSE 5500
ENTRYPOINT ["/ejecutable"]
Al usar FROM scratch como base, la imagen final contiene únicamente el binario compilado, alcanzando un tamaño de apenas unos pocos megabytes.
$ docker images mi-servidor-go
REPOSITORY TAG SIZE
mi-servidor-go latest 6.1MB
Recomendaciones de buenas prácticas
| Ámbito | Práctica recomendada | Motivo |
|---|---|---|
| Imagen base | Usar variantes -slim o -alpine |
Reduce drásticamente el tamaño de la imagen |
| Capas | Colocar primero las instrucciones que cambian menos | Aprovecha la caché de construcción y acelera los rebuilds |
| Seguridad | Crear un usuario dedicado con RUN adduser y USER |
Evita ejecutar procesos como root dentro del contenedor |
| Depuración | Borrar cachés de gestores de paquetes tras la instalación | Elimina archivos innecesarios del producto final |
| Contexto | Utilizar un archivo .dockerignore |
Evita copiar archivos innecesarios y posibles secretos |
Archivo .dockerignore
Crea un fichero llamado .dockerignore en la raíz del proyecto para excluir archivos del contexto de construcción:
node_modules
__pycache__
*.pyc
.git
.github
.env
.env.local
dist
build
*.log
Dockerfile
.dockerignore
README.md
LICENSE
Este archivo cumple dos propósitos: acelera el proceso de construcción al reducir el tamaño del contexto enviado al demonio de Docker, y evita incluir secretos como variables de entorno o historiales de control de versiones.
Entorno de desarrollo con recarga automática
Durante el desarrollo, resulta útil que los cambios en el código se reflejen automáticamente sin reconstruir la imagen. Para una aplicación Node.js, puedes montar un volumen y usar un monitor de archivos:
FROM node:20-alpine
WORKDIR /desarrollo
COPY paquete.json paquete-lock.json ./
RUN npm install
RUN npm install -g nodemon
COPY . .
EXPOSE 3000
CMD ["nodemon", "--watch", "src", "--ext", "ts", "src/servidor.ts"]
Lanzar el contenedor con el volumen enlazado:
docker run --rm -v $(pwd)/src:/desarrollo/src -p 3000:3000 mi-app-dev
Cada modificación en la carpeta src del host provocará que nodemon reinicie la aplicación automáticamente dentro del contenedor.
Referencia rápida de comandos de Docker
| Comando | Descripción |
|---|---|
docker build -t etiqueta . |
Construir una imagen a partir del Dockerfile actual |
docker run -d -p externo:interno etiqueta |
Ejecutar un contenedor en segundo plano con mapeo de puertos |
docker ps |
Listar contenedores en ejecución |
docker ps -a |
Listar todos los contenedores (incluyendo detenidos) |
docker images |
Mostrar imágenes almacenadas localmente |
docker logs identificador |
Consultar la salida estándar de un contenedor |
docker exec -it identificador sh |
Abrir una termnial interactiva dentro del contenedor |
docker stop identificador |
Detener un contenedor en ejecución |
docker rm identificador |
Eliminar un contenedor detenido |
docker rmi etiqueta |
Eliminar una imagen local |
Solución de problemas frecuentes
| Síntoma | Causa probable | Solución |
|---|---|---|
bind: address already in use |
El puerto asignado está ocupado por otro proceso | Especificar un puerto alternativo: -p 8081:8080 |
permission denied al acceder a archivos |
El proceso corre como root y el host tiene permisos distintos | Definir un usuario con USER y ajustar propietarios con chown |
| La imagen ocupa varios cientos de MB | Se usó una imagen base completa sin limpiar cachés | Alternar a -slim o -alpine y eliminar listas de paquetes tras instalar |
| Los cambios en el código no surten efecto | Se ejecutó un contenedor con una imagen antigua | Volver a ejecutar docker build antes de docker run |
no such file or directory en COPY |
El archivo no está dentro del contexto de construcción o está en .dockerignore |
Verificar la ruta y revisar el archivo .dockerignore |