← maurobernal.com.ar

Docker Compose de cero a producción — Parte 3: Proyectos reales y estrategias Dev vs Prod

Llegamos al cierre de la serie Docker Compose de cero a producción. En la Parte 1 vimos el archivo compose.yaml, en la Parte 2 los comandos y las dependencias robustas. Ahora lo llevamos a la práctica: dos proyectos reales completos y la estrategia que uso para manejar configuraciones distintas entre desarrollo y producción.

Proyecto 1: WordPress + MySQL

El ejemplo clásico de un stack de dos niveles: una aplicación web y su base de datos. Este proyecto completo vive en un solo archivo.

Estructura del proyecto

wordpress_project/
└── compose.yaml

compose.yaml

services:
  db:
    image: mysql:8.0
    container_name: wordpress_db
    restart: unless-stopped
    environment:
      MYSQL_DATABASE: wordpress
      MYSQL_USER: wp_user
      MYSQL_PASSWORD: wp_password
      MYSQL_ROOT_PASSWORD: root_secret_password
    volumes:
      - db_data:/var/lib/mysql
    networks:
      - app_network
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 30s

  wordpress:
    image: wordpress:latest
    container_name: wordpress_app
    restart: unless-stopped
    depends_on:
      db:
        condition: service_healthy   # Espera que MySQL esté listo de verdad
    ports:
      - "8000:80"
    environment:
      WORDPRESS_DB_HOST: db:3306     # 'db' es el nombre del servicio = hostname
      WORDPRESS_DB_USER: wp_user
      WORDPRESS_DB_PASSWORD: wp_password
      WORDPRESS_DB_NAME: wordpress
    networks:
      - app_network

networks:
  app_network:
    driver: bridge

volumes:
  db_data:
    driver: local

Puesta en marcha

cd wordpress_project
docker compose up -d

# Esperar unos segundos y visitar http://localhost:8000
# El instalador de WordPress estará listo

Lo que me parece elegante de este ejemplo: WORDPRESS_DB_HOST: db:3306. No hay IP, no hay configuración de DNS manual. El nombre del servicio db funciona directamente como hostname gracias a la red que Compose crea automáticamente. Esto es lo que hace que los entornos sean tan portables.

Proyecto 2: API REST con Node.js y PostgreSQL

Este ejemplo es más representativo del trabajo diario: construimos nuestra propia imagen desde un Dockerfile y configuramos un entorno de desarrollo con hot-reload.

Estructura del proyecto

api_project/
├── compose.yaml
├── Dockerfile
├── index.js
└── package.json

Dockerfile

FROM node:18-alpine
WORKDIR /usr/src/app
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 3000
CMD ["npm", "start"]

index.js

const express = require('express');
const { Pool } = require('pg');
const app = express();

const pool = new Pool({
  user: process.env.DB_USER,
  host: process.env.DB_HOST,      // Viene del ambiente: 'db'
  database: process.env.DB_NAME,
  password: process.env.DB_PASSWORD,
  port: 5432,
});

app.get('/', async (req, res) => {
  try {
    const result = await pool.query('SELECT NOW()');
    res.send(`Hora en la base de datos: ${result.rows[0].now}`);
  } catch (err) {
    res.status(500).send('Error conectando a la base de datos');
  }
});

app.listen(3000, () => console.log('API corriendo en http://localhost:3000'));

compose.yaml

services:
  db:
    image: postgres:15-alpine
    environment:
      POSTGRES_DB: apidb
      POSTGRES_USER: apiuser
      POSTGRES_PASSWORD: apipassword
    volumes:
      - pg_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U apiuser -d apidb"]
      interval: 10s
      timeout: 5s
      retries: 5

  api:
    build: .
    ports:
      - "3000:3000"
    volumes:
      - .:/usr/src/app       # Bind mount: cambios en el código se reflejan al instante
    environment:
      DB_HOST: db
      DB_USER: apiuser
      DB_PASSWORD: apipassword
      DB_NAME: apidb
    depends_on:
      db:
        condition: service_healthy

volumes:
  pg_data: {}
# La primera vez, --build para construir la imagen
docker compose up --build -d

# Verificar que funciona
curl http://localhost:3000
# → Hora en la base de datos: 2025-05-01 14:23:45.123456+00

El bind mount (- .:/usr/src/app) es el truco de desarrollo: cualquier cambio en el código se refleja dentro del contenedor sin reconstruir la imagen. Para que los cambios se apliquen automáticamente en Node.js, agregá nodemon como dependencia de desarrollo y usalo en el CMD.

Gestión de entornos: desarrollo vs producción

Esta fue una de las lecciones más importantes que aprendí: nunca uses el mismo archivo compose.yaml sin modificaciones para desarrollo y producción. Las necesidades son opuestas: en desarrollo querés agilidad y debuggear fácil; en producción, estabilidad, seguridad y recursos bien definidos.

Estrategia con compose.override.yaml

Compose tiene un mecanismo automático: si existe un compose.override.yaml en el mismo directorio, lo fusiona automáticamente con el compose.yaml base al ejecutar docker compose up.

# compose.yaml — Base (versionado en Git)
services:
  api:
    build: .
    restart: unless-stopped
    environment:
      - APP_ENV=production
# compose.override.yaml — Desarrollo (puede NO versionarse si tiene configs locales)
services:
  api:
    ports:
      - "8000:8000"
      - "9229:9229"        # Puerto de debugging de Node.js
    volumes:
      - .:/app             # Bind mount para hot-reload
    environment:
      - APP_ENV=development   # Sobreescribe el valor del base

  # Servicio adicional solo para desarrollo
  pgadmin:
    image: dpage/pgadmin4
    ports:
      - "5050:80"
    environment:
      PGADMIN_DEFAULT_EMAIL: dev@local.com
      PGADMIN_DEFAULT_PASSWORD: dev

Estrategia con múltiples archivos explícitos

# compose.prod.yaml — Producción
services:
  api:
    restart: always
    deploy:
      resources:
        limits:
          cpus: '1.0'
          memory: 512M
    logging:
      driver: "json-file"
      options:
        max-size: "10m"
        max-file: "3"
# Desarrollo (usa override automático)
docker compose up -d

# Producción (combina base + prod explícitamente)
docker compose -f compose.yaml -f compose.prod.yaml up -d

Archivos .env para variables de entorno

Para evitar hardcodear valores en los archivos YAML (especialmente secretos), uso archivos .env. Compose los carga automáticamente si existen en el mismo directorio.

# .env (NO versionar si tiene contraseñas reales — agregarlo al .gitignore)
TAG=15-alpine
POSTGRES_USER=miusuario
POSTGRES_PASSWORD=supersecreto
POSTGRES_DB=miapp
# compose.yaml usando las variables del .env
services:
  db:
    image: postgres:${TAG}
    environment:
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_DB: ${POSTGRES_DB}

Cada desarrollador tiene su propio .env local con sus credenciales. El archivo compose.yaml se versiona sin datos sensibles. Para producción, las variables se inyectan desde el CI/CD o desde un gestor de secretos.

Builds multi-stage para producción

Las imágenes de desarrollo son gordas (incluyen herramientas de build, dependencias de dev, etc.). Las de producción deben ser mínimas. Los builds multi-stage resuelven esto con un solo Dockerfile:

# Dockerfile
# ---- Etapa de Build ----
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json .
RUN npm install
COPY . .
RUN npm run build

# ---- Etapa de Producción ----
FROM node:20-slim AS production
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
EXPOSE 3000
CMD ["node", "dist/index.js"]
# compose.yaml
services:
  api:
    build:
      context: .
      target: production   # Solo construye hasta la etapa 'production'

La imagen de producción solo contiene lo necesario para correr — sin el compilador, sin herramientas de build, sin dependencias de desarrollo. Más pequeña, más segura, más rápida de desplegar.

El checklist final para ir a producción con Compose

  • restart: unless-stopped o always en todos los servicios críticos
  • ✅ Healthchecks definidos en las bases de datos y servicios de los que dependen otros
  • depends_on con condition: service_healthy donde corresponde
  • ✅ Volúmenes nombrados para cualquier dato que deba persistir
  • ✅ Secretos y contraseñas en .env o variables de CI/CD, nunca hardcodeados
  • ✅ Imágenes multi-stage para producción
  • deploy.resources.limits configurados para evitar que un servicio consuma todo el host
  • ✅ Logging configurado (json-file con max-size para evitar llenar el disco)

¿Y después de Compose?

Docker Compose es ideal para un único host. Cuando necesitás escalar a múltiples máquinas, alta disponibilidad real o gestión de carga compleja, los próximos pasos naturales son:

  • Docker Swarm: orquestación multi-host con conceptos muy similares a Compose. Curva de aprendizaje baja si ya dominás Compose.
  • Kubernetes: el estándar de la industria para producción a gran escala. La herramienta kompose convert traduce tus archivos Compose a manifiestos de Kubernetes para facilitar la migración.

Lo bueno: los conceptos que aprendiste en esta serie (servicios, redes, volúmenes, variables de entorno, healthchecks) se trasladan directamente a esos entornos.

Parte 2: CLI esencial, depends_on, healthchecks y perfiles

Comentarios

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Este sitio usa Akismet para reducir el spam. Aprende cómo se procesan los datos de tus comentarios.