Francisco Javier Palacios PérezFco. Javier Palacios Pérez
Desarrollador de software
Estructura del docker-compose.yml: health checks, variables de entorno y restart

Estructura del docker-compose.yml: health checks, variables de entorno y restart

Estructura del docker-compose.yml: health checks, variables de entorno y restart

Estructura del docker-compose.yml: health checks, variables de entorno y restart

En la lección anterior, depends_on: db prometía implícitamente que la API esperaría a que Postgres estuviese listo. Lo que realmente hace es esperar a que el contenedor de Postgres esté corriendo. Son dos cosas distintas: una es que el camión de IKEA haya llegado a tu portal, la otra es que el mueble esté montado.

En tu portátil, Postgres inicializa en menos de un segundo y casi nunca hay problema. En un servidor de CI con disco más lento, tu API intenta conectarse a la base de datos mientras Postgres todavía está escribiendo su directorio de datos. El resultado: fallo de conexión, contenedor reiniciado, error intermitente que no puedes reproducir en local. El peor tipo de bug.

El tutorial anterior lo dejó ahí conscientemente. La solución — health checks con condition: service_healthy — es el núcleo de esta lección, junto con las variables de entorno externalizadas y las políticas de restart. Con estos tres elementos, el docker-compose.yml pasa de ser una conveniencia de desarrollo a algo que funciona de verdad en un servidor real.

Todos los campos de un servicio

Antes de entrar en health checks, conviene tener una referencia de qué puede definir un servicio en el docker-compose.yml. La lección anterior usó image, ports, environment, depends_on y volumes. Hay bastantes más:

services:
  api:
    # Image or build (one of the two)
    image: myapp/api:latest
    build:
      context: ./api # Directory with the Dockerfile
      dockerfile: Dockerfile # Name of the Dockerfile (default: Dockerfile)
      args:
        NODE_ENV: production # Build arguments

    # Optional: custom name (auto-generated by default)
    container_name: myapp-api

    # Port mapping
    ports:
      - "3000:3000"

    # Environment variables
    environment:
      NODE_ENV: production
      DATABASE_URL: postgresql://db:5432/myapp

    # Variables from a file (all at once)
    env_file:
      - .env

    # Dependencies
    depends_on:
      - db

    # Volumes (bind mounts and named volumes)
    volumes:
      - ./api/src:/app/src # Bind mount for development (hot reload)
      - api-data:/app/data # Named volume

    # Networks this service connects to
    networks:
      - backend
      - frontend

    # Health check
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
      interval: 30s
      timeout: 3s
      retries: 3
      start_period: 40s

    # Restart policy
    restart: unless-stopped

No todos los campos son necesarios al mismo tiempo — la mayoría de servicios usan cinco o seis. Esta es la referencia completa; los campos más relevantes los veremos en detalle a lo largo de esta lección y las siguientes.

Una aclaración sobre build: puedes usar la forma corta (build: ./api) o la larga con context, dockerfile y args. La forma corta asume que el Dockerfile está en ese directorio y se llama Dockerfile. La forma larga es útil cuando necesitas pasar argumentos de build o usar un Dockerfile con nombre distinto.

Health checks: esperar a que el servicio esté listo

Un health check es una instrucción que dice a Docker cómo verificar que un servicio no solo está arrancado, sino operativo. Docker ejecuta ese comando periódicamente y marca el servicio como healthy cuando devuelve 0 en varias ejecuciones consecutivas.

Para Postgres, el health check estándar usa pg_isready, una utilidad que viene con la imagen y devuelve 0 si la base de datos acepta conexiones:

services:
  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: myapp
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: myapp
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U myapp -d myapp"]
      interval: 5s # How often Docker runs the check
      timeout: 3s # Max time allowed per check
      retries: 5 # Fail after this many consecutive failures
      start_period: 10s # Grace period before failures start counting

start_period merece una explicación: durante ese tiempo, si el health check falla, Docker no cuenta el fallo contra el límite de retries. Útil para servicios que tardan un poco en arrancar — sin start_period, Postgres podría marcarse como unhealthy durante la inicialización inicial antes de tener la oportunidad de estar listo.

Ahora, para que depends_on espere de verdad:

services:
  api:
    build: ./api
    depends_on:
      db:
        condition: service_healthy # Wait until db passes its health check

La sintaxis cambia de lista a mapa cuando añades una condición. condition: service_healthy garantiza que la API no arranca hasta que el health check de Postgres devuelva healthy. Esto es lo que depends_on: [db] sonaba como que hacía.

Para Redis, el health check equivalente es redis-cli ping:

redis:
  image: redis:7-alpine
  healthcheck:
    test: ["CMD", "redis-cli", "ping"]
    interval: 5s
    timeout: 3s
    retries: 3

Y para una API HTTP propia, comprobando un endpoint de salud:

api:
  build: ./api
  healthcheck:
    test: ["CMD-SHELL", "curl -sf http://localhost:3000/health || exit 1"]
    interval: 10s
    timeout: 5s
    retries: 3
    start_period: 15s

El || exit 1 garantiza que si curl falla por cualquier motivo — conexión rechazada, timeout, código 500 — el health check falla también. Sin él, el shell devuelve 0 aunque curl haya encontrado un error, y Docker marca el servicio como healthy cuando no lo está.

Variables de entorno: el fichero .env

En desarrollo, tres variables en el docker-compose.yml son manejables. En staging son doce. En producción son cuarenta y siete, alguien hardcodeó una contraseña en algún sitio, y hay un secreto de JWT que lleva tres años sin rotarse porque nadie recuerda quién lo generó.

La solución es sacar las variables fuera del fichero YAML.

Interpolación desde .env

Compose busca automáticamente un fichero .env en el directorio donde ejecutas los comandos. Las variables que define están disponibles para interpolación en el docker-compose.yml:

# .env
POSTGRES_USER=myapp
POSTGRES_PASSWORD=secret
POSTGRES_DB=myapp
API_PORT=3000
# docker-compose.yml
services:
  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_DB: ${POSTGRES_DB}

  api:
    build: ./api
    ports:
      - "${API_PORT}:3000"

La sintaxis ${VARIABLE} toma el valor del .env o del entorno del shell. Si la variable no existe, Compose la deja vacía sin avisar. Para hacer que falle explícitamente cuando falta una variable crítica:

POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Error: POSTGRES_PASSWORD no está definida}

El .env va en .gitignore. Siempre. Lo que sí se versiona es un .env.example con los nombres de las variables y valores de ejemplo, para que cualquiera que clone el repositorio sepa qué necesita configurar.

env_file: pasar un fichero directamente al contenedor

env_file es una alternativa que pasa todas las variables de un fichero directamente al contenedor, sin declararlas una por una en environment:

services:
  api:
    build: ./api
    env_file:
      - .env
      - .env.local # Local overrides, not versioned

La diferencia con la interpolación en el YAML: env_file pasa las variables al contenedor directamente — no están disponibles para el resto del docker-compose.yml. La interpolación ${VARIABLE} en el YAML funciona a nivel de Compose y puede usarse en cualquier campo (ports, image, healthcheck). Si necesitas la variable solo dentro del contenedor, env_file es más limpio. Si la necesitas también en otros campos del YAML, usa la interpolación.

Políticas de restart

Sin política de restart configurada, si un contenedor falla, se queda caído. En desarrollo lo notas enseguida. En un servidor que nadie monitorea activamente puede estar caído horas.

Las cuatro opciones disponibles:

PolíticaComportamiento
"no"Nunca reinicia automáticamente (valor por defecto)
alwaysReinicia siempre, incluso tras docker stop
on-failureReinicia solo si el proceso sale con código de error
unless-stoppedComo always, pero respeta docker compose stop

Para la mayoría de los casos, unless-stopped es la opción razonable: el servicio se recupera solo si el proceso falla, pero puedes pararlo manualmente con docker compose stop sin que vuelva a arrancar por su cuenta.

always tiene un comportamiento que sorprende la primera vez: si el contenedor estaba corriendo cuando reiniciaste el servidor, Docker lo arrancará automáticamente al arrancar el demonio de Docker. En producción puede ser exactamente lo que quieres. En un portátil con diez proyectos distintos, te encontrarás con que Docker arranca media docena de stacks de desarrollo que no recordabas tener activos.

Un stack completo

Todo junto. Node.js API + Postgres + Redis, con health checks, variables desde .env y política de restart.

El fichero .env (este va en .gitignore):

POSTGRES_USER=myapp
POSTGRES_PASSWORD=changeme
POSTGRES_DB=myapp
REDIS_PASSWORD=redispass
API_PORT=3000

El .env.example (este sí se versiona):

POSTGRES_USER=
POSTGRES_PASSWORD=
POSTGRES_DB=
REDIS_PASSWORD=
API_PORT=3000

El docker-compose.yml:

services:
  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_DB: ${POSTGRES_DB}
    volumes:
      - db-data:/var/lib/postgresql/data
    networks:
      - backend
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
      interval: 5s
      timeout: 3s
      retries: 5
      start_period: 10s
    restart: unless-stopped

  redis:
    image: redis:7-alpine
    command: redis-server --requirepass ${REDIS_PASSWORD} --loglevel warning
    networks:
      - backend
    healthcheck:
      test: ["CMD-SHELL", "redis-cli -a ${REDIS_PASSWORD} --no-auth-warning ping"]
      interval: 5s
      timeout: 3s
      retries: 3
    restart: unless-stopped

  api:
    build: ./api
    ports:
      - "${API_PORT}:3000"
    environment:
      DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
      REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_healthy
    networks:
      - backend
    restart: unless-stopped

volumes:
  db-data:

networks:
  backend:

El campo networks limita qué servicios pueden verse entre sí. En este caso los tres comparten la red backend, así que la API puede resolver db y redis por nombre. Si añadieras un frontend, lo pondrías en una red frontend separada que solo conectaría con la API — no con la base de datos directamente. Eso es networking en Compose, que tiene su propia lección más adelante.

Con este fichero, docker compose up -d arranca Postgres y Redis, espera a que ambos pasen sus health checks, y solo entonces arranca la API. Si el proceso de la API falla, unless-stopped lo reinicia. Las credenciales están en .env, fuera del repositorio.

Es más largo que el ejemplo de la lección anterior. También es el que vas a usar en un entorno real.


Con health checks, variables externalizadas y restart policies, el stack de Docker Compose deja de ser una conveniencia de desarrollo y empieza a comportarse como infraestructura de verdad. El siguiente tutorial profundiza en los volúmenes: cómo funciona la persistencia de datos entre reinicios de contenedor, la diferencia entre bind mounts y volúmenes con nombre, y cómo manejar backups desde el propio Compose.

¡Nunca dejes de programar!


💡 Reto: Coge el docker-compose.yml del reto anterior y añade un health check a Postgres con pg_isready. Configura condition: service_healthy en el depends_on de los servicios que dependan de él. Arranca la stack con docker compose up -d y observa con docker compose ps cómo el estado del contenedor de Postgres pasa por starting hasta llegar a healthy antes de que arranquen los demás servicios.