Tu aplicación necesita Node.js, una base de datos y Redis. Instalar cada dependencia manualmente funciona, hasta que otra persona usa una versión distinta, ocupa el mismo puerto o no sabe cómo preparar las tablas. El problema ya no está en el código de la aplicación: está en reproducir el entorno que necesita.
Docker Compose permite describir esos servicios y sus conexiones en un archivo. Una vez preparados el proyecto y su configuración local, un comando puede construir la aplicación, crear sus redes y volúmenes y esperar a que los servicios estén disponibles.
Vamos a montar una API pequeña con PostgreSQL y Redis. Además de levantarla, comprobaremos qué sucede con sus datos al detener y eliminar contenedores. Esa segunda parte es tan importante como conseguir que respondan las primeras peticiones.
Dockerfile, imagen, contenedor y Compose no son lo mismo
| Concepto | Qué representa | Ejemplo en este laboratorio |
|---|---|---|
| Dockerfile | Receta para construir una imagen | Instalar dependencias y copiar la API |
| Imagen | Plantilla con archivos y configuración para crear contenedores | Nuestra API o postgres:17-alpine |
| Contenedor | Instancia de ejecución de una imagen, con su propio estado | El proceso PostgreSQL del laboratorio |
| Docker Compose | Herramienta para definir y gestionar una aplicación de varios servicios | API, PostgreSQL, Redis, redes y volúmenes |
Un servicio es la definición de una parte de la aplicación; Compose crea contenedores a partir de ella. No necesitas escribir un Dockerfile para cada servicio: puedes utilizar una imagen existente para PostgreSQL y construir únicamente la API. La descripción oficial del modelo de Compose explica cómo encajan esos elementos.
Compose facilita compartir la configuración, pero no vuelve idénticas todas las máquinas: siguen influyendo la arquitectura, los recursos disponibles y las versiones de las imágenes. Una etiqueta como node:24-slim puede recibir actualizaciones; para fijar exactamente su contenido se utiliza un digest. En este laboratorio fijamos las dependencias de Node.js con versiones y lockfile, y las imágenes por versión mayor.
Qué vamos a ejecutar
La API tendrá dos operaciones independientes: guardar notas ficticias en PostgreSQL e incrementar un contador en Redis. No sincronizaremos ambos almacenes ni simularemos una transacción distribuida.
No se pudo mostrar el diagrama. Puedes consultar su definición.
flowchart TD
equipo["curl en tu equipo"] -->|"127.0.0.1:3007"| api["API Node.js · puerto 3000"]
subgraph datos["Red datos · interna"]
pg["PostgreSQL"] --> vp["Datos SQL"]
redis["Redis"] --> vr["Datos Redis"]
end
api -->|"postgres:5432"| pg
api -->|"redis:6379"| redis
La API pertenece a dos redes: entrada, para su acceso publicado, y datos, compartida con las dependencias. PostgreSQL y Redis solo pertenecen a datos y no publican puertos en el equipo. El volumen de cada servicio conserva sus archivos aunque reemplacemos su contenedor.
Requisitos
Necesitas Docker Engine en ejecución, Docker Compose moderno, Node.js 24 y pnpm. Docker Desktop incluye Compose; en Linux también puedes instalar el plugin sobre Docker Engine siguiendo la guía oficial de instalación. Aquí utilizaremos docker compose, con espacio, no el ejecutable histórico docker-compose.
Comprueba las herramientas:
docker version
docker compose version
node --version
pnpm --versionEl proyecto fija pnpm 11.14.0. Si necesitas preparar el gestor, consulta la guía de pnpm, dependencias y workspaces. Node.js y pnpm locales se utilizarán para generar el lockfile; la API se ejecutará dentro del contenedor.
Crea una carpeta nueva y ejecuta desde ella los siguientes comandos:
mkdir compose-lab
cd compose-lab
mkdir srcPreparar una API pequeña con pnpm
Crea package.json:
{
"name": "compose-lab",
"private": true,
"type": "module",
"packageManager": "pnpm@11.14.0",
"scripts": {
"start": "node src/server.mjs"
},
"dependencies": {
"pg": "8.23.1",
"redis": "6.3.1"
}
}Instala las dependencias:
pnpm installEste paso genera pnpm-lock.yaml. Guárdalo en Git junto con package.json: la construcción utilizará --frozen-lockfile para rechazar discrepancias, en lugar de resolver otras dependencias silenciosamente.
Crea src/server.mjs:
import { createServer } from 'node:http';
import pg from 'pg';
import { createClient } from 'redis';
const pool = new pg.Pool({
connectionTimeoutMillis: 2000,
query_timeout: 2000,
max: 5,
});
const redis = createClient({
url: process.env.REDIS_URL,
disableOfflineQueue: true,
socket: { connectTimeout: 2000 },
});
pool.on('error', () => console.error('Conexión PostgreSQL interrumpida'));
redis.on('error', () => console.error('Conexión Redis interrumpida'));
await redis.connect();
function json(response, status, body) {
response.writeHead(status, { 'Content-Type': 'application/json' });
response.end(JSON.stringify(body));
}
const server = createServer(async (request, response) => {
const route = `${request.method} ${request.url}`;
try {
if (route === 'GET /health') {
await pool.query('SELECT 1');
await redis.ping();
return json(response, 200, { status: 'ok' });
}
if (route === 'POST /notas') {
const result = await pool.query(
'INSERT INTO notas (titulo) VALUES ($1) RETURNING id, titulo',
['Nota de prueba'],
);
return json(response, 201, result.rows[0]);
}
if (route === 'GET /notas') {
const result = await pool.query(
'SELECT id, titulo FROM notas ORDER BY id LIMIT 20',
);
return json(response, 200, result.rows);
}
if (route === 'POST /contador') {
const value = await redis.incr('lab:contador');
return json(response, 200, { contador: value });
}
if (route === 'GET /contador') {
const value = await redis.get('lab:contador');
return json(response, 200, { contador: Number(value ?? 0) });
}
json(response, 404, { error: 'Ruta no encontrada' });
} catch {
console.error('No se pudo completar la operación');
json(response, 503, { error: 'Dependencia no disponible' });
}
});
server.listen(3000, '0.0.0.0', () => {
console.log('API lista en el puerto interno 3000');
});
process.once('SIGTERM', () => {
server.close(async () => {
await Promise.allSettled([pool.end(), redis.close()]);
});
});pg toma la conexión de las variables PGHOST, PGUSER y las demás que configuraremos en Compose. El cliente oficial node-postgres documenta ese mecanismo. Para Redis usamos createClient, conexión explícita y un listener de errores.
La API escucha en 0.0.0.0 dentro del contenedor, para que Docker pueda dirigirle el tráfico. Esto no obliga a publicarla en todas las interfaces del equipo: esa decisión la tomaremos en ports.
El ejemplo inserta un título fijo para concentrarnos en Compose. No acepta datos personales ni implementa autenticación, y no debe publicarse como API de producción. Los errores devuelven un mensaje genérico; no imprimimos contraseñas ni cadenas de conexión. Las conexiones pueden fallar después del arranque, por eso también manejamos errores durante cada petición.
Construir la imagen de la aplicación
Crea Dockerfile:
FROM node:24-slim
RUN npm install --global pnpm@11.14.0
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile --prod
COPY --chown=node:node src ./src
USER node
EXPOSE 3000
CMD ["node", "src/server.mjs"]Utilizamos npm solamente para instalar una versión concreta de pnpm dentro de la imagen; pnpm gestiona las dependencias del proyecto. Copiar primero el manifiesto y el lockfile permite reutilizar esa capa cuando solo cambia el código. El proceso final corre como node, no como root.
EXPOSE 3000 describe el puerto de la aplicación: no crea una publicación en el equipo anfitrión. La referencia de Dockerfile distingue ambas operaciones.
Crea .dockerignore para excluir archivos innecesarios del contexto de construcción:
node_modules
.git
.envY .gitignore para evitar versionar dependencias instaladas y configuración local:
node_modules/
.env.dockerignore y .gitignore cumplen funciones distintas. Ninguno borra archivos que ya se hayan incluido en una imagen o en el historial de Git.
Configuración local y preparación de PostgreSQL
Crea .env.example:
API_PORT=3007
POSTGRES_PASSWORD=solo-lab-admin
APP_DB_PASSWORD=solo-lab-appSon credenciales ficticias y públicas, exclusivamente para este laboratorio. Prepara la copia local:
cp .env.example .envCompose utiliza .env para sustituir expresiones como ${API_PORT:-3007}. No introduce automáticamente todas sus variables en los contenedores: lo haremos explícitamente con environment. La documentación de interpolación explica los valores predeterminados y la precedencia; una variable exportada en tu terminal puede prevalecer sobre el archivo.
Tampoco conviertas .env en una medida de protección por sí sola. Quien pueda inspeccionar el entorno del contenedor puede acceder a esos valores. Para ampliar esa diferencia, consulta variables de entorno frente a gestores de secretos.
Crea init.sql:
CREATE ROLE lab_app LOGIN PASSWORD 'solo-lab-app';
CREATE TABLE notas (
id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
titulo text NOT NULL
);
GRANT CONNECT ON DATABASE laboratorio TO lab_app;
GRANT USAGE ON SCHEMA public TO lab_app;
GRANT SELECT, INSERT ON notas TO lab_app;
GRANT USAGE ON SEQUENCE notas_id_seq TO lab_app;lab_admin inicializará la base; la aplicación utilizará lab_app, con permisos para leer e insertar notas, sin privilegios administrativos. La contraseña literal coincide con APP_DB_PASSWORD porque este archivo contiene únicamente datos ficticios. Un archivo .sql no sustituye automáticamente variables de .env.
Los scripts de inicialización se ejecutan cuando el directorio de datos está vacío. Cambiar init.sql o la contraseña de .env después no modifica una base ya inicializada. En un sistema real usarías migraciones y un procedimiento de rotación, no borrarías el volumen para aplicar cambios. Este comportamiento está documentado en la imagen oficial de PostgreSQL.
El archivo compose.yaml completo
Crea compose.yaml junto al Dockerfile:
name: compose-lab
services:
api:
build: .
init: true
ports:
- "127.0.0.1:${API_PORT:-3007}:3000"
environment:
PGHOST: postgres
PGPORT: "5432"
PGDATABASE: laboratorio
PGUSER: lab_app
PGPASSWORD: ${APP_DB_PASSWORD:?Define APP_DB_PASSWORD en .env}
REDIS_URL: redis://redis:6379
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
healthcheck:
test:
- CMD
- node
- -e
- >-
fetch('http://127.0.0.1:3000/health', {
signal: AbortSignal.timeout(2000)
})
.then(r => process.exit(r.ok ? 0 : 1))
.catch(() => process.exit(1))
interval: 5s
timeout: 3s
retries: 10
start_period: 10s
networks:
- entrada
- datos
postgres:
image: postgres:17-alpine
environment:
POSTGRES_DB: laboratorio
POSTGRES_USER: lab_admin
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Define POSTGRES_PASSWORD en .env}
volumes:
- postgres_data:/var/lib/postgresql/data
- ./init.sql:/docker-entrypoint-initdb.d/01-init.sql:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 5s
timeout: 3s
retries: 10
start_period: 10s
networks:
- datos
redis:
image: redis:8-alpine
command: ["redis-server", "--appendonly", "yes", "--appendfsync", "everysec"]
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 10
networks:
- datos
volumes:
postgres_data:
redis_data:
networks:
entrada:
datos:
internal: trueEl nivel superior contiene services, volumes y networks. Dentro de cada servicio definimos su imagen o construcción, configuración y conexiones. build: . usa el Dockerfile y el contexto de la carpeta actual; image obtiene una imagen existente.
No agregamos version: "3.8": el campo version es obsoleto en la especificación actual. name: compose-lab identifica el proyecto y agrupa sus recursos. No ejecutes dos copias simultáneas con ese mismo nombre; para otra instancia utiliza un nombre y un puerto diferentes.
Publicar un puerto no es lo mismo que exponerlo
La entrada 127.0.0.1:3007:3000 tiene tres partes:
127.0.0.1: interfaz del equipo donde se publica.3007: puerto al que llamarácurldesde tu equipo.3000: puerto donde escucha Node.js dentro del contenedor.
Con 3007:3000, sin dirección, la publicación puede quedar disponible en todas las interfaces. Para este laboratorio usamos loopback y un Docker actualizado. Consulta los detalles de publicación de puertos, incluida la advertencia sobre versiones antiguas.
expose en Compose declara puertos internos sin publicarlos en el host. No es una lista de control de acceso: los servicios que comparten red pueden comunicarse con los puertos donde los procesos escuchan aunque no incluyamos expose. Por eso no lo necesitamos aquí. La referencia de servicios describe esta diferencia.
Si docker compose ps muestra 5432/tcp, eso no significa que PostgreSQL esté publicado. Una asignación como 127.0.0.1:3007->3000/tcp sí muestra un puerto del host enlazado al contenedor.
Redes: localhost no apunta al contenedor vecino
Para la API, localhost se refiere a su propio contenedor. PostgreSQL está en postgres:5432, y Redis en redis:6379. Compose proporciona resolución por nombre de servicio; no fijes sus direcciones IP, porque pueden cambiar al recrear contenedores.
Compose podría crear una única red por defecto. Aquí declaramos dos para mostrar el aislamiento: datos tiene internal: true, mientras que entrada conserva conectividad externa para la API. Una red interna no reemplaza la autenticación ni protege frente a quien controla Docker en el host. La referencia de redes explica la red predeterminada y las redes internas.
Healthchecks: iniciado no significa preparado
Sin una condición de salud, depends_on establece un orden de inicio, pero no demuestra que una base acepte consultas. Con condition: service_healthy, Compose espera el healthcheck de la dependencia antes de iniciar la API. Es el patrón documentado en la guía de orden de arranque.
En este laboratorio:
- PostgreSQL usa
pg_isreadypara comprobar disponibilidad del servidor. No prueba los permisos delab_app. - Redis debe responder a
PING. - La API consulta PostgreSQL y Redis desde
/health, con su propia configuración.
interval define la frecuencia, timeout limita cada comprobación y retries controla cuántos fallos consecutivos llevan al estado unhealthy. start_period concede un margen inicial para los fallos de arranque; no es una pausa fija antes de comprobar.
En el comando de PostgreSQL, $$ evita que Compose sustituya la variable y permite que la shell del contenedor expanda $POSTGRES_USER y $POSTGRES_DB.
Un healthcheck no repara ni reinicia automáticamente un servicio. Tampoco depends_on garantiza que una dependencia siga funcionando después. La recuperación ante caídas necesita manejo de errores y una política explícita; el laboratorio no configura reinicios automáticos.
Levantar y comprobar los tres servicios
Primero valida la configuración sin imprimir sus valores:
docker compose config --quietdocker compose config sin --quiet es útil para inspeccionar el resultado, pero puede mostrar credenciales interpoladas. No pegues esa salida sin revisarla.
Ahora levanta el entorno:
docker compose up -d --build --wait--build construye la imagen de la API; -d deja los servicios en segundo plano; --wait espera a que estén ejecutándose o saludables, según tengan healthcheck. Aquí los tres lo tienen. La referencia de up describe estas opciones y la recreación de contenedores cuando cambia su configuración.
Comprueba el estado y crea los datos:
docker compose ps
curl --fail-with-body http://127.0.0.1:3007/health
curl --fail-with-body -X POST http://127.0.0.1:3007/notas
curl --fail-with-body -X POST http://127.0.0.1:3007/contador
curl --fail-with-body http://127.0.0.1:3007/notas
curl --fail-with-body http://127.0.0.1:3007/contadorEn la ejecución del laboratorio, los tres servicios quedaron healthy. Sobre volúmenes nuevos, las respuestas fueron, en ese mismo orden:
{"status":"ok"}
{"id":1,"titulo":"Nota de prueba"}
{"contador":1}
[{"id":1,"titulo":"Nota de prueba"}]
{"contador":1}Son cinco respuestas independientes, no un único documento JSON. Si repites los POST, crearás más notas e incrementarás el contador; sus valores ya no serán los iniciales.
Para inspeccionar la base sin publicar su puerto:
docker compose exec postgres psql -U lab_admin -d laboratorioDentro de psql, ejecuta SELECT * FROM notas; y sal con \q. Este acceso administrativo sirve para inspeccionar el laboratorio; no es la cuenta que utiliza la API.
Detener contenedores sin perder los datos
Un contenedor tiene una capa escribible que desaparece al eliminarlo. Los volúmenes administrados por Docker tienen un ciclo de vida independiente.
En PostgreSQL 17 montamos postgres_data en /var/lib/postgresql/data. Esa ruta corresponde a la versión utilizada: no cambies a PostgreSQL 18 o superior sin revisar las rutas y el procedimiento de actualización de su imagen.
Redis escribe en /data, respaldado por redis_data. Además activamos AOF, un registro de operaciones que permite reconstruir el estado. Tener un volumen no basta si el servicio nunca escribe allí sus datos. Con appendfsync everysec puede perderse aproximadamente el último segundo ante un fallo grave; no es una garantía de pérdida cero. Véase la documentación de persistencia de Redis.
Para detener sin eliminar los contenedores:
docker compose stop
docker compose up -d --waitPara eliminar los contenedores y las redes del proyecto, conservando sus volúmenes con nombre:
docker compose down
docker compose up -d --wait
curl --fail-with-body http://127.0.0.1:3007/notas
curl --fail-with-body http://127.0.0.1:3007/contadorLa prueba conservó la nota con id: 1 y el contador en 1 después de ambas secuencias. Se recrearon los contenedores, pero se reutilizaron los archivos persistentes.
Un volumen no es una copia de seguridad: un borrado, corrupción o pérdida del equipo también puede afectar esos datos. Además, si cambias el nombre del proyecto, Compose puede crear otros volúmenes; ver una base vacía no implica necesariamente que se borrara la anterior.
Limpiar el laboratorio deliberadamente
Cuando ya no necesites sus datos ficticios:
docker compose down -vEste comando elimina también los volúmenes declarados en este proyecto y sus datos. No lo utilices como arreglo automático para errores de conexión. La referencia de down distingue la eliminación predeterminada de la opción --volumes.
Al volver a levantar este laboratorio después de down -v, se inicializan volúmenes nuevos: la lista de notas queda vacía y el contador vuelve a cero. También se comprobó ese comportamiento durante la prueba.
Logs, cambios de código y reconstrucción
Para ver las últimas líneas de todos los servicios:
docker compose logs --tail 30Para seguir únicamente la aplicación:
docker compose logs -f apiAquí Ctrl+C deja de seguir los logs; no detiene los contenedores iniciados con -d.
El Dockerfile copia el código dentro de la imagen. Editar src/server.mjs en tu equipo no modifica una imagen ya construida. Tras un cambio, ejecuta:
docker compose up -d --build --waitLa verificación del laboratorio también conservó los datos después de reconstruir. docker compose restart solo reinicia contenedores existentes: no reconstruye la imagen ni incorpora por sí solo nuevos valores de configuración. En desarrollo puedes añadir recarga con bind mounts o Compose Watch; no hacen falta para entender este primer flujo.
Errores habituales y cómo investigarlos
| Síntoma | Qué revisar primero |
|---|---|
Puerto ocupado, address already in use o port is already allocated | Otro proceso usa el puerto del host; cambia API_PORT y vuelve a ejecutar up |
La API intenta conectar a localhost:5432 | Desde su contenedor debe usar el nombre postgres |
up --wait falla y un servicio está unhealthy | Inspecciona ps y los logs del servicio, no solo el orden de inicio |
PostgreSQL rechaza la contraseña después de cambiar .env | El volumen conserva la contraseña anterior; editar la variable no la rota |
| Un cambio de código no aparece | Reconstruye la imagen; reiniciar no vuelve a copiar los archivos |
pnpm install --frozen-lockfile falla | Comprueba que el manifiesto y el lockfile estén sincronizados |
| Parece que desaparecieron los datos | Comprueba nombre del proyecto, volumen montado y si se ejecutó down -v |
Para el conflicto de puertos, cambia por ejemplo API_PORT=3017 en .env y ejecuta de nuevo docker compose up -d --wait. Las siguientes peticiones desde el host usarán http://127.0.0.1:3017; el puerto interno de Node.js seguirá siendo 3000.
Si quieres investigar un fallo de salud, obtén solo el estado correspondiente:
docker inspect \
--format '{{json .State.Health}}' \
"$(docker compose ps -q api)"Revisa también el contenido antes de compartirlo: otros healthchecks podrían escribir información sensible en su salida. Evita publicar un docker inspect completo, que incluye la configuración del contenedor.
Qué cambiaría antes de usarlo en producción
Este entorno privilegia la reproducción local: API sin autenticación, datos ficticios, contraseñas conocidas y Redis sin credenciales dentro de una red aislada. No es una plantilla lista para exponer a Internet.
Para un despliegue real, revisa al menos:
- Autenticación y autorización de la API, TLS y exposición controlada de puertos.
- Credenciales propias por entorno, permisos mínimos y entrega mediante mecanismos de secretos; nunca copiarlas al build.
- Autenticación de Redis y segmentación según el entorno, sin confiar únicamente en que no exista un puerto publicado.
- Copias de seguridad con restauraciones probadas, migraciones y procedimientos de actualización de PostgreSQL.
- Límites de recursos, logs acotados, métricas, alertas y recuperación ante fallos.
- Versiones de imágenes controladas, evaluación de actualizaciones y persistencia adecuada para la máquina donde se ejecutarán.
Compose puede utilizarse en producción en un servidor, con una configuración apropiada; Docker publica una guía específica para ese escenario. El comando por sí solo no aporta alta disponibilidad ni reemplaza esas decisiones operativas.
Un entorno que otra persona pueda reproducir
Versiona compose.yaml, Dockerfile, código, init.sql, .env.example, los archivos de exclusión, package.json y pnpm-lock.yaml. Conserva .env local y documenta qué datos pueden eliminarse.
La prueba útil para tu equipo es sencilla: alguien clona el proyecto, prepara su configuración, levanta los servicios y entiende cómo verificar que funcionan. Si también sabe cuándo reconstruir y qué ocurre con los volúmenes al detener el entorno, Compose está resolviendo un problema de colaboración, no solo ahorrando comandos.
Referencias
- Docker: modelo de aplicación de Compose.
- Docker: referencia de servicios y redes.
- Docker: orden de arranque y dependencias saludables.
- Docker: interpolación de variables.
- Docker: volúmenes y eliminación con compose down.
- Imagen oficial de PostgreSQL: variables e inicialización.
- Redis: mecanismos de persistencia.
