
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ítica | Comportamiento |
|---|---|
"no" | Nunca reinicia automáticamente (valor por defecto) |
always | Reinicia siempre, incluso tras docker stop |
on-failure | Reinicia solo si el proceso sale con código de error |
unless-stopped | Como 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.