ToolsOps

Docker Compose para Node.js + PostgreSQL

Una app Node.js y su base de datos PostgreSQL en el mismo Docker Compose, con depends_on y condition: service_healthy para evitar el ECONNREFUSED al arrancar, migraciones y desarrollo frente a producción.

Cuando tu app y su base de datos viven en el mismo compose.yaml, aparece un problema que no existía cuando Postgres estaba aparte: el orden de arranque. Esta guía monta una app Node.js junto a PostgreSQL y se centra en que arranquen en el orden correcto, sin el clásico ECONNREFUSED.

compose.yaml de app + base de datos

La app se construye desde tu Dockerfile (build: .) y depende de Postgres con la forma larga de depends_on.

services:
  app:
    build: .
    restart: unless-stopped
    environment:
      DATABASE_URL: ${DATABASE_URL}
    ports:
      - "127.0.0.1:3000:3000"
    depends_on:
      postgres:
        condition: service_healthy
  postgres:
    image: postgres:16-alpine
    restart: unless-stopped
    environment:
      POSTGRES_DB: ${POSTGRES_DB}
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 20s

volumes:
  postgres_data:

El .env.example:

DATABASE_URL=postgresql://app:CHANGE_ME@postgres:5432/app
POSTGRES_DB=app
POSTGRES_USER=app
POSTGRES_PASSWORD=CHANGE_ME

Por qué tu app fallaba con ECONNREFUSED

Es el error que casi todo el mundo se encuentra la primera vez. La app arranca en milisegundos; Postgres tarda unos segundos en estar listo para aceptar conexiones. Si tu app intenta conectar de inmediato, recibe ECONNREFUSED y, a menudo, se cae.

La causa es un malentendido habitual: depends_on en su forma corta solo ordena el arranque, no espera a que el servicio esté listo. La forma larga con condition: service_healthy sí espera, pero necesita que Postgres tenga un healthcheck (aquí, pg_isready). Con esa combinación, Compose no arranca app hasta que la base de datos responde.

Aun así, conviene que la app reintente la conexión unas pocas veces al inicio: es una red de seguridad barata frente a cortes puntuales y frente a entornos donde no controlas el healthcheck.

El nombre del host es el del servicio

Fíjate en la URL: postgresql://app:CHANGE_ME@postgres:5432/app. El host es postgres, el nombre del servicio, no localhost. Dentro del contenedor de la app, localhost es la propia app. Los servicios de un mismo compose se encuentran por nombre en la red interna, así que no hace falta publicar el puerto de Postgres para que la app lo use.

Migraciones

depends_on garantiza que Postgres acepta conexiones, no que tu esquema exista. Trata las migraciones como un paso explícito: ejecútalas al arrancar la app (con reintentos) o como un comando aparte antes de servir tráfico, por ejemplo docker compose run --rm app npm run migrate. Así separas "la base de datos está viva" de "la base de datos tiene mi esquema".

Desarrollo frente a producción

Para desarrollo con recarga en caliente puedes montar el código como volumen, pero evita montar el node_modules del host encima del de la imagen. En producción separarías el paso de migraciones, no publicarías el puerto de Postgres, tratarías DATABASE_URL como secreto y fijarías límites de recursos. Este compose es el punto de partida sobre el que construir, no el despliegue final.

Siguientes pasos

Genera este stack en el generador de Docker Compose y, para entender a fondo la espera entre servicios, lee la guía de healthchecks y depends_on.

Preguntas frecuentes

Mi app falla con ECONNREFUSED al arrancar, ¿por qué?
Porque tu app intentó conectar a Postgres antes de que Postgres aceptara conexiones. depends_on en su forma corta (una lista) solo espera a que el contenedor de la base de datos arranque, no a que esté listo. La solución es la forma larga con condition: service_healthy más un healthcheck en Postgres: así Compose no arranca tu app hasta que pg_isready da el visto bueno.
¿Por qué DATABASE_URL apunta a 'postgres' y no a 'localhost'?
Dentro de Compose cada servicio resuelve a los demás por su nombre en la red interna. Tu app y Postgres no comparten localhost: localhost dentro del contenedor de la app es la propia app. Por eso la URL es postgresql://app:...@postgres:5432/app, donde postgres es el nombre del servicio. localhost solo funcionaría desde tu máquina, a través del puerto publicado.
Aun con healthcheck, a veces la primera consulta falla. ¿Es normal?
Puede pasar: service_healthy garantiza que Postgres acepta conexiones, pero tu esquema o tus migraciones quizá no se hayan ejecutado todavía. depends_on resuelve el orden de arranque, no el estado de tu esquema. Lo robusto es que la app reintente la conexión unas cuantas veces al inicio y que las migraciones se ejecuten como un paso explícito antes de servir tráfico.
¿Hago npm install dentro o fuera del contenedor?
Para que la imagen sea reproducible, el npm install (o npm ci) va en el Dockerfile, durante el build, copiando primero package.json y package-lock.json para aprovechar la caché de capas. En desarrollo con recarga en caliente mucha gente monta el código como volumen, pero entonces conviene NO montar node_modules del host encima del del contenedor (sus binarios pueden no coincidir): se suele excluir node_modules del montaje.
¿Esto vale para producción?
Es un esqueleto de desarrollo. En producción separarías el paso de migraciones, no publicarías el puerto de Postgres, gestionarías DATABASE_URL como secreto fuera de git, fijarías límites de recursos y casi siempre usarías una base de datos gestionada o un orquestador. Aquí el objetivo es que app y base de datos arranquen juntas y en el orden correcto.