Docker: Curso Intensivo de Cero a Producción

Docker: Curso Intensivo de Cero a Producción

Aprende Docker desde cero: cómo funciona internamente, imágenes, contenedores, volúmenes, redes, docker-compose y patrones reales de producción.

Por Omar Flores

Piensa en cómo se envía mercancía por el océano. Antes de los contenedores de carga, cada barco transportaba productos a granel: sacos de café, cajas de madera, barriles de aceite. Cargar y descargar un barco tardaba semanas. Cada puerto tenía su propio sistema. Un producto que funcionaba bien empaquetado en México llegaba roto a Japón porque el manejo en tránsito era impredecible.

El contenedor de transporte lo cambió todo. Un estándar universal: mismas dimensiones, mismos enganches, mismo manejo en cualquier puerto del mundo. Lo que entra en el contenedor en la fábrica es exactamente lo que sale en el destino. No hay ambigüedad. No hay “en mi puerto funciona”.

Docker hizo lo mismo con el software. Y eso cambió cómo se construye, se distribuye y se opera cualquier aplicación moderna.

El problema que Docker resolvió

Durante años, el flujo de despliegue de software tuvo el mismo problema: la aplicación funcionaba en el equipo del desarrollador, fallaba en el servidor del colega, y producía errores distintos en el servidor de producción. El servidor tenía una versión diferente de Python. La librería del sistema no estaba. La variable de entorno faltaba. La ruta del archivo era distinta.

He visto equipos pasar días enteros depurando diferencias entre ambientes. No porque el código fuera malo, sino porque el ambiente era invisible. La aplicación dependía de decenas de supuestos sobre el sistema operativo subyacente, ninguno documentado, todos frágiles.

La solución intuitiva era documentar el proceso de instalación. Pero la documentación se desactualiza. La solución real es empaquetar no solo el código, sino todo el ambiente que lo hace funcionar.

Cómo funciona Docker internamente

Docker no es una máquina virtual. Una VM emula hardware completo: tiene su propio kernel, su propia memoria virtual, su propio sistema de archivos desde cero. Lanzar una VM tarda minutos y consume gigabytes de RAM.

Docker usa características del kernel de Linux —namespaces y cgroups— para crear procesos aislados dentro del mismo sistema operativo host.

namespaces crean la ilusión de aislamiento: cada contenedor tiene su propio árbol de procesos, su propia interfaz de red, su propio sistema de archivos, su propio hostname. Desde dentro, el contenedor no puede ver los procesos del host ni los de otros contenedores.

cgroups limitan los recursos: cuánta CPU, cuánta RAM, cuánto I/O de disco puede usar cada contenedor. Sin ellos, un contenedor buggy podría consumir todos los recursos del servidor y matar a los demás.

Sistema Host (Linux kernel)
├── Docker Daemon
│   ├── Contenedor A (namespace propio, cgroup propio)
│   │   └── proceso: node app.js [PID 1 dentro del contenedor]
│   ├── Contenedor B (namespace propio, cgroup propio)
│   │   └── proceso: nginx [PID 1 dentro del contenedor]
│   └── Contenedor C (namespace propio, cgroup propio)
│       └── proceso: postgres [PID 1 dentro del contenedor]
└── Procesos normales del host

El resultado: un contenedor arranca en milisegundos y usa solo la memoria que su proceso necesita. Una aplicación Node.js en un contenedor consume exactamente lo mismo que corriendo directamente en el host — no hay overhead de virtualización.

Imágenes y contenedores: la distinción fundamental

Antes de escribir una línea de Dockerfile, es necesario entender la diferencia entre imagen y contenedor.

Una imagen es una plantilla de solo lectura. Es el molde. Contiene el sistema de archivos con todo lo necesario para correr la aplicación: el sistema operativo base, las dependencias, el código, la configuración. Una imagen no corre — existe.

Un contenedor es una instancia en ejecución de una imagen. Es lo que el molde produce. Puedes crear 50 contenedores a partir de la misma imagen. Cada uno es independiente — lo que pasa dentro de uno no afecta a los demás ni a la imagen original.

# La imagen es el molde
docker pull nginx:alpine          # Descargar imagen del registry
docker images                     # Listar imágenes locales

# El contenedor es la instancia
docker run nginx:alpine           # Crear y arrancar un contenedor desde la imagen
docker ps                         # Listar contenedores en ejecución
docker ps -a                      # Listar todos (incluyendo detenidos)

Esta distinción importa porque las imágenes se construyen una vez y se distribuyen. Los contenedores son efímeros — se crean, corren, y se destruyen. El estado que necesita persistir no vive en el contenedor, vive en volúmenes.

El Dockerfile: construir tu propia imagen

Un Dockerfile es un script de instrucciones para construir una imagen. Cada instrucción crea una nueva capa en el sistema de archivos. Las capas se apilan y se comparten entre imágenes — si dos imágenes usan la misma base node:20-alpine, esa capa se descarga y almacena una sola vez en disco.

# Imagen base — el sistema operativo + runtime de partida
FROM node:20-alpine

# Directorio de trabajo dentro del contenedor
WORKDIR /app

# Copiar manifiestos de dependencias primero
# Esta capa solo se reconstruye cuando package.json cambia
COPY package.json package-lock.json ./

# Instalar dependencias
RUN npm ci --frozen-lockfile

# Copiar el resto del código
# Esta capa se reconstruye cuando cualquier archivo de código cambia
COPY . .

# Puerto que la aplicación escucha (documentación — no publica el puerto)
EXPOSE 3000

# Comando que corre cuando el contenedor arranca
CMD ["node", "src/index.js"]

El orden de las instrucciones importa. Docker cachea cada capa — si una capa no cambió, reutiliza el resultado anterior en lugar de reconstruirla. Por eso las dependencias (COPY package.json + RUN npm install) van antes que el código fuente. El código cambia en cada commit; las dependencias cambian raramente. Si pusieras COPY . . primero, cada cambio de código invalidaría el caché de instalación de dependencias, y npm install correría desde cero en cada build.

# Construir la imagen — . es el contexto de build (directorio actual)
docker build -t mi-app:1.0 .

# Construir con etiqueta latest adicional
docker build -t mi-app:1.0 -t mi-app:latest .

# Correr un contenedor desde la imagen recién construida
docker run -p 3000:3000 mi-app:1.0

-p 3000:3000 mapea el puerto 3000 del host al puerto 3000 del contenedor. El formato es host:contenedor. Sin este mapeo, el puerto solo existe dentro de la red interna de Docker.

Multistage builds: imágenes de producción delgadas

Un error frecuente en equipos que empiezan con Docker: llevar las herramientas de construcción a producción. El resultado es una imagen que incluye Node.js completo, el directorio node_modules con miles de archivos de desarrollo, el código fuente TypeScript sin compilar, y herramientas de build que nadie necesita en runtime.

El multistage build usa múltiples etapas FROM en el mismo Dockerfile. La etapa final solo contiene lo que necesitas en producción.

# ── Etapa 1: construcción ────────────────────────────────────────────────────
FROM node:20-alpine AS builder

WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --frozen-lockfile

COPY . .
RUN npm run build          # Compila TypeScript → JavaScript en /app/dist


# ── Etapa 2: producción ──────────────────────────────────────────────────────
FROM node:20-alpine AS production

# Crear usuario no-root — nunca correr en producción como root
RUN addgroup -S appgroup && adduser -S appuser -G appgroup

WORKDIR /app

# Solo las dependencias de producción
COPY package.json package-lock.json ./
RUN npm ci --frozen-lockfile --omit=dev

# Solo el código compilado de la etapa anterior
COPY --from=builder /app/dist ./dist

# Cambiar al usuario sin privilegios
USER appuser

EXPOSE 3000
CMD ["node", "dist/index.js"]

La imagen de producción resultante no tiene TypeScript, no tiene el compilador, no tiene las dependencias de desarrollo. Puede pasar de 1.2GB a menos de 200MB. Menos superficie de ataque, menos tiempo de descarga en deploys, menos costo de almacenamiento en el registry.

USER appuser es una práctica de seguridad crítica. Si alguien explota una vulnerabilidad en tu aplicación y obtiene acceso al contenedor, opera como un usuario sin privilegios — no puede instalar software, no puede leer archivos fuera de /app, no puede escalar privilegios.

Volúmenes: persistencia de datos

Los contenedores son efímeros. Todo lo que se escribe dentro del filesystem de un contenedor desaparece cuando el contenedor se destruye. Esto es intencional — garantiza reproductibilidad. Pero la base de datos, los uploads de usuarios, y los logs necesitan sobrevivir al ciclo de vida del contenedor.

Los volúmenes son el mecanismo de persistencia. Existen fuera del contenedor y se montan dentro de él.

# Crear un volumen nombrado — Docker lo gestiona en /var/lib/docker/volumes/
docker volume create postgres_data

# Montar el volumen al correr el contenedor
docker run \
  -v postgres_data:/var/lib/postgresql/data \
  postgres:16-alpine

# Montar un directorio del host (bind mount) — útil en desarrollo
docker run \
  -v $(pwd)/src:/app/src \    # El código local es visible dentro del contenedor
  -p 3000:3000 \
  mi-app:dev

Hay dos tipos de montaje. Los volúmenes nombrados (postgres_data:/ruta) son gestionados por Docker — viven en el filesystem del host en una ubicación que Docker controla. Son la opción correcta para datos de producción: bases de datos, uploads, certificados.

Los bind mounts ($(pwd)/src:/app/src) montan un directorio del host directamente. Son ideales en desarrollo: editas el código en tu editor, el cambio es inmediatamente visible dentro del contenedor sin reconstruir la imagen.

# Listar volúmenes
docker volume ls

# Ver detalles de un volumen (dónde está en el host)
docker volume inspect postgres_data

# Eliminar volúmenes no utilizados
docker volume prune

Una advertencia importante: eliminar un contenedor no elimina sus volúmenes. Eso es comportamiento correcto — los datos persisten. Pero si creas volúmenes para pruebas y los olvidas, acumulan espacio en disco. docker volume prune elimina los volúmenes que no están montados en ningún contenedor activo.

Redes: comunicación entre contenedores

Por defecto, cada contenedor está aislado. Para que dos contenedores se comuniquen, necesitan estar en la misma red Docker.

Docker crea automáticamente tres redes:

  • bridge — la red por defecto. Los contenedores pueden comunicarse por IP, pero no por nombre.
  • host — el contenedor usa la red del host directamente. Sin aislamiento de red.
  • none — sin red. El contenedor está completamente aislado.

Para producción, siempre crea redes personalizadas. En una red personalizada, los contenedores se resuelven por nombre — api resuelve al IP del contenedor llamado api.

# Crear una red personalizada
docker network create mi-red

# Conectar contenedores a la red
docker run --network mi-red --name api mi-api:latest
docker run --network mi-red --name frontend mi-frontend:latest

# Desde dentro del contenedor frontend, "api" resuelve al contenedor de la API
# curl http://api:3000/health  ← esto funciona

Las redes también permiten segmentar el acceso. Una base de datos no necesita estar en la misma red que el contenedor de Nginx. Si defines frontend_net (Nginx + API) y backend_net (API + DB), Nginx nunca puede alcanzar directamente la base de datos, aunque alguien comprometiera ese contenedor.

Docker Compose: orquestar múltiples servicios

Un proyecto real tiene múltiples servicios. Manejar cada docker run manualmente con todos sus flags, volúmenes, redes y variables de entorno es imposible de mantener.

Docker Compose define todo el stack en un archivo docker-compose.yml declarativo. Un solo comando levanta, detiene y reconstruye todos los servicios en el orden correcto.

# docker-compose.yml
services:

  api:
    build:
      context: ./api
      target: production          # Etapa específica del multistage build
    ports:
      - "3000:3000"               # Exponer al host para desarrollo; en prod quitar esto
    environment:
      - NODE_ENV=production
      - DATABASE_URL=postgresql://postgres:secret@db:5432/appdb
      - JWT_SECRET=${JWT_SECRET}  # Leer del .env del host — nunca hardcodear secretos
    depends_on:
      db:
        condition: service_healthy   # Esperar a que la DB esté lista antes de arrancar
    networks:
      - app_net
      - db_net
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://localhost:3000/health"]
      interval: 30s
      timeout: 5s
      retries: 3

  db:
    image: postgres:16-alpine
    environment:
      - POSTGRES_DB=appdb
      - POSTGRES_USER=postgres
      - POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
    volumes:
      - postgres_data:/var/lib/postgresql/data
      - ./db/init.sql:/docker-entrypoint-initdb.d/init.sql:ro  # Script inicial
    networks:
      - db_net
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres -d appdb"]
      interval: 10s
      timeout: 5s
      retries: 5
    restart: unless-stopped

  redis:
    image: redis:7-alpine
    command: redis-server --maxmemory 256mb --maxmemory-policy allkeys-lru
    volumes:
      - redis_data:/data
    networks:
      - app_net
    restart: unless-stopped

networks:
  app_net:    # API, Redis — servicios de la aplicación
  db_net:     # API, DB — la API necesita ambas redes; la DB solo la suya

volumes:
  postgres_data:
  redis_data:
# Levantar todo el stack en background
docker-compose up -d

# Ver logs de todos los servicios
docker-compose logs -f

# Ver logs de un servicio específico
docker-compose logs -f api

# Reconstruir una imagen y reiniciar ese servicio
docker-compose up -d --build api

# Detener todo sin eliminar volúmenes
docker-compose down

# Detener y eliminar volúmenes (cuidado: borra los datos)
docker-compose down -v

depends_on con condition: service_healthy garantiza que la API no intente conectarse a la base de datos antes de que esté lista para aceptar conexiones. Sin esto, la API arranca, intenta conectarse, falla, y hay que reiniciarla manualmente.

Variables de entorno y secretos

Las variables de entorno son el mecanismo estándar para configurar contenedores por ambiente. Nunca deben estar hardcodeadas en el Dockerfile ni en el docker-compose.yml para valores sensibles.

# .env — en .gitignore, NUNCA en el repositorio
POSTGRES_PASSWORD=supersecreto123
JWT_SECRET=mi-jwt-secret-largo-y-aleatorio
API_KEY=sk-prod-xxxxx
# docker-compose.yml — referencia variables del .env del host
services:
  api:
    environment:
      - POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
      - JWT_SECRET=${JWT_SECRET}

Docker Compose lee automáticamente el archivo .env del mismo directorio. Las variables con ${NOMBRE} se sustituyen al ejecutar docker-compose up.

Para entornos diferentes, puedes tener múltiples archivos de composición:

# Levantar con configuración de staging
docker-compose -f docker-compose.yml -f docker-compose.staging.yml up -d

# Levantar con configuración de producción
docker-compose -f docker-compose.yml -f docker-compose.prod.yml up -d
# docker-compose.prod.yml — sobreescribe valores del base
services:
  api:
    deploy:
      replicas: 3
      resources:
        limits:
          cpus: '0.5'
          memory: 512M

El archivo base define la estructura. Los archivos de override añaden o sobreescriben configuración específica del ambiente. Esto evita duplicar la definición completa del stack para cada ambiente.

Health checks: contenedores que se auto-diagnostican

Un contenedor puede estar “corriendo” según Docker y aun así no estar funcionando — el proceso arrancó pero la aplicación está en un estado inválido, la base de datos no está conectada, o el puerto no responde.

El HEALTHCHECK define cómo Docker verifica si el contenedor está realmente sano.

FROM node:20-alpine AS production

# ... resto del Dockerfile ...

# Docker ejecuta este comando cada 30s
# Si falla 3 veces consecutivas, el contenedor se marca como "unhealthy"
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
    CMD wget -qO- http://localhost:3000/health || exit 1

CMD ["node", "dist/index.js"]

El endpoint /health en tu aplicación debe ser liviano y verificar lo esencial:

// Un health check útil verifica conexiones reales, no solo que el proceso corre
app.get('/health', async (req, res) => {
  try {
    await db.query('SELECT 1');     // Verificar conexión a DB
    await redis.ping();             // Verificar conexión a Redis
    res.json({ status: 'healthy', timestamp: Date.now() });
  } catch (error) {
    res.status(503).json({ status: 'unhealthy', error: error.message });
  }
});

Con health checks configurados, docker-compose up --wait espera hasta que todos los servicios estén healthy antes de retornar. En pipelines de CI/CD, esto garantiza que el ambiente está completamente listo antes de correr las pruebas de integración.

Comandos esenciales de operación

Conocer Docker en producción significa saber cómo investigar qué está pasando sin necesidad de reiniciar cosas a ciegas.

# ── Inspección ──────────────────────────────────────────────────────────────

# Ver procesos corriendo dentro de un contenedor
docker exec mi-api ps aux

# Entrar al shell de un contenedor en ejecución
docker exec -it mi-api sh

# Ver uso de recursos en tiempo real
docker stats

# Ver configuración completa de un contenedor
docker inspect mi-api

# Ver el historial de capas de una imagen
docker history mi-app:latest


# ── Logs ────────────────────────────────────────────────────────────────────

# Últimas 100 líneas
docker logs --tail 100 mi-api

# Logs en tiempo real
docker logs -f mi-api

# Logs con timestamp
docker logs -t mi-api


# ── Limpieza ────────────────────────────────────────────────────────────────

# Eliminar contenedores detenidos, imágenes sin usar, redes sin usar
docker system prune

# Incluir volúmenes (cuidado)
docker system prune --volumes

# Ver cuánto espacio ocupa Docker
docker system df

docker exec -it mi-api sh es el comando de debugging más valioso. Te da un shell interactivo dentro del contenedor — puedes ver los archivos, ejecutar comandos, verificar variables de entorno, y diagnosticar problemas directamente en el ambiente donde la aplicación corre.

Publicar imágenes: Docker Hub y GitHub Container Registry

Las imágenes construidas localmente solo existen en tu máquina. Para usarlas en un servidor de producción o compartirlas con el equipo, necesitas publicarlas en un registry.

# ── Docker Hub ──────────────────────────────────────────────────────────────

# Autenticarse
docker login

# Etiquetar la imagen con tu usuario de Docker Hub
docker tag mi-app:latest usuario/mi-app:latest
docker tag mi-app:latest usuario/mi-app:1.0.0

# Publicar
docker push usuario/mi-app:latest
docker push usuario/mi-app:1.0.0


# ── GitHub Container Registry ────────────────────────────────────────────────

# Autenticarse con un Personal Access Token
echo $GITHUB_TOKEN | docker login ghcr.io -u USUARIO --password-stdin

# Etiquetar
docker tag mi-app:latest ghcr.io/usuario/mi-app:latest

# Publicar
docker push ghcr.io/usuario/mi-app:latest

GitHub Container Registry es la opción más conveniente si ya usas GitHub: las imágenes viven junto al código, los permisos se heredan del repositorio, y los GitHub Actions tienen acceso sin configuración adicional.

CI/CD con Docker y GitHub Actions

El flujo completo de producción: cada push a main construye la imagen, la publica en el registry, y actualiza el servidor.

# .github/workflows/deploy.yml
name: Build and Deploy

on:
  push:
    branches: [main]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Log in to GitHub Container Registry
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Build and push
        uses: docker/build-push-action@v5
        with:
          context: .
          target: production
          push: true
          tags: |
            ghcr.io/${{ github.repository }}:latest
            ghcr.io/${{ github.repository }}:${{ github.sha }}
          # Reutilizar capas del build anterior — reduce tiempo de 5min a 30s
          cache-from: type=gha
          cache-to: type=gha,mode=max

  deploy:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - name: Deploy to production
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.SERVER_IP }}
          username: deploy
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          script: |
            docker pull ghcr.io/${{ github.repository }}:${{ github.sha }}
            docker-compose up -d --no-deps --build api
            docker image prune -f

--no-deps actualiza solo el servicio api sin reiniciar la base de datos ni Redis. cache-from: type=gha usa el caché de GitHub Actions entre builds — la primera vez es lenta, las siguientes usan las capas cacheadas y son dramáticamente más rápidas.

Errores comunes y cómo evitarlos

Correr como root en producción

El antipatrón más peligroso: no definir un usuario en el Dockerfile. Por defecto, los procesos dentro del contenedor corren como root.

# ANTIPATRÓN: root dentro del contenedor
FROM node:20-alpine
COPY . .
CMD ["node", "index.js"]

# CORRECTO: usuario sin privilegios
FROM node:20-alpine
RUN addgroup -S app && adduser -S appuser -G app
USER appuser
COPY --chown=appuser:app . .
CMD ["node", "index.js"]

Guardar secretos en la imagen

# ANTIPATRÓN: la clave queda grabada en una capa de la imagen para siempre
RUN export API_KEY=sk-secret && npm run build

# CORRECTO: los secretos llegan como variables de entorno en tiempo de ejecución
ENV API_KEY=""    # Definir como vacío — el valor real viene en docker run o docker-compose

Una vez que un secreto entra en una capa de imagen, queda ahí permanentemente — incluso si lo “eliminas” en una instrucción posterior. El historial de capas lo preserva. Nunca pases secretos como ARG o ENV en el Dockerfile.

Ignorar el .dockerignore

Sin .dockerignore, el contexto de build incluye node_modules, .git, archivos de logs, y cualquier otra cosa en el directorio. Esto hace que cada build transfiera cientos de MB innecesarios al Docker daemon.

# .dockerignore
node_modules
.git
.env
*.log
dist
coverage
.DS_Store

Un proceso por contenedor

# ANTIPATRÓN: múltiples responsabilidades en un contenedor
# Un contenedor que corre la app Y la base de datos

# CORRECTO: un proceso por contenedor, orquestados por Compose
services:
  api:
    build: ./api
  db:
    image: postgres:16-alpine

Un contenedor, una responsabilidad. Esto permite escalar, reiniciar, y actualizar cada servicio de forma independiente.

Lo que esto significa para el negocio

Docker no es una tecnología que deberías adoptar porque “todo el mundo lo usa”. Es una tecnología que resuelve problemas concretos con costos concretos.

El onboarding de un desarrollador nuevo en un proyecto sin Docker puede tomar dos días completos: instalar dependencias, configurar la base de datos, entender las variables de entorno, depurar diferencias entre su sistema operativo y el del equipo. Con Docker y un docker-compose.yml bien escrito, ese proceso toma veinte minutos: git clone + docker-compose up.

Los incidentes de “funciona en mi máquina” desaparecen cuando el ambiente de desarrollo, staging y producción están definidos en código y son estructuralmente idénticos. No hay suposiciones sobre el sistema operativo del servidor. No hay sorpresas en el deploy.

Y cuando llega el momento de escalar — más instancias de la API, migración a un nuevo servidor, replicación en otra región — Docker hace que la unidad de escala sea la imagen, no un servidor configurado a mano.

La reproductibilidad no es un detalle de implementación. Es lo que separa la infraestructura que un equipo puede operar de la que solo puede esperar que funcione.