Tutorial

Docker Compose: toda tu aplicación en un solo archivo

Con tu copiloto de IA, construí un libro de visitas en Node.js con Valkey, correlo primero con comandos sueltos y después llevalo a un compose.yaml: servicios, red, volúmenes, dependencias con healthchecks, configuración con .env y desarrollo en vivo con Compose Watch.

Con tu copiloto de IA, construí un libro de visitas en Node.js con Valkey, correlo primero con comandos sueltos y después llevalo a un compose.yaml: servicios, red, volúmenes, dependencias con healthchecks, configuración con .env y desarrollo en vivo con Compose Watch.

Docker Compose

Objetivo del laboratorio

Vas a construir una aplicación de dos piezas en el orden en que suele pasar en la realidad. Primero la aplicación: la construís con tu copiloto de IA, la hacés funcionar en tu máquina con la base de datos en un contenedor y le escribís su Dockerfile. Recién cuando funciona, la llevás a Docker Compose, que describe toda la aplicación en un archivo versionable y la levanta con un solo comando.

Ese orden no es casual. Al llegar a Compose ya vas a haber sentido el problema que resuelve: varias terminales, comandos que hay que recordar, una base de datos que hay que levantar antes que la app y configuración repartida entre la línea de comandos y el código.

Arquitectura

La aplicación es un libro de visitas (guestbook): cada persona deja su nombre y un mensaje, y todos ven las firmas más recientes. Es un ejemplo clásico de aplicación de varios servicios, porque separa con claridad la parte que atiende pedidos de la parte que guarda datos.

Cómo usar esta guía

Todos los comandos se ejecutan en docker-01, como el usuario laborant, dentro de ~/lab-compose/app. Cada paso tiene el comando, lo que deberías ver y una tabla 🔍 que explica sus partes. Los bloques ::simple-task verifican solos que cada etapa quedó resuelta.

En este laboratorio la IA trabaja como un copiloto: escribe el código, el Dockerfile y el compose.yaml, y vos tomás las decisiones. Usá el asistente que prefieras (ChatGPT, Claude, Gemini, Copilot o Kiro) y seguí siempre el mismo ciclo: pedir con un contrato o requisitos claros, revisar la respuesta contra ellos y probar con un comando. Nunca pegues secretos en un prompt.

Tu entorno de trabajo

El playground te da tres herramientas en el navegador. Conocelas antes de empezar: las vas a usar en todo el laboratorio.

Dos terminales

Vas a usar dos terminales. Abrí una segunda pestaña con el botón + de la barra superior:

  • 🖥️ Terminal 1: procesos que ocupan la pantalla (la app local, los logs, Compose Watch).
  • 🖥️ Terminal 2: todo lo demás. Si un paso no indica terminal, va acá.

Un editor de código (IDE)

La pestaña IDE abre un editor en el navegador con tu carpeta personal. En el panel Explorer vas a encontrar lab-compose/app con todos los archivos del laboratorio.

El IDE del playground con el proyecto abierto

Los pasos usan comandos para crear y modificar archivos, así se pueden copiar y pegar. Pero siempre podés abrir el archivo en el IDE para leerlo con resaltado de sintaxis o editarlo a mano. Es especialmente cómodo en el Bloque G: guardás en el IDE y Compose Watch lleva el cambio al contenedor.

Ver la aplicación en tu navegador

La app corre dentro del playground, así que localhost solo funciona desde sus terminales. Para abrirla en tu navegador, tenés que exponer el puerto:

  1. Abrí el menú ⋮ de la esquina superior derecha y elegí Expose HTTP port.
    Menú del playground con la opción Expose HTTP port
  2. En Exposed ports aparece una URL. Abrila: es tu aplicación.
    Puerto expuesto con su URL

A lo largo del laboratorio la app cambia de puerto. Exponé el que corresponda en cada momento:

MomentoPuertoQué vas a ver
Paso 03, la app en tu máquina3000El libro de visitas corriendo con npm start
Paso 06, la app con Compose8080El mismo libro, ahora en contenedores
Desde el Paso 089090El puerto definido en .env
Note

Qué conviene saber sobre la exposición de puertos. La URL es privada por defecto: solo la abrís vos, con tu sesión. Con el interruptor Public podés compartirla con otras personas. Además, solo se pueden exponer servicios que escuchen en la interfaz principal de la máquina, no solo en localhost. Podés comprobarlo con curl http://docker-01:<puerto>: si responde, se puede exponer. Todos los servicios de este laboratorio cumplen esa condición. Cuando un puerto deja de usarse, borralo de la lista con el ícono de la papelera.

Bloque A · Preparación (paso 01)

Paso 01 · Verificar el entorno

Corré esto en las dos terminales:

node --version
docker compose version
cd ~/lab-compose/app

Deberías ver: node en v24 o superior y la versión de Docker Compose (v2.x). Si node no aparece, esperá unos segundos y abrí una terminal nueva: el entorno lo instala al arrancar.

🔍 Qué hace cada comando

ComandoQué hace
node --versionVersión de Node.js, para correr la app en tu máquina en el Bloque B.
docker compose versionVersión del plugin de Compose. En Compose v2 el comando es docker compose (con espacio); el antiguo docker-compose (con guion) está discontinuado.

Bloque B · Primero, la aplicación (pasos 02 a 04)

Paso 02 · Construir el libro de visitas con tu copiloto de IA

La clave para que tu copiloto de IA genere algo útil es un contrato verificable. Este es el de la aplicación:

RutaComportamiento
GET /La página del libro de visitas (archivos estáticos de public/).
GET /api/infoSuma una visita y responde version (de package.json), host y visitas.
GET /api/firmasLas 50 firmas más recientes.
POST /api/firmasRecibe { "nombre": "...", "mensaje": "..." }, guarda la firma y responde 201. Sin nombre o mensaje, 400.
GET /healthok si Valkey responde a PING; 503 si no.

Y así se guardan los datos en Valkey:

ClaveTipoCómo se usa
visitasContadorINCR en cada visita a la página.
firmasLista de textos JSONLPUSH agrega la firma nueva al principio y LTRIM conserva solo las 100 más recientes. Cada firma guarda nombre, mensaje, fecha y el host que la atendió.
🤖 Prompt · Generar el libro de visitas
Contexto: libro de visitas para un laboratorio de Docker Compose.
Node.js 24, CommonJS, Express 5 y la librería oficial "redis" (node-redis v5)
para conectarse a Valkey.

Contrato:
- Sirve los archivos de public/ en la raíz (express.static), así GET / muestra la página.
- GET /api/info incrementa la clave "visitas" (INCR) y responde JSON
  { version, host: os.hostname(), visitas }, con version leída de package.json.
- GET /api/firmas responde las 50 firmas más recientes de la lista "firmas"
  (LRANGE 0 49), convertidas de texto JSON a objetos.
- POST /api/firmas recibe JSON { nombre, mensaje } (máximo 40 y 280 caracteres).
  Si falta alguno, responde 400. Si no, guarda { nombre, mensaje, fecha ISO,
  host: os.hostname() } como texto JSON con LPUSH, recorta la lista a 100
  elementos con LTRIM (ambos en una transacción MULTI) y responde 201 con la firma.
- GET /health hace PING a Valkey: responde el texto "ok", o 503 si falla.

Configuración y resiliencia:
- Puerto: process.env.PORT o 3000. Conexión: process.env.VALKEY_URL o "redis://localhost:6379".
- Listener de "error" en el cliente, y disableOfflineQueue: true.
- Si el cliente no está listo (isReady), las rutas de datos responden 503.
- La app arranca aunque Valkey no esté disponible (no esperar la conexión antes de listen).

Restricciones: solo express y redis como dependencias. Sin autenticación.

Salida, en tres bloques de código y sin explicaciones entre ellos:
1. app.js
2. package.json (name "lab-compose", version "1.0.0", script "start")
3. public/index.html: página en modo oscuro, sin recursos externos, con el título
   "Libro de visitas", métricas de visitas, cantidad de firmas, versión y host,
   un indicador de salud que consulte /health, un formulario (nombre y mensaje)
   con el subtítulo "Dejá tu firma", y la lista de firmas con fecha relativa.
   Mostrá los textos con textContent, nunca con innerHTML, para evitar inyección de HTML.

Antes de guardar, revisá el código contra esta lista:

  • ¿package.json declara solo express y redis?
  • ¿Lee VALKEY_URL con su valor por defecto? Es la pieza que vas a cambiar al pasar a Compose.
  • ¿Hay un listener on('error') en el cliente de Valkey?
  • ¿POST /api/firmas valida nombre y mensaje, y recorta la lista con LTRIM?
  • ¿La página muestra las firmas con textContent? Si usa innerHTML con el texto que escribe la gente, cualquiera podría inyectar HTML en el libro.

Si algo no cumple, devolvéselo al modelo citando el requisito. Guardá los tres archivos en ~/lab-compose/app (con index.html dentro de public/). Si después de un par de intentos el modelo no cumple el contrato, usá el código de referencia:

Código de referencia del libro de visitas (v1.0.0)

Copiá este bloque entero en la Terminal 2. Crea los tres archivos de una vez:

cd ~/lab-compose/app
mkdir -p public

cat > app.js << 'EOF'
const express = require('express');
const os = require('os');
const path = require('path');
const { createClient } = require('redis');
const { version } = require('./package.json');

const PORT = process.env.PORT || 3000;
const VALKEY_URL = process.env.VALKEY_URL || 'redis://localhost:6379';
const MAX_FIRMAS = 100;

const valkey = createClient({ url: VALKEY_URL, disableOfflineQueue: true });
valkey.on('error', (err) => console.error(`[valkey] ${err.message}`));
valkey.on('ready', () => console.log(`[valkey] conectado a ${VALKEY_URL}`));
valkey.connect().catch((err) => console.error(`[valkey] conexion inicial fallida: ${err.message}`));

const app = express();
app.use(express.json());
app.use(express.static(path.join(__dirname, 'public')));

const requiereValkey = (req, res, next) =>
  valkey.isReady ? next() : res.status(503).json({ error: 'Valkey no disponible' });

app.get('/api/info', requiereValkey, async (req, res) => {
  const visitas = await valkey.incr('visitas');
  res.json({ version, host: os.hostname(), visitas });
});

app.get('/api/firmas', requiereValkey, async (req, res) => {
  const firmas = await valkey.lRange('firmas', 0, 49);
  res.json(firmas.map((f) => JSON.parse(f)));
});

app.post('/api/firmas', requiereValkey, async (req, res) => {
  const nombre = String(req.body?.nombre ?? '').trim().slice(0, 40);
  const mensaje = String(req.body?.mensaje ?? '').trim().slice(0, 280);
  if (!nombre || !mensaje) {
    return res.status(400).json({ error: 'nombre y mensaje son obligatorios' });
  }
  const firma = { nombre, mensaje, fecha: new Date().toISOString(), host: os.hostname() };
  await valkey
    .multi()
    .lPush('firmas', JSON.stringify(firma))
    .lTrim('firmas', 0, MAX_FIRMAS - 1)
    .exec();
  res.status(201).json(firma);
});

app.get('/health', async (req, res) => {
  try {
    await valkey.ping();
    res.send('ok');
  } catch {
    res.status(503).send('valkey no responde');
  }
});

app.listen(PORT, () => console.log(`API escuchando en ${PORT}`));
EOF

cat > package.json << 'EOF'
{
  "name": "lab-compose",
  "version": "1.0.0",
  "main": "app.js",
  "scripts": {
    "start": "node app.js"
  },
  "dependencies": {
    "express": "^5.1.0",
    "redis": "^5.0.0"
  }
}
EOF

cat > public/index.html << 'EOF'
<!DOCTYPE html>
<html lang="es">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Libro de visitas · lab-compose</title>
  <style>
    :root { --texto: #e8ecf5; --suave: #9aa4bf; --borde: rgba(255,255,255,.12);
            --fuego: #ff6b35; --fuego2: #ffb347; --ok: #22c55e; --mal: #ef4444; }
    * { box-sizing: border-box; }
    body { margin: 0; min-height: 100vh; padding: 32px 16px;
           font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; color: var(--texto);
           background: radial-gradient(circle at 15% 10%, rgba(255,107,53,.18), transparent 40%),
                       radial-gradient(circle at 85% 90%, rgba(124,156,255,.2), transparent 45%),
                       linear-gradient(135deg, #0b1020, #1a1033) fixed; }
    main { max-width: 640px; margin: 0 auto; }
    .card { padding: 28px; border-radius: 24px; background: rgba(255,255,255,.06);
            border: 1px solid var(--borde); box-shadow: 0 20px 60px rgba(0,0,0,.45); }
    header { display: flex; justify-content: space-between; align-items: center; gap: 12px; margin-bottom: 20px; }
    h1 { margin: 0; font-size: 1.7rem;
         background: linear-gradient(90deg, var(--fuego), var(--fuego2));
         -webkit-background-clip: text; background-clip: text; color: transparent; }
    .salud { display: inline-flex; align-items: center; gap: 8px; font-size: .8rem; white-space: nowrap;
             padding: 6px 12px; border-radius: 999px; border: 1px solid var(--borde); }
    .punto { width: 10px; height: 10px; border-radius: 50%; background: var(--suave); }
    .punto.ok { background: var(--ok); box-shadow: 0 0 10px var(--ok); } .punto.mal { background: var(--mal); }
    .metricas { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin-bottom: 24px; }
    .metrica { padding: 12px; border-radius: 14px; text-align: center; background: rgba(0,0,0,.25); border: 1px solid var(--borde); }
    .metrica span { display: block; font-size: .68rem; text-transform: uppercase; letter-spacing: 1px; color: var(--suave); }
    .metrica strong { display: block; margin-top: 4px; font-size: 1.3rem; font-family: ui-monospace, Menlo, monospace; word-break: break-all; }
    .metrica.chica strong { font-size: .85rem; }
    h2 { font-size: .95rem; margin: 0 0 12px; color: var(--suave); }
    form { display: grid; gap: 10px; }
    input, textarea { width: 100%; padding: 11px 14px; border-radius: 12px; font: inherit; font-size: .95rem;
                      border: 1px solid var(--borde); background: rgba(0,0,0,.3); color: var(--texto); }
    textarea { resize: vertical; min-height: 80px; }
    input:focus, textarea:focus { outline: 2px solid var(--fuego); outline-offset: 1px; }
    .fila { display: flex; justify-content: space-between; align-items: center; }
    .contador { font-size: .75rem; color: var(--suave); }
    button { cursor: pointer; border: 0; border-radius: 12px; padding: 11px 22px; font-weight: 600;
             color: #1a1033; background: linear-gradient(90deg, var(--fuego), var(--fuego2)); }
    button:disabled { opacity: .6; cursor: wait; }
    .error { color: var(--mal); font-size: .85rem; min-height: 1.2em; margin: 6px 0 0; }
    .firmas { list-style: none; margin: 24px 0 0; padding: 0; display: grid; gap: 12px; }
    .firma { display: grid; grid-template-columns: 42px 1fr; gap: 12px; padding: 16px; border-radius: 16px;
             background: rgba(255,255,255,.05); border: 1px solid var(--borde); animation: entrar .35s ease-out; }
    @keyframes entrar { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
    .avatar { width: 42px; height: 42px; border-radius: 50%; display: grid; place-items: center;
              font-weight: 700; color: #1a1033; background: linear-gradient(135deg, var(--fuego), var(--fuego2)); }
    .firma b { display: block; }
    .firma p { margin: 6px 0; line-height: 1.45; white-space: pre-wrap; word-break: break-word; }
    .meta { font-size: .75rem; color: var(--suave); }
    .vacio { text-align: center; color: var(--suave); font-style: italic; padding: 20px; }
    footer { margin-top: 24px; text-align: center; font-size: .8rem; color: var(--suave); }
    footer b { color: var(--fuego2); }
  </style>
</head>
<body>
  <main>
    <section class="card">
      <header>
        <h1>Libro de visitas</h1>
        <span class="salud"><span class="punto" id="punto"></span><span id="salud">Chequeando...</span></span>
      </header>
      <div class="metricas">
        <div class="metrica"><span>Visitas</span><strong id="visitas">-</strong></div>
        <div class="metrica"><span>Firmas</span><strong id="total">-</strong></div>
        <div class="metrica chica"><span>Versión · host</span><strong id="info">-</strong></div>
      </div>
      <h2>Dejá tu firma</h2>
      <form id="formulario">
        <input id="nombre" placeholder="Tu nombre" maxlength="40" autocomplete="off" required>
        <textarea id="mensaje" placeholder="Tu mensaje" maxlength="280" required></textarea>
        <div class="fila">
          <span class="contador" id="contador">0 / 280</span>
          <button type="submit" id="enviar">Firmar</button>
        </div>
      </form>
      <p class="error" id="error"></p>
    </section>

    <ul class="firmas" id="firmas"></ul>

    <footer>Hecho por <b>Roxs</b> 🔥 · Node.js + Valkey + Docker Compose</footer>
  </main>

  <script>
    const $ = (id) => document.getElementById(id);

    function hace(fechaIso) {
      const s = Math.floor((Date.now() - new Date(fechaIso)) / 1000);
      if (s < 60) return 'hace instantes';
      if (s < 3600) return `hace ${Math.floor(s / 60)} min`;
      if (s < 86400) return `hace ${Math.floor(s / 3600)} h`;
      return new Date(fechaIso).toLocaleDateString('es');
    }

    async function cargarInfo() {
      try {
        const d = await (await fetch('/api/info')).json();
        $('visitas').textContent = d.visitas ?? '-';
        $('info').textContent = d.version ? `${d.version} · ${d.host}` : '-';
      } catch {}
    }

    async function cargarFirmas() {
      try {
        const res = await fetch('/api/firmas');
        if (!res.ok) throw new Error();
        const firmas = await res.json();
        $('total').textContent = firmas.length;
        $('firmas').innerHTML = '';
        if (firmas.length === 0) {
          $('firmas').innerHTML = '<li class="vacio">Todavía nadie firmó. ¡Sé la primera persona!</li>';
          return;
        }
        for (const f of firmas) {
          const li = document.createElement('li');
          li.className = 'firma';
          const avatar = document.createElement('div');
          avatar.className = 'avatar';
          avatar.textContent = f.nombre.charAt(0).toUpperCase();
          const cuerpo = document.createElement('div');
          const nombre = document.createElement('b');
          nombre.textContent = f.nombre;
          const mensaje = document.createElement('p');
          mensaje.textContent = f.mensaje;
          const meta = document.createElement('span');
          meta.className = 'meta';
          meta.textContent = `${hace(f.fecha)} · vía ${f.host}`;
          cuerpo.append(nombre, mensaje, meta);
          li.append(avatar, cuerpo);
          $('firmas').appendChild(li);
        }
      } catch {
        $('firmas').innerHTML = '<li class="vacio">No se pudieron cargar las firmas</li>';
      }
    }

    async function chequearSalud() {
      try {
        const res = await fetch('/health');
        const ok = res.ok && (await res.text()) === 'ok';
        $('punto').className = 'punto ' + (ok ? 'ok' : 'mal');
        $('salud').textContent = ok ? 'Valkey conectado' : 'Valkey no disponible';
      } catch {
        $('punto').className = 'punto mal';
        $('salud').textContent = 'API sin respuesta';
      }
    }

    $('mensaje').addEventListener('input', () => {
      $('contador').textContent = `${$('mensaje').value.length} / 280`;
    });

    $('formulario').addEventListener('submit', async (e) => {
      e.preventDefault();
      $('enviar').disabled = true;
      const res = await fetch('/api/firmas', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ nombre: $('nombre').value, mensaje: $('mensaje').value }),
      }).catch(() => null);
      $('enviar').disabled = false;
      if (!res || !res.ok) {
        $('error').textContent = 'No se pudo guardar la firma';
        return;
      }
      $('error').textContent = '';
      $('mensaje').value = '';
      $('contador').textContent = '0 / 280';
      cargarFirmas();
    });

    cargarInfo();
    cargarFirmas();
    chequearSalud();
    setInterval(chequearSalud, 5000);
    setInterval(cargarFirmas, 10000);
  </script>
</body>
</html>
EOF

ls -R

🔍 Las piezas clave de app.js

PiezaQué hace
express.static(...)Sirve public/index.html en la raíz: la página y la API viven en el mismo servidor.
createClient({ url: VALKEY_URL, disableOfflineQueue: true })Crea el cliente de Valkey. Sin la cola offline, si Valkey no está, los comandos fallan rápido en lugar de colgar las peticiones.
valkey.on('error', ...)Registra los errores de conexión. Sin este listener, un error terminaría el proceso.
multi().lPush(...).lTrim(...).exec()Agrega la firma y recorta la lista en una sola transacción: nunca queda a medio actualizar.

Paso 03 · Correrla en tu máquina, con Valkey en un contenedor

La app necesita una base de datos. En lugar de instalar Valkey, lo corrés en un contenedor y publicás su puerto para que la app local lo alcance:

docker run -d --name valkey-dev -p 6379:6379 valkey/valkey:8-alpine
docker exec valkey-dev valkey-cli PING
npm install

Deberías ver: PONG y la instalación de express y redis.

En la 🖥️ Terminal 1, arrancá la app:

cd ~/lab-compose/app
npm start

Deberías ver: API escuchando en 3000 y [valkey] conectado a redis://localhost:6379.

En la 🖥️ Terminal 2, probá el contrato:

curl -s localhost:3000/api/info ; echo
curl -s -X POST localhost:3000/api/firmas \
  -H 'Content-Type: application/json' \
  -d '{"nombre":"Roxs","mensaje":"La primera firma del libro"}' ; echo
curl -s -w ' [HTTP %{http_code}]\n' -X POST localhost:3000/api/firmas \
  -H 'Content-Type: application/json' -d '{"nombre":"Sin mensaje"}'
curl -s localhost:3000/api/firmas ; echo
curl -s localhost:3000/health ; echo

Deberías ver: un JSON con visitas, la firma creada con fecha y host, un error con [HTTP 400] por el mensaje faltante, la lista con tu firma y ok.

Ahora abrilo en tu navegador: exponé el puerto 3000 (ver Ver la aplicación en tu navegador) y abrí la URL. Vas a ver el libro de visitas con tu firma. Dejá otra desde el formulario y fijate que aparece también con curl -s localhost:3000/api/firmas.

🔍 Qué hace cada parte

ParteQué hace
docker run -d --name valkey-dev -p 6379:6379 ...Levanta Valkey en segundo plano y publica su puerto, para que la app que corre en tu máquina lo encuentre en localhost:6379.
docker exec valkey-dev valkey-cli PINGEjecuta el cliente de Valkey dentro del contenedor para verificar que responde.
npm installDescarga las dependencias en node_modules y genera package-lock.json.
curl -X POST -H '...' -d '{...}'Envía un pedido POST con cuerpo JSON.
-w ' [HTTP %{http_code}]\n'Agrega el código de respuesta HTTP al final de la salida.

Fijate cuántas cosas estás sosteniendo a mano para que esto funcione: un contenedor de Valkey con su puerto publicado, un proceso de Node ocupando una terminal, y una conexión a localhost que solo tiene sentido en tu máquina. Guardá esa lista: es exactamente lo que va a resolver Compose.

Paso 04 · Escribir el Dockerfile

Para que Compose pueda correr la app, primero tiene que poder empaquetarse en una imagen:

cat > .dockerignore << 'EOF'
node_modules
.git
*.log
.env
Dockerfile*
.dockerignore
compose*.yaml
EOF

cat > Dockerfile << 'EOF'
FROM node:24-alpine
ENV NODE_ENV=production
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY --chown=node:node . .
USER node
EXPOSE 3000
HEALTHCHECK --interval=10s --timeout=3s --start-period=10s --retries=3 \
  CMD wget -qO- http://localhost:3000/health || exit 1
CMD ["node", "app.js"]
EOF

No hace falta construirla ahora: Compose lo va a hacer en el paso siguiente.

🔍 Qué hace cada instrucción

InstrucciónQué hace
.dockerignoreExcluye del build las dependencias del host, los logs, los secretos (.env) y los propios archivos de Docker y Compose.
COPY package*.json + RUN npm ci --omit=devInstala las dependencias de producción antes de copiar el código, para aprovechar la caché.
USER nodeLa app corre sin privilegios de administrador.
HEALTHCHECKConsulta /health cada 10 segundos. Usa wget porque la imagen Alpine no trae curl.

Fijate que no se define VALKEY_URL: la conexión se configura desde afuera, y eso es lo que va a hacer Compose.

Bloque C · De comandos sueltos a Compose (pasos 05 y 06)

Paso 05 · Describir la aplicación en compose.yaml

Primero, desarmá lo que levantaste a mano. En la 🖥️ Terminal 1, pará la app con Ctrl+C. En la 🖥️ Terminal 2:

docker rm -f valkey-dev

Ahora, en lugar de pedirle a tu copiloto de IA que traduzca comandos, describile la arquitectura y los requisitos, y dejá que proponga cómo cumplirlos:

🤖 Prompt · compose.yaml a partir de requisitos
Escribí un compose.yaml para un libro de visitas con dos servicios.

Arquitectura:
- "app": API en Node.js que se construye con el Dockerfile de la carpeta actual.
  Escucha en el puerto 3000 y se conecta a Valkey con la variable VALKEY_URL.
  Se publica en el puerto 8080 de la máquina.
- "valkey": base de datos Valkey 8 (imagen valkey/valkey:8-alpine) con
  persistencia AOF activada.

Requisitos:
- Nombre de proyecto "lab-compose" y la imagen de la app etiquetada "lab-compose-app".
- Los datos de Valkey tienen que sobrevivir a que se borre el contenedor.
- La app arranca solo cuando Valkey está listo para recibir conexiones.
- Valkey no debe ser accesible desde fuera de la aplicación.
- Si un contenedor se cae, se reinicia solo, salvo que lo detenga manualmente.
- Especificación actual de Compose.

Debajo del archivo, explicá qué clave cumple cada requisito.

Los requisitos no dicen cómo resolver cada cosa. Tu trabajo es verificar si el modelo eligió bien el mecanismo:

RequisitoLo correctoError frecuente de los modelos
Los datos sobrevivenVolumen nombrado en /dataNo declarar volumen, o usar un bind mount a una carpeta del host
La app espera a Valkeydepends_on con condition: service_healthy + healthcheck en Valkeydepends_on sin condición: solo ordena el arranque, no espera
Valkey no es accesible desde afueraSin ports: en ValkeyPublicar el 6379 "por las dudas"
Especificación actualSin clave version:Agregar version: "3.8", que es obsoleta y genera una advertencia
Conexión a ValkeyVALKEY_URL: redis://valkey:6379Dejar localhost, que dentro de un contenedor es el propio contenedor

Guardá el resultado como compose.yaml. Si el modelo no cumple, usá el de referencia:

cat > compose.yaml << 'EOF'
name: lab-compose

services:
  app:
    build: .
    image: lab-compose-app
    ports:
      - "8080:3000"
    environment:
      VALKEY_URL: redis://valkey:6379
    depends_on:
      valkey:
        condition: service_healthy
    restart: unless-stopped

  valkey:
    image: valkey/valkey:8-alpine
    command: ["valkey-server", "--appendonly", "yes"]
    volumes:
      - valkey-data:/data
    healthcheck:
      test: ["CMD", "valkey-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 5
    restart: unless-stopped

volumes:
  valkey-data:
EOF

docker compose config -q && echo "compose.yaml válido"

Qué cambió respecto de lo que hacías a mano en el Paso 03:

En el Paso 03, a manoEn compose.yaml
docker run ... valkey/valkey:8-alpineEl servicio valkey.
-p 6379:6379 para que la app local llegueSin ports:: la app lo alcanza por la red interna del proyecto.
VALKEY_URL = localhost:6379redis://valkey:6379: el nombre del servicio funciona como nombre de host.
Los datos vivían en el contenedorVolumen nombrado valkey-data, independiente del contenedor.
npm start en una terminalbuild: . construye la imagen con tu Dockerfile y la corre.
Levantar Valkey antes que la appdepends_on + healthcheck.
(una red)Nada: Compose crea una red para el proyecto automáticamente.

🔍 Qué hace cada clave

ClaveQué hace
name: lab-composeNombre del proyecto. Compose lo usa como prefijo de todo lo que crea; sin esta clave usaría el nombre de la carpeta (app).
build: . + image:Construye la imagen con el Dockerfile de la carpeta y le pone ese nombre.
command:Reemplaza el comando por defecto de la imagen: arranca Valkey con AOF.
healthcheck:Cómo sabe Docker que Valkey está listo: ejecuta valkey-cli ping cada 5 segundos.
restart: unless-stoppedSi el contenedor se cae, Docker lo reinicia, salvo que lo hayas detenido vos.
volumes: (al final)Declara los volúmenes nombrados del proyecto.
docker compose config -qValida el archivo sin mostrarlo (-q, quiet).

Paso 06 · Levantar y operar la aplicación

docker compose up -d --build
docker compose ps
curl -s localhost:8080/api/info ; echo
docker network ls --filter name=lab-compose
docker volume ls --filter name=lab-compose

Deberías ver: Compose construyendo la imagen y creando la red lab-compose_default, el volumen lab-compose_valkey-data y los dos contenedores, ambos Up y (healthy). El libro de visitas está vacío: es un Valkey nuevo, con su propio volumen. Todo lleva el nombre del proyecto como prefijo, así varios proyectos conviven en la misma máquina sin chocar.

Exponé el puerto 8080 y abrí la URL: es el mismo libro de visitas, ahora corriendo en contenedores. Dejá una firma. Si todavía tenés expuesto el 3000, borralo: ya no hay nada escuchando ahí.

En la 🖥️ Terminal 1, seguí los logs:

docker compose logs -f

En la 🖥️ Terminal 2, consultá los datos directamente en Valkey:

curl -s -X POST localhost:8080/api/firmas \
  -H 'Content-Type: application/json' \
  -d '{"nombre":"Compose","mensaje":"Ahora todo se levanta con un comando"}' ; echo
docker compose exec valkey valkey-cli LLEN firmas
docker compose exec valkey valkey-cli LRANGE firmas 0 0

Deberías ver: en la Terminal 1, los logs de ambos servicios intercalados, cada línea con el nombre de su servicio como prefijo. En la Terminal 2, la cantidad de firmas y la más reciente tal como se guarda en Valkey: un texto JSON. Salí de los logs con Ctrl+C.

🔍 Qué hace cada comando

ComandoQué hace
docker compose up -d --buildCrea todo lo que describe el archivo y lo arranca en segundo plano. --build construye la imagen antes. Si algo ya existe y no cambió, lo deja como está.
docker compose psLista los contenedores de este proyecto, con su estado y salud.
docker compose logs -f [servicio]Sigue los logs de todos los servicios, o de uno solo.
docker compose exec <servicio> <comando>Ejecuta un comando en un servicio. Usás el nombre del servicio, no el del contenedor.

Bloque D · Dependencias y salud (paso 07)

Paso 07 · Ver el arranque ordenado

docker compose down
docker compose up -d
docker inspect "$(docker compose ps -q valkey)" --format '{{.State.Health.Status}}'

Deberías ver: que Compose crea primero valkey, espera a que su healthcheck lo reporte como sano (Healthy) y recién entonces arranca app. El último comando confirma healthy. Sin la condición service_healthy, los dos arrancarían casi a la vez y la app intentaría conectarse a una base de datos que todavía no está lista.

🔍 Qué hace cada parte

ParteQué hace
docker compose downDetiene y borra los contenedores y la red del proyecto. Los volúmenes no se tocan.
docker compose ps -q valkeyDevuelve solo el ID del contenedor del servicio, para combinarlo con otros comandos.
Note

depends_on solo actúa al arrancar. Si Valkey se reinicia más tarde, Compose no detiene la app. Por eso la aplicación igual tiene que tolerar que su base de datos no esté disponible y reconectarse sola, como hace este libro de visitas.

Bloque E · Configuración con .env (paso 08)

Paso 08 · Parametrizar el puerto

Los valores que cambian según el entorno no deberían estar escritos en el compose.yaml. Compose lee automáticamente un archivo .env en la misma carpeta y reemplaza las variables ${...}:

sed -i 's|- "8080:3000"|- "${APP_PORT:-8080}:3000"|' compose.yaml
echo "APP_PORT=9090" > .env
docker compose config | grep -A3 'ports:'
docker compose up -d
curl -s localhost:9090/health ; echo

Deberías ver: published: "9090" en la configuración resuelta y que Compose recrea solo app, dejando valkey como estaba. Compose compara el archivo con lo que está corriendo y aplica únicamente la diferencia. A partir de acá, la app responde en el 9090: exponé ese puerto para seguir viéndola en el navegador, y borrá el 8080.

🔍 Qué hace cada parte

ParteQué hace
sed -i 's|viejo|nuevo|' compose.yamlReemplaza texto en el archivo. Usa | como separador porque el texto contiene /.
${APP_PORT:-8080}Usa la variable APP_PORT; si no está definida, 8080.
.envArchivo de variables que Compose lee solo.
docker compose configMuestra el archivo con las variables resueltas: la forma de verificar qué va a aplicar Compose.
Important

El .env no se versiona si tiene secretos. Es común guardar en el repositorio un .env.example con valores de ejemplo y agregar .env al .gitignore. El .dockerignore ya lo excluye, para que nunca termine dentro de una imagen.

Bloque F · Ciclo de vida y datos (paso 09)

Paso 09 · Qué sobrevive a cada comando

docker compose exec valkey valkey-cli LLEN firmas

docker compose stop && docker compose start
docker compose exec valkey valkey-cli LLEN firmas

docker compose down && docker compose up -d
docker compose exec valkey valkey-cli LLEN firmas

Deberías ver: la misma cantidad de firmas las tres veces. Con down se borraron los contenedores, pero el volumen quedó, y los datos con él.

Ahora el comando que sí borra los datos:

docker compose down -v
docker compose up -d
curl -s localhost:9090/api/firmas ; echo

Deberías ver: []: el libro de visitas está vacío. Dejá un par de firmas nuevas antes de seguir, desde el navegador o con curl: las vas a necesitar en el Bloque G.

ComandoContenedoresRedVolúmenes (datos)
docker compose stop / startDetenidos y rearrancados, no borradosSe mantieneSe mantienen
docker compose downBorradosBorradaSe mantienen
docker compose down -vBorradosBorradaBorrados

Para pensar: docker compose down -v es cómodo para empezar de cero en desarrollo. ¿Qué precauciones tomarías antes de ejecutarlo en un entorno con datos reales?

Bloque G · Desarrollo con Compose Watch (pasos 10 y 11)

En el Bloque B, cada cambio en el código implicaba parar y volver a arrancar npm start. Compose Watch vigila tus archivos y, según qué cambie, los sincroniza con el contenedor, lo reinicia o reconstruye la imagen.

Paso 10 · Configurar Compose Watch y cambiar en vivo

Reemplazá el compose.yaml por esta versión. Incluye el puerto parametrizado y una sección develop nueva en el servicio app:

cat > compose.yaml << 'EOF'
name: lab-compose

services:
  app:
    build: .
    image: lab-compose-app
    ports:
      - "${APP_PORT:-8080}:3000"
    environment:
      VALKEY_URL: redis://valkey:6379
    depends_on:
      valkey:
        condition: service_healthy
    restart: unless-stopped
    develop:
      watch:
        - action: sync+restart
          path: ./app.js
          target: /app/app.js
        - action: sync
          path: ./public
          target: /app/public
        - action: rebuild
          path: ./package.json

  valkey:
    image: valkey/valkey:8-alpine
    command: ["valkey-server", "--appendonly", "yes"]
    volumes:
      - valkey-data:/data
    healthcheck:
      test: ["CMD", "valkey-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 5
    restart: unless-stopped

volumes:
  valkey-data:
EOF

🔍 Las tres acciones de Watch

AcciónCuándo se disparaQué hacePor qué acá
sync+restartCambia app.jsCopia el archivo al contenedor y reinicia el procesoNode necesita reiniciarse para cargar código nuevo.
syncCambia algo en public/Copia los archivos, sin reiniciarExpress lee los archivos estáticos del disco: el cambio se ve al instante.
rebuildCambia package.jsonReconstruye la imagen y recrea el contenedorNuevas dependencias o versión requieren volver a ejecutar npm ci.

En la 🖥️ Terminal 1, arrancá el modo watch y dejalo abierto:

cd ~/lab-compose/app
docker compose watch

En la 🖥️ Terminal 2, cambiá la interfaz (acción sync):

sed -i 's|Dejá tu firma|Dejá tu firma (actualizado con Compose Watch)|' public/index.html
sleep 2
curl -s localhost:9090/ | grep -o 'Dejá tu firma[^<]*'

Deberías ver: el texto nuevo servido por el contenedor, sin reiniciar nada. En la Terminal 1, el aviso de sincronización. Recargá la página en el navegador para verlo.

Probalo también desde el IDE: abrí lab-compose/app/public/index.html, cambiá cualquier texto visible, guardá y recargá el navegador. Es el flujo de trabajo real con Compose Watch: editás, guardás y el contenedor ya tiene el cambio.

Ahora un cambio de código (acción sync+restart): agregá una ruta GET /api/stats que responda visitas (sin incrementarlas), la cantidad de firmas, version y host.

🤖 Prompt · Modificar código existente con cambio mínimo
Este es mi app.js actual:
[pegá acá el contenido completo de app.js]

Agregá GET /api/stats que responda JSON con: visitas (valor numérico de la
clave "visitas" con GET, sin incrementarla; 0 si no existe), firmas (LLEN de
"firmas"), version y host. Usá el mismo middleware que las otras rutas de datos.
Restricciones: no modifiques ninguna ruta existente ni agregues dependencias.
Devolveme el archivo completo y, aparte, la lista de líneas que agregaste.

Revisá: si el modelo tocó algo fuera de la ruta nueva, es un cambio que no pediste.

Cambio de referencia para /api/stats
sed -i '/^app.listen/d' app.js

cat >> app.js << 'EOF'
app.get('/api/stats', requiereValkey, async (req, res) => {
  const [visitas, firmas] = await Promise.all([
    valkey.get('visitas'),
    valkey.lLen('firmas'),
  ]);
  res.json({ visitas: Number(visitas ?? 0), firmas, version, host: os.hostname() });
});

app.listen(PORT, () => console.log(`API escuchando en ${PORT}`));
EOF

sed -i '/^app.listen/d' borra la línea de app.listen; cat >> agrega la ruta nueva y vuelve a poner app.listen al final.

Después de guardar app.js:

sleep 5
curl -s localhost:9090/api/stats ; echo

Deberías ver: las estadísticas con "version":"1.0.0". En la Terminal 1, Compose sincronizó el archivo y reinició app.

Paso 11 · Publicar la versión 2

Cambiar la versión modifica package.json, lo que dispara la acción rebuild:

npm version 2.0.0 --no-git-tag-version
sleep 15
curl -s localhost:9090/api/stats ; echo

Deberías ver: en la Terminal 1, que Compose reconstruye la imagen y recrea el contenedor de app. Después, "version":"2.0.0" con las visitas y firmas que ya tenías: se reconstruyó la app, pero los datos viven en el volumen de Valkey. Si firmás de nuevo, la firma nueva muestra un host distinto al de las anteriores: la atendió el contenedor recién creado.

🔍 Qué hace cada comando

ComandoQué hace
npm version 2.0.0 --no-git-tag-versionActualiza la versión en package.json y package-lock.json, sin crear un tag de Git.
sleep 15Espera a que termine la reconstrucción. Si todavía ves 1.0.0, esperá unos segundos más y repetí el curl.

Detené el modo watch en la 🖥️ Terminal 1 con Ctrl+C. Los servicios siguen corriendo.

Si algo falla

SíntomaQué hacer
ECONNREFUSED 127.0.0.1:6379 en los logs de appVALKEY_URL quedó en localhost: en Compose tiene que ser redis://valkey:6379.
Advertencia the attribute version is obsoleteBorrá la línea version: del principio de compose.yaml.
app queda en Waiting y nunca arrancaEl healthcheck de valkey no pasa: revisá docker compose logs valkey.
port is already allocated¿Quedó corriendo npm start o valkey-dev? Revisá con docker ps, o cambiá APP_PORT.
docker compose watch no reconoce sync+restartTu versión de Compose es anterior a la 2.23: actualizala, o usá sync y reiniciá con docker compose restart app.
Un cambio no se refleja con WatchEl archivo está fuera de las rutas vigiladas: revisá path en develop.watch.
node: command not found en una terminal nuevasource ~/.nvm/nvm.sh o abrí otra terminal.
Los comandos no encuentran el proyectoEjecutalos desde ~/lab-compose/app, donde está compose.yaml.

Cheatsheet · Docker Compose

Quiero...Comando
Validar y ver la configuración finaldocker compose config
Levantar todo (reconstruyendo)docker compose up -d --build
Ver el estado de los serviciosdocker compose ps
Seguir los logsdocker compose logs -f [servicio]
Ejecutar un comando en un serviciodocker compose exec <servicio> <comando>
Detener sin borrar / volver a arrancardocker compose stop / docker compose start
Borrar contenedores y red (conserva datos)docker compose down
Borrar también los volúmenesdocker compose down -v
Desarrollar con recarga automáticadocker compose watch

Limpieza

docker compose down -v --rmi local

Borra contenedores, red, volúmenes y la imagen construida por el proyecto (--rmi local elimina solo las imágenes que Compose construyó, no las descargadas como valkey).

Cierre

  • Construí una aplicación con mi copiloto de IA a partir de un contrato y la hice funcionar en mi máquina.
  • Llevé la aplicación a un compose.yaml a partir de requisitos, y verifiqué los mecanismos que eligió el copiloto.
  • Ordené el arranque con depends_on y healthchecks.
  • Separé la configuración en un .env.
  • Sé qué comandos conservan los datos y cuál los borra.
  • Desarrollé en vivo con Compose Watch y publiqué una versión nueva.

La idea que atraviesa el laboratorio: la aplicación deja de ser una lista de comandos que alguien tiene que recordar para convertirse en un archivo versionable. Cualquier persona del equipo la levanta igual, con docker compose up.


Hecho por Roxs 🔥 · Build with Fire

About the Author

rossana suarez

rossana suarez

Roxs🔥| NaranjaX | ROXS FEST 🤩 | AWS Hero⭐| Docker Captain🐳 | DevOps Ambassador⭐|DevOps🔥| DevSecOps🔥|Content Creator roxs.dev | Developer👩‍💻|Mentor👩‍🏫 🇻🇪🇦🇷

Find this author online

More tutorials you might like

Learn by doing, not just by reading or watching

Sign up for a free account to start a VM playground right on this page, track your progress, and get notified about new learning materials.

Sign up for free