Skip to content

🎯 Meta: empaquetar cualquier aplicación para que corra igual en tu máquina, en la del compañero y en el servidor de producción. Docker resuelve el eterno "en mi máquina funciona". Es la herramienta más importante de DevOps moderno.

Versión: Docker Engine 28.x · Docker Compose v2.


12.1 · El problema que resuelve Docker

Sin Docker:                          Con Docker:
"En mi máquina funciona"             "Funciona en el contenedor"
Tú:      PHP 8.5, Postgres 18        La imagen lleva TODO dentro:
Server:  PHP 8.1, Postgres 15  💥    código + PHP 8.5 + libs + config
Compañero: Windows, tú Mac      💥    → corre idéntico en cualquier lado ✅

Un contenedor empaqueta tu app con todo lo que necesita (runtime, librerías, config) en una unidad que corre igual en cualquier máquina con Docker. No es una máquina virtual: comparte el kernel del sistema, por eso es ligero y rápido (arranca en milisegundos, pesa MB no GB).

Máquina Virtual                      Contenedor
┌──────────────────┐                 ┌──────────────────┐
│  App              │                 │  App              │
│  Librerías        │                 │  Librerías        │
│  SO INVITADO      │  ← pesado       │  (comparte kernel)│  ← ligero
│  completo (GB)    │                 └──────────────────┘
├──────────────────┤                 ┌──────────────────┐
│  Hypervisor       │                 │  Docker Engine    │
├──────────────────┤                 ├──────────────────┤
│  SO anfitrión     │                 │  SO anfitrión     │
└──────────────────┘                 └──────────────────┘

12.2 · Conceptos fundamentales

IMAGEN        →  la "plantilla" de solo lectura (tu app empaquetada). Como una clase.
CONTENEDOR    →  una instancia en ejecución de una imagen. Como un objeto.
DOCKERFILE    →  la receta para construir una imagen.
REGISTRY      →  almacén de imágenes (Docker Hub, GitHub Container Registry).
VOLUMEN       →  almacenamiento persistente (los datos sobreviven al contenedor).
RED (network) →  cómo se comunican los contenedores entre sí.

🧠 Imagen vs Contenedor: la imagen es la receta + ingredientes congelados; el contenedor es el plato cocinado y servido. De una imagen lanzas muchos contenedores idénticos.

Comandos básicos:

bash
docker pull postgres:18            # descarga una imagen
docker run postgres:18             # crea y arranca un contenedor
docker ps                          # contenedores corriendo
docker ps -a                       # todos (incluidos parados)
docker images                      # imágenes locales
docker logs <id>                   # ver los logs de un contenedor
docker exec -it <id> bash          # entrar a un contenedor en marcha
docker stop <id> / docker rm <id>  # parar / eliminar
docker system prune                # limpiar basura (imágenes/contenedores sueltos)

12.3 · Tu primer Dockerfile

Un Dockerfile describe cómo construir la imagen de tu app. Ejemplo para FastAPI (Python):

dockerfile
# Imagen base: Python 3.13 ligero
FROM python:3.13-slim

# Directorio de trabajo dentro del contenedor
WORKDIR /app

# Copiar SOLO las dependencias primero (aprovecha la caché de capas)
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# Copiar el resto del código
COPY . .

# Puerto que expone la app
EXPOSE 8000

# Comando al arrancar
CMD ["gunicorn", "main:app", "-w", "4", "-k", "uvicorn.workers.UvicornWorker", "-b", "0.0.0.0:8000"]
bash
docker build -t tienda-api .          # construir la imagen (el "." es el contexto)
docker run -p 8000:8000 tienda-api    # ejecutar, mapeando el puerto

💡 Tip de oro (orden de capas): copia requirements.txt e instala antes de copiar el código. Docker cachea cada instrucción; si solo cambias tu código, no reinstala las dependencias (que es lo lento). Este truco te ahorra minutos en cada build. Aplica igual a package.json (Node), composer.json (PHP), go.mod (Go).

Instrucciones de Dockerfile más usadas

InstrucciónQué hace
FROMImagen base de la que partes
WORKDIRCarpeta de trabajo dentro del contenedor
COPY / ADDCopiar archivos al contenedor
RUNEjecutar un comando al construir (instalar deps)
ENVVariable de entorno
EXPOSEDocumentar qué puerto usa (informativo)
CMDComando por defecto al arrancar el contenedor
ENTRYPOINTComando fijo (CMD le pasa argumentos)

12.4 · Multi-stage builds (imágenes pequeñas)

Compilas en una imagen "gorda" y copias solo el resultado a una imagen mínima. Brilla con Go:

dockerfile
# ─── Etapa 1: compilar ───
FROM golang:1.26 AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o servidor .

# ─── Etapa 2: imagen final mínima ───
FROM scratch                        # imagen VACÍA (0 bytes de base)
COPY --from=builder /app/servidor /servidor
EXPOSE 8080
ENTRYPOINT ["/servidor"]

Resultado: una imagen de ~10 MB (solo el binario), en vez de ~800 MB. Menos peso = despliegues más rápidos, menos superficie de ataque, menos coste.

Para NestJS/Node el patrón multi-stage también aplica:

dockerfile
FROM node:24-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:24-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev              # solo dependencias de producción
COPY --from=build /app/dist ./dist
CMD ["node", "dist/main.js"]

Para Bun/Elysia, la base es oven/bun:1 y arranca en milisegundos.

💡 Usa imágenes slim o alpine siempre que puedas. Alpine es Linux minimalista (~5 MB). Solo cuidado: alpine usa musl en vez de glibc, lo que a veces da problemas con librerías nativas de Python. Si algo raro pasa, prueba la variante slim.


12.5 · Docker Compose (varios contenedores juntos)

Una app real tiene varias piezas: la API, la base de datos, quizá Redis, Nginx… Docker Compose las orquesta con un solo archivo compose.yaml:

yaml
# compose.yaml
services:
  api:
    build: .                       # construye desde el Dockerfile local
    ports:
      - "8000:8000"
    environment:
      DATABASE_URL: postgresql://postgres:secreto@db:5432/tienda
    depends_on:
      db:
        condition: service_healthy  # espera a que la BD esté lista
    restart: unless-stopped

  db:
    image: postgres:18
    environment:
      POSTGRES_PASSWORD: secreto
      POSTGRES_DB: tienda
    volumes:
      - datos_db:/var/lib/postgresql/data    # persiste los datos
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      retries: 5

  redis:
    image: redis:7-alpine
    restart: unless-stopped

volumes:
  datos_db:                        # volumen nombrado (persistente)
bash
docker compose up -d              # levanta todo en segundo plano
docker compose logs -f api        # ver logs de un servicio
docker compose ps                 # estado
docker compose down               # parar y eliminar (los volúmenes quedan)
docker compose down -v            # ...incluyendo volúmenes (borra datos!)

🧠 La magia de las redes de Compose: los servicios se ven entre sí por su nombre. La API se conecta a la BD con el host db (no localhost), porque Compose crea una red interna donde cada servicio es un hostname. Por eso el DATABASE_URL dice @db:5432.


12.6 · Volúmenes: no pierdas tus datos

Un contenedor es efímero: si lo borras, se va todo lo que había dentro. Para que los datos sobrevivan (la BD, archivos subidos) usas volúmenes:

yaml
volumes:
  - datos_db:/var/lib/postgresql/data     # volumen nombrado (gestionado por Docker)
  - ./uploads:/app/uploads                # bind mount (carpeta de tu máquina)
  • Volumen nombrado (datos_db:): Docker lo gestiona, ideal para BD en producción.
  • Bind mount (./carpeta:): mapea una carpeta real de tu disco, útil en desarrollo (editas el código y se refleja dentro del contenedor al instante).

⚠️ El error que todos cometen una vez: correr PostgreSQL en Docker sin volumen, meter datos importantes, borrar el contenedor y… adiós datos. Siempre volumen para cualquier cosa que deba persistir.


12.7 · Variables de entorno y secretos

Nunca metas contraseñas en el Dockerfile ni en la imagen. Usa variables de entorno:

yaml
services:
  api:
    env_file:
      - .env                       # carga variables desde un archivo
    environment:
      APP_ENV: production          # o directamente aquí

⚠️ Los secretos NO van en la imagen. Cualquiera que baje tu imagen puede inspeccionar sus capas y ver un secreto "quemado" dentro. Pásalos en runtime (env vars) o usa Docker Secrets / el gestor de secretos de tu nube. Y el .env fuera de git (cap. 00).


12.8 · Buenas prácticas de Docker (checklist)

  1. Imagen base oficial y con versión fija: python:3.13-slim, no python:latest. latest cambia sin avisar y rompe builds reproducibles.
  2. Multi-stage para imágenes pequeñas.
  3. .dockerignore para no copiar basura (node_modules, .git, .env) a la imagen:
    node_modules
    .git
    .env
    __pycache__
    *.log
  4. Un proceso por contenedor. No metas API + BD + Nginx en el mismo contenedor.
  5. Usuario no-root dentro del contenedor (seguridad):
    dockerfile
    RUN adduser --disabled-password appuser
    USER appuser
  6. Healthchecks para que Docker/orquestador sepa si tu app está viva.
  7. Ordena las capas de menos a más cambiante (deps antes que código).
  8. No guardes datos en el contenedor: usa volúmenes.

12.9 · Escaneo de vulnerabilidades e imágenes seguras

Una imagen python:3.13-slim de hace 6 meses puede arrastrar librerías del sistema con CVEs ya conocidos. Escanéala antes de subirla a producción, igual que escaneas dependencias de tu app (apéndice C):

bash
# Trivy — el escáner de imágenes más usado en 2026, gratis y rápido
trivy image tienda-api:latest

# Falla el pipeline si hay vulnerabilidades CRITICAL sin parchear
trivy image --severity CRITICAL --exit-code 1 tienda-api:latest
yaml
# En CI (cap. 14), justo después de docker build
- name: Escanear imagen
  run: trivy image --severity HIGH,CRITICAL --exit-code 1 ghcr.io/miapp/api:${{ github.sha }}

💡 Genera también un SBOM (Software Bill of Materials — el "listado de ingredientes" de tu imagen: qué paquetes y versiones exactas contiene) con trivy image --format cyclonedx o docker sbom. Cuando aparezca un CVE nuevo mañana, puedes preguntar "¿alguna de mis imágenes en producción lo tiene?" sin volver a escanear todo desde cero.

⚠️ Reconstruye imágenes viejas aunque tu código no haya cambiado. Si tu Dockerfile fija FROM python:3.13-slim y nunca reconstruyes, sigues sirviendo los parches de seguridad del sistema operativo del día que la construiste, no los de hoy. Programa reconstrucciones periódicas (semanal) igual que programas backups.


12.10 · De Docker a producción (un vistazo)

  • Un servidor (VPS): docker compose up -d detrás de Nginx (cap. 13). Suficiente para la mayoría de proyectos.
  • Escala grande: orquestadores como Kubernetes (k8s) gestionan cientos de contenedores en muchas máquinas: auto-escalado, auto-reparación, despliegues sin downtime. Es potente y complejo; no lo necesitas hasta que lo necesitas de verdad.
  • Nube gestionada: servicios como Google Cloud Run, AWS ECS/Fargate o Railway corren tus contenedores sin que administres servidores.

🧠 Consejo: domina Docker + Compose + un VPS con Nginx primero. Cubre el 90% de los casos. Kubernetes es una herramienta de escala que añade muchísima complejidad; apréndela cuando un proyecto real lo pida, no "por CV".


✅ Ejercicio del capítulo

Dockeriza una de tus APIs del blog, completa:

1. Dockerfile multi-stage optimizado (caché de dependencias bien ordenada).
2. .dockerignore correcto.
3. Usuario no-root dentro del contenedor.
4. compose.yaml con: tu api + PostgreSQL (con volumen) + healthcheck.
5. La API se conecta a la BD por el nombre del servicio (no localhost).
6. Variables sensibles vía .env (fuera de git).
7. `docker compose up -d` y comprueba que la API responde y los datos
   persisten tras `docker compose down` y volver a levantar.
8. Mide el tamaño final de la imagen y optimízalo (base slim/alpine).

Tu app ahora corre igual en cualquier máquina del planeta. Falta ponerle un buen portero delante: Nginx.

💡 Pistas de la solución
  • Si el build tarda igual al cambiar solo tu código, revisa el orden de las instrucciones: COPY requirements.txt . / RUN pip install... debe ir ANTES de COPY . . — si están al revés, Docker invalida la caché de dependencias en cada cambio de código.
  • Para verificar que la API usa el nombre del servicio y no localhost: apaga temporalmente el volumen de datos, borra el contenedor de la API y vuelve a levantarlo — si sigue conectando a la BD sin cambiar código, el hostname está bien resuelto por Compose.
  • El tamaño de la imagen se mide con docker images — compara antes/después de pasar a multi-stage y slim/alpine. Una reducción de 10× (de ~800MB a ~80MB) es normal en Node; en Go, de ~800MB a ~10MB con scratch.

🧠 Autoevaluación

Docker cachea cada instrucción del Dockerfile por capas. Si el archivo de dependencias no cambió, la instrucción RUN npm install/pip install reutiliza la capa cacheada aunque el resto del código sí haya cambiado — evitando reinstalar todo en cada build.

Un contenedor es efímero: todo lo que se escribe dentro de su sistema de archivos desaparece al borrarlo. Sin un volumen que persista /var/lib/postgresql/data fuera del ciclo de vida del contenedor, borrar (o recrear) el contenedor de la BD borra todos los datos sin aviso.

Compose crea una red interna donde cada servicio es accesible por su nombre — "db" resuelve a la IP del contenedor de PostgreSQL dentro de esa red. "localhost" dentro del contenedor de la API se refiere al propio contenedor de la API, no al de la base de datos.

La primera etapa incluye todo el toolchain de compilación de Go (pesado); la segunda etapa parte de una imagen mínima (incluso scratch, vacía) y copia SOLO el binario compilado. El resultado final no lleva compilador, código fuente ni dependencias de build — solo el ejecutable, típicamente unos pocos MB frente a los cientos del entorno de compilación.

No necesariamente: python:3.13-slim es una etiqueta fija en tu Dockerfile, pero el sistema operativo base dentro de esa imagen puede acumular CVEs conocidos que no se parchean solos — cada build reutiliza la capa base tal como estaba cuando la descargaste. Hay que reconstruir (y volver a escanear con algo como Trivy) periódicamente, aunque tu propio código no haya cambiado una línea.


Siguiente: 13-nginx-apache.md — el servidor web que recibe al mundo.