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.

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.

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.

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:
- Abrí el menú ⋮ de la esquina superior derecha y elegí Expose HTTP port.

- En Exposed ports aparece una URL. Abrila: es tu aplicación.

A lo largo del laboratorio la app cambia de puerto. Exponé el que corresponda en cada momento:
| Momento | Puerto | Qué vas a ver |
|---|---|---|
| Paso 03, la app en tu máquina | 3000 | El libro de visitas corriendo con npm start |
| Paso 06, la app con Compose | 8080 | El mismo libro, ahora en contenedores |
| Desde el Paso 08 | 9090 | El puerto definido en .env |
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
| Comando | Qué hace |
|---|---|
node --version | Versión de Node.js, para correr la app en tu máquina en el Bloque B. |
docker compose version | Versió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:
| Ruta | Comportamiento |
|---|---|
GET / | La página del libro de visitas (archivos estáticos de public/). |
GET /api/info | Suma una visita y responde version (de package.json), host y visitas. |
GET /api/firmas | Las 50 firmas más recientes. |
POST /api/firmas | Recibe { "nombre": "...", "mensaje": "..." }, guarda la firma y responde 201. Sin nombre o mensaje, 400. |
GET /health | ok si Valkey responde a PING; 503 si no. |
Y así se guardan los datos en Valkey:
| Clave | Tipo | Cómo se usa |
|---|---|---|
visitas | Contador | INCR en cada visita a la página. |
firmas | Lista de textos JSON | LPUSH 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.jsondeclara soloexpressyredis? - ¿Lee
VALKEY_URLcon 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/firmasvalida nombre y mensaje, y recorta la lista conLTRIM? - ¿La página muestra las firmas con
textContent? Si usainnerHTMLcon 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
| Pieza | Qué 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
| Parte | Qué 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 PING | Ejecuta el cliente de Valkey dentro del contenedor para verificar que responde. |
npm install | Descarga 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ón | Qué hace |
|---|---|
.dockerignore | Excluye 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=dev | Instala las dependencias de producción antes de copiar el código, para aprovechar la caché. |
USER node | La app corre sin privilegios de administrador. |
HEALTHCHECK | Consulta /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:
| Requisito | Lo correcto | Error frecuente de los modelos |
|---|---|---|
| Los datos sobreviven | Volumen nombrado en /data | No declarar volumen, o usar un bind mount a una carpeta del host |
| La app espera a Valkey | depends_on con condition: service_healthy + healthcheck en Valkey | depends_on sin condición: solo ordena el arranque, no espera |
| Valkey no es accesible desde afuera | Sin ports: en Valkey | Publicar el 6379 "por las dudas" |
| Especificación actual | Sin clave version: | Agregar version: "3.8", que es obsoleta y genera una advertencia |
| Conexión a Valkey | VALKEY_URL: redis://valkey:6379 | Dejar 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 mano | En compose.yaml |
|---|---|
docker run ... valkey/valkey:8-alpine | El servicio valkey. |
-p 6379:6379 para que la app local llegue | Sin ports:: la app lo alcanza por la red interna del proyecto. |
VALKEY_URL = localhost:6379 | redis://valkey:6379: el nombre del servicio funciona como nombre de host. |
| Los datos vivían en el contenedor | Volumen nombrado valkey-data, independiente del contenedor. |
npm start en una terminal | build: . construye la imagen con tu Dockerfile y la corre. |
| Levantar Valkey antes que la app | depends_on + healthcheck. |
| (una red) | Nada: Compose crea una red para el proyecto automáticamente. |
🔍 Qué hace cada clave
| Clave | Qué hace |
|---|---|
name: lab-compose | Nombre 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-stopped | Si 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 -q | Valida 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
| Comando | Qué hace |
|---|---|
docker compose up -d --build | Crea 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 ps | Lista 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
| Parte | Qué hace |
|---|---|
docker compose down | Detiene y borra los contenedores y la red del proyecto. Los volúmenes no se tocan. |
docker compose ps -q valkey | Devuelve solo el ID del contenedor del servicio, para combinarlo con otros comandos. |
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
| Parte | Qué hace |
|---|---|
sed -i 's|viejo|nuevo|' compose.yaml | Reemplaza texto en el archivo. Usa | como separador porque el texto contiene /. |
${APP_PORT:-8080} | Usa la variable APP_PORT; si no está definida, 8080. |
.env | Archivo de variables que Compose lee solo. |
docker compose config | Muestra el archivo con las variables resueltas: la forma de verificar qué va a aplicar Compose. |
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.
| Comando | Contenedores | Red | Volúmenes (datos) |
|---|---|---|---|
docker compose stop / start | Detenidos y rearrancados, no borrados | Se mantiene | Se mantienen |
docker compose down | Borrados | Borrada | Se mantienen |
docker compose down -v | Borrados | Borrada | Borrados |
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ón | Cuándo se dispara | Qué hace | Por qué acá |
|---|---|---|---|
sync+restart | Cambia app.js | Copia el archivo al contenedor y reinicia el proceso | Node necesita reiniciarse para cargar código nuevo. |
sync | Cambia algo en public/ | Copia los archivos, sin reiniciar | Express lee los archivos estáticos del disco: el cambio se ve al instante. |
rebuild | Cambia package.json | Reconstruye la imagen y recrea el contenedor | Nuevas 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
| Comando | Qué hace |
|---|---|
npm version 2.0.0 --no-git-tag-version | Actualiza la versión en package.json y package-lock.json, sin crear un tag de Git. |
sleep 15 | Espera 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íntoma | Qué hacer |
|---|---|
ECONNREFUSED 127.0.0.1:6379 en los logs de app | VALKEY_URL quedó en localhost: en Compose tiene que ser redis://valkey:6379. |
Advertencia the attribute version is obsolete | Borrá la línea version: del principio de compose.yaml. |
app queda en Waiting y nunca arranca | El 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+restart | Tu 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 Watch | El archivo está fuera de las rutas vigiladas: revisá path en develop.watch. |
node: command not found en una terminal nueva | source ~/.nvm/nvm.sh o abrí otra terminal. |
| Los comandos no encuentran el proyecto | Ejecutalos desde ~/lab-compose/app, donde está compose.yaml. |
Cheatsheet · Docker Compose
| Quiero... | Comando |
|---|---|
| Validar y ver la configuración final | docker compose config |
| Levantar todo (reconstruyendo) | docker compose up -d --build |
| Ver el estado de los servicios | docker compose ps |
| Seguir los logs | docker compose logs -f [servicio] |
| Ejecutar un comando en un servicio | docker compose exec <servicio> <comando> |
| Detener sin borrar / volver a arrancar | docker compose stop / docker compose start |
| Borrar contenedores y red (conserva datos) | docker compose down |
| Borrar también los volúmenes | docker compose down -v |
| Desarrollar con recarga automática | docker 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.yamla partir de requisitos, y verifiqué los mecanismos que eligió el copiloto. - Ordené el arranque con
depends_ony 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
More tutorials you might like

How Container Filesystem Works: Building a Docker-like Container From Scratch
Learn how Linux containers are built from the ground up. Starting with the mount namespace and a root filesystem, see why PID, cgroup, UTS, and network namespaces naturally follow - and how this foundation makes concepts like bind mounts, volumes, and persistence in Docker or Kubernetes much easier to grasp.

How Container Networking Works: Building a Bridge Network From Scratch
Begin with the basics to understand Docker and Kubernetes networking: learn how to create and interconnect Linux network namespaces using only command-line tools.

How Kubernetes Reinvented Virtual Machines - In a Good Sense
How Virtual Machines were used to deploy services. What old problems containers solve and what new problems create. How Kubernetes used containers to recreate Virtual Machines in a better way?

How Container Images Actually Work: Layers, Configs, Manifests, Indexes, and More
A practical deep dive into container image internals that will help you build a clear mental model of how images are composed, identified, stored, and distributed across registries.
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.