Contenedores conectados: redes, volúmenes y bind mounts
Conectá una API con su base de datos en Docker: redes con resolución de nombres, datos persistentes con volúmenes y desarrollo en vivo con bind mounts. Con Node.js, Valkey y la IA como copiloto.

Objetivo del laboratorio
Casi ninguna aplicación vive sola: necesita una base de datos, un cache o una cola. En este laboratorio vas a resolver los tres problemas que aparecen apenas hay más de un contenedor: cómo se encuentran entre sí, dónde quedan los datos y cómo se trabaja sobre el código sin reconstruir la imagen a cada rato.
Vas a usar dos piezas: una API en Node.js con Express, generada con IA, que cuenta visitas y guarda tareas; y Valkey, una base de datos clave-valor en memoria, de código abierto y compatible con el protocolo de Redis.
Así queda la arquitectura al final:
docker-01
┌──────────────────────────────────────────────────────────────┐
│ :8080 ──┐ red lab-net │
│ │ ┌────────────────────────────────────────┐ │
│ └──▶│ app (v2) ──redis://valkey:6379──▶ valkey │
│ :3001 ──┐ │ │ │ │
│ └──▶│ app-dev ─────────────────────────┘ │ │
│ └──────┬──────────────────────────┬──────┘ │
│ │ bind mount │ volumen │
│ ▼ ▼ │
│ ~/lab-valkey/app (código) valkey-data (/data) │
└──────────────────────────────────────────────────────────────┘
Cómo usar esta guía
Todos los comandos se ejecutan en docker-01, como el usuario laborant. 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.
Vas a usar dos terminales. Abrí una segunda pestaña antes de empezar:
- 🖥️ Terminal 1: procesos que ocupan la pantalla (la app local, los logs en vivo).
- 🖥️ Terminal 2: todo lo demás. Si un paso no indica terminal, va acá.
Cuando uses la IA, seguí siempre el mismo ciclo: pedir con un contrato claro, revisar la respuesta contra ese contrato y probar con un comando. Nunca pegues secretos en un prompt.
Bloque A · Preparación (paso 01)
Paso 01 · Verificar el entorno
Corré esto en las dos terminales:
node --version
docker info --format '{{.ServerVersion}}'
mkdir -p ~/lab-valkey/app && cd ~/lab-valkey/app
Deberías ver: node en v24 o superior y la versión del motor de Docker. Si node no aparece, esperá unos segundos y abrí una terminal nueva: el entorno lo instala al arrancar.
Bloque B · Valkey en un contenedor (paso 02)
Paso 02 · Levantar Valkey y probarlo
docker run -d --name valkey-dev -p 6379:6379 valkey/valkey:8-alpine
docker exec valkey-dev valkey-cli PING
docker exec valkey-dev valkey-cli INCR prueba
docker exec valkey-dev valkey-cli RPUSH lista "a" "b"
docker exec valkey-dev valkey-cli LRANGE lista 0 -1
docker exec valkey-dev valkey-cli DEL prueba lista
Deberías ver: PONG, 1, 2, la lista con a y b, y 2 (claves borradas).
🔍 Qué hace cada parte
| Parte | Qué hace |
|---|---|
-d --name valkey-dev | Corre el contenedor en segundo plano, con un nombre fijo. |
-p 6379:6379 | Publica el puerto de Valkey en la máquina (izquierda: host; derecha: contenedor), para que la app local pueda conectarse. |
docker exec valkey-dev valkey-cli ... | Ejecuta el cliente de Valkey dentro del contenedor. |
PING / INCR / RPUSH / LRANGE | Verificar que responde, sumar 1 a un contador, agregar a una lista y leerla completa. Son los mismos comandos que va a usar la app. |
Bloque C · Generar la aplicación con IA (pasos 03 y 04)
Paso 03 · Generar el código a partir de un contrato
La clave para que una IA genere algo útil es un contrato verificable. Este es el de la aplicación:
| Ruta | Comportamiento |
|---|---|
GET / | Incrementa visitas en Valkey y responde JSON con mensaje, version (de package.json), host y visitas. |
GET /api/tareas | Responde el array de la lista tareas. |
POST /api/tareas | Recibe { "texto": "..." }, lo agrega a tareas y responde 201; sin texto, 400. |
GET /health | ok si Valkey responde a PING; 503 si no. |
GET /public/ | Una página para ver visitas y cargar tareas. |
La conexión se configura con la variable VALKEY_URL (por defecto redis://localhost:6379). Esa variable es la pieza central del Bloque E.
🤖 Prompt · Generar la aplicación
Contexto: API mínima para un laboratorio de Docker. Node.js 24, CommonJS,
Express 5 y la librería oficial "redis" (node-redis v5) para conectarse a Valkey.
Contrato:
- GET / incrementa la clave "visitas" (INCR) y responde JSON:
{ mensaje: "Hola desde lab-valkey", version, host: os.hostname(), visitas }
donde version se lee de package.json.
- GET /api/tareas responde el array de la lista "tareas" (LRANGE 0 -1).
- POST /api/tareas recibe JSON { texto }, lo agrega con RPUSH (máximo 200
caracteres) y responde 201. Si texto falta o está vacío, responde 400.
- GET /health hace PING a Valkey: responde el texto "ok", o 503 si falla.
- Sirve archivos estáticos de public/ bajo /public.
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: app.js, package.json (name "lab-valkey", version "1.0.0", scripts
"start" y "dev" con node --watch) y public/index.html (una página en modo oscuro,
sin recursos externos, con visitas, versión, host, indicador de salud y
formulario de tareas). Sin explicaciones entre los bloques.
Antes de guardar, revisá: ¿declara solo express y redis? ¿Lee VALKEY_URL con su valor por defecto? ¿Tiene el listener on('error')? ¿/health devuelve texto plano ok? Si algo no cumple, devolvéselo al modelo citando el requisito.
Guardá los tres archivos en ~/lab-valkey/app. Si el modelo no cumple el contrato después de un par de intentos, usá el código de referencia:
Código de referencia de la aplicación (v1.0.0)
Copiá este bloque entero en la Terminal 2. Crea los tres archivos de una vez:
cd ~/lab-valkey/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 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('/public', express.static(path.join(__dirname, 'public')));
const requiereValkey = (req, res, next) =>
valkey.isReady ? next() : res.status(503).json({ error: 'Valkey no disponible' });
app.get('/', requiereValkey, async (req, res) => {
const visitas = await valkey.incr('visitas');
res.json({ mensaje: 'Hola desde lab-valkey', version, host: os.hostname(), visitas });
});
app.get('/api/tareas', requiereValkey, async (req, res) => {
res.json(await valkey.lRange('tareas', 0, -1));
});
app.post('/api/tareas', requiereValkey, async (req, res) => {
const texto = String(req.body?.texto ?? '').trim();
if (!texto) return res.status(400).json({ error: 'El campo texto es obligatorio' });
await valkey.rPush('tareas', texto.slice(0, 200));
res.status(201).json({ ok: true });
});
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-valkey",
"version": "1.0.0",
"main": "app.js",
"scripts": {
"start": "node app.js",
"dev": "node --watch 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>lab-valkey · Tareas</title>
<style>
:root {
--bg1: #0b1020; --bg2: #1a1033;
--card: rgba(255, 255, 255, 0.06); --borde: rgba(255, 255, 255, 0.12);
--texto: #e8ecf5; --suave: #9aa4bf;
--fuego: #ff6b35; --fuego2: #ffb347; --ok: #22c55e; --mal: #ef4444;
}
* { box-sizing: border-box; }
body {
margin: 0; min-height: 100vh; display: grid; place-items: center; padding: 24px;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; color: var(--texto);
background:
radial-gradient(circle at 15% 20%, rgba(255, 107, 53, 0.18), transparent 40%),
radial-gradient(circle at 85% 80%, rgba(124, 156, 255, 0.20), transparent 45%),
linear-gradient(135deg, var(--bg1), var(--bg2));
}
.card {
width: 100%; max-width: 580px; padding: 32px;
background: var(--card); border: 1px solid var(--borde); border-radius: 24px;
backdrop-filter: blur(12px); box-shadow: 0 20px 60px rgba(0, 0, 0, 0.45);
}
.cabecera { display: flex; justify-content: space-between; align-items: center; margin-bottom: 22px; }
.marca { font-weight: 700; letter-spacing: .5px; color: var(--suave); font-size: .9rem; }
.salud {
display: inline-flex; align-items: center; gap: 8px; font-size: .85rem;
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); animation: latido 1.8s infinite; }
.punto.mal { background: var(--mal); }
@keyframes latido {
0% { box-shadow: 0 0 0 0 rgba(34, 197, 94, .7); }
70% { box-shadow: 0 0 0 10px rgba(34, 197, 94, 0); }
100% { box-shadow: 0 0 0 0 rgba(34, 197, 94, 0); }
}
.metricas { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin-bottom: 26px; }
.metrica {
padding: 14px; border-radius: 14px; text-align: center;
background: rgba(0, 0, 0, 0.25); border: 1px solid var(--borde);
}
.metrica span { display: block; font-size: .7rem; text-transform: uppercase; letter-spacing: 1px; color: var(--suave); }
.metrica strong {
display: block; margin-top: 6px; font-size: 1rem;
font-family: ui-monospace, "SF Mono", Menlo, monospace; word-break: break-all;
}
.metrica.destacada strong {
font-size: 1.9rem;
background: linear-gradient(90deg, var(--fuego), var(--fuego2));
-webkit-background-clip: text; background-clip: text; color: transparent;
}
h2 { font-size: .95rem; margin: 0 0 12px; color: var(--suave); font-weight: 600; }
form { display: flex; gap: 8px; }
input {
flex: 1; padding: 11px 14px; border-radius: 12px; font-size: .95rem;
border: 1px solid var(--borde); background: rgba(0, 0, 0, 0.3); color: var(--texto);
}
input:focus { outline: 2px solid var(--fuego); outline-offset: 1px; }
button {
cursor: pointer; border: 0; border-radius: 12px; padding: 11px 18px; font-weight: 600;
color: #1a1033; background: linear-gradient(90deg, var(--fuego), var(--fuego2));
transition: transform .15s ease;
}
button:hover { transform: translateY(-2px); }
.error { color: var(--mal); font-size: .85rem; min-height: 1.3em; margin: 8px 0; }
ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; }
li {
padding: 11px 14px; border-radius: 12px;
background: rgba(0, 0, 0, 0.25); border: 1px solid var(--borde);
animation: entrar .3s ease-out;
}
@keyframes entrar { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
.vacio { color: var(--suave); font-style: italic; }
footer { margin-top: 24px; text-align: center; font-size: .8rem; color: var(--suave); }
footer b { color: var(--fuego2); }
@media (max-width: 480px) { .metricas { grid-template-columns: 1fr; } }
</style>
</head>
<body>
<main class="card">
<div class="cabecera">
<span class="marca">lab-valkey</span>
<span class="salud"><span class="punto" id="punto"></span><span id="salud">Chequeando...</span></span>
</div>
<section class="metricas">
<div class="metrica destacada"><span>Visitas</span><strong id="visitas">-</strong></div>
<div class="metrica"><span>Versión</span><strong id="version">-</strong></div>
<div class="metrica"><span>Host</span><strong id="host">-</strong></div>
</section>
<h2>Tareas guardadas en Valkey</h2>
<form id="formulario">
<input id="texto" placeholder="Escribí una tarea..." maxlength="200" autocomplete="off">
<button type="submit">Agregar</button>
</form>
<p class="error" id="error"></p>
<ul id="lista"></ul>
<footer>Hecho por <b>Roxs</b> 🔥 · Node.js + Valkey + Docker</footer>
</main>
<script>
const $ = (id) => document.getElementById(id);
async function cargarInfo() {
try {
const res = await fetch('/');
const data = await res.json();
$('visitas').textContent = data.visitas ?? '-';
$('version').textContent = data.version ?? '-';
$('host').textContent = data.host ?? '-';
} catch {
$('visitas').textContent = '-';
}
}
async function cargarTareas() {
try {
const res = await fetch('/api/tareas');
if (!res.ok) throw new Error();
const tareas = await res.json();
$('lista').innerHTML = '';
if (tareas.length === 0) {
$('lista').innerHTML = '<li class="vacio">Todavía no hay tareas</li>';
return;
}
for (const tarea of tareas) {
const li = document.createElement('li');
li.textContent = tarea;
$('lista').appendChild(li);
}
} catch {
$('lista').innerHTML = '<li class="vacio">No se pudieron cargar las tareas</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';
}
}
$('formulario').addEventListener('submit', async (evento) => {
evento.preventDefault();
const texto = $('texto').value.trim();
if (!texto) return;
const res = await fetch('/api/tareas', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ texto }),
});
$('error').textContent = res.ok ? '' : 'No se pudo guardar la tarea';
if (res.ok) $('texto').value = '';
cargarTareas();
});
cargarInfo();
cargarTareas();
chequearSalud();
setInterval(chequearSalud, 5000);
</script>
</body>
</html>
EOF
ls -R
🔍 Las piezas clave de app.js
| Pieza | Qué hace |
|---|---|
createClient({ url: VALKEY_URL, ... }) | Crea el cliente. redis://host:puerto indica a qué servidor conectarse. |
disableOfflineQueue: true | Si Valkey no está, los comandos fallan rápido en lugar de colgar las peticiones. |
valkey.on('error', ...) | Registra errores de conexión. Sin este listener, un error terminaría el proceso. |
requiereValkey | Middleware que responde 503 si el cliente no está listo. |
Paso 04 · Instalar y probar localmente
En la 🖥️ Terminal 1:
cd ~/lab-valkey/app
npm install
npm start
Deberías ver: API escuchando en 3000 y [valkey] conectado a redis://localhost:6379.
En la 🖥️ Terminal 2:
curl -s localhost:3000/ ; echo
curl -s -X POST localhost:3000/api/tareas \
-H 'Content-Type: application/json' \
-d '{"texto":"Levantar Valkey en un contenedor"}' ; echo
curl -s localhost:3000/api/tareas ; echo
curl -s localhost:3000/health ; echo
Deberías ver: un JSON con visitas, {"ok":true}, la lista con la tarea y ok. Si tu playground permite exponer puertos, publicá el 3000 y abrí /public/ para ver la interfaz.
🔍 Qué hace cada parte
| Parte | Qué hace |
|---|---|
npm install | Descarga las dependencias en node_modules y genera package-lock.json. |
curl -s URL | Pedido HTTP GET; -s oculta la barra de progreso. |
-X POST -H '...' -d '{...}' | Cambia el método a POST, avisa que el cuerpo es JSON y lo envía. |
; echo | Agrega un salto de línea al final de la respuesta. |
Cuando la tarea se complete, pará la app en la 🖥️ Terminal 1 con Ctrl+C.
Bloque D · Contenerizar la aplicación (paso 05)
Paso 05 · Dockerfile y build
cat > .dockerignore << 'EOF'
node_modules
.git
*.log
.env
Dockerfile*
.dockerignore
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
docker build -t lab-valkey-app:1.0.0 .
docker image ls lab-valkey-app
Deberías ver: el build completo y la imagen lab-valkey-app:1.0.0 en el listado.
🔍 Qué hace cada instrucción
| Instrucción | Qué hace |
|---|---|
.dockerignore | Excluye del build las dependencias del host, el historial de Git, los logs y los secretos (.env). |
COPY package*.json + RUN npm ci --omit=dev | Instala dependencias de producción antes de copiar el código, para aprovechar la caché. |
USER node | La app corre sin privilegios de administrador. |
HEALTHCHECK | Cada 10 segundos consulta /health. Usa wget porque la imagen Alpine no trae curl. |
-t lab-valkey-app:1.0.0 | Nombre y versión explícita: la vas a necesitar en el Bloque H. |
No se define VALKEY_URL en la imagen: se pasa al ejecutar, para que la misma imagen sirva en cualquier entorno.
Bloque E · Networking (pasos 06 y 07)
Paso 06 · Por qué la app no encuentra a Valkey
Primer intento: correr la app sin VALKEY_URL, con el valor por defecto localhost:
docker run -d --name app -p 8080:3000 lab-valkey-app:1.0.0
sleep 3
docker logs app | tail -3
Deberías ver: connect ECONNREFUSED 127.0.0.1:6379. Dentro de un contenedor, localhost es el propio contenedor: ahí no hay nada escuchando en el 6379.
Segundo intento: usar el nombre del contenedor de Valkey:
docker rm -f app
docker run -d --name app -p 8080:3000 -e VALKEY_URL=redis://valkey-dev:6379 lab-valkey-app:1.0.0
sleep 3
docker logs app | tail -3
Deberías ver: getaddrinfo ENOTFOUND valkey-dev. Los dos contenedores están en la red bridge por defecto, que conecta por IP pero no resuelve nombres.
| Error | Qué significa |
|---|---|
ENOTFOUND | El nombre no se pudo resolver: problema de DNS o de red. |
ECONNREFUSED | El destino existe, pero nadie escucha en ese puerto. |
Paso 07 · Crear una red propia
Una red definida por el usuario incluye un DNS interno: los contenedores se encuentran por su nombre.
docker rm -f app valkey-dev
docker network create lab-net
docker run -d --name valkey --network lab-net valkey/valkey:8-alpine
docker run -d --name app --network lab-net -p 8080:3000 \
-e VALKEY_URL=redis://valkey:6379 \
lab-valkey-app:1.0.0
sleep 3
curl -s localhost:8080/ ; echo
docker ps --format 'table {{.Names}}\t{{.Ports}}'
Deberías ver: la app respondiendo con visitas en 1 (es un Valkey nuevo) y, en la tabla, que app publica el 8080 mientras valkey solo muestra 6379/tcp, sin mapeo a la máquina.
🔍 Qué hace cada parte
| Parte | Qué hace |
|---|---|
docker network create lab-net | Crea una red bridge con DNS interno. |
--network lab-net | Conecta el contenedor a esa red. |
valkey sin -p | La base de datos no se publica: solo es alcanzable desde dentro de lab-net. Menos superficie expuesta. |
-e VALKEY_URL=redis://valkey:6379 | La app se conecta por nombre de contenedor y puerto interno. |
Bloque F · Persistencia con volúmenes (pasos 08 y 09)
Paso 08 · Comprobar que los datos se pierden
for i in 1 2 3; do curl -s localhost:8080/ > /dev/null; done
docker exec valkey valkey-cli GET visitas
docker rm -f valkey
docker run -d --name valkey --network lab-net valkey/valkey:8-alpine
sleep 3
docker exec valkey valkey-cli GET visitas
Deberías ver: un número de visitas y, después de recrear el contenedor, una respuesta vacía. Los datos vivían en el contenedor borrado. Si ahora la app responde 503, esperá unos segundos: se está reconectando al contenedor nuevo.
Paso 09 · Persistir con un volumen nombrado
docker rm -f valkey
docker volume create valkey-data
docker run -d --name valkey --network lab-net \
-v valkey-data:/data \
valkey/valkey:8-alpine valkey-server --appendonly yes
sleep 3
for i in 1 2 3 4 5; do curl -s localhost:8080/ > /dev/null; done
docker exec valkey valkey-cli GET visitas
Ahora apagalo de forma ordenada, borralo y creá uno nuevo con el mismo volumen:
docker stop valkey && docker rm valkey
docker run -d --name valkey --network lab-net \
-v valkey-data:/data \
valkey/valkey:8-alpine valkey-server --appendonly yes
sleep 3
docker exec valkey valkey-cli GET visitas
Deberías ver: el mismo valor de visitas antes y después. El contenedor es nuevo; los datos no.
🔍 Qué hace cada parte
| Parte | Qué hace |
|---|---|
docker volume create valkey-data | Crea un volumen nombrado: almacenamiento administrado por Docker, independiente de los contenedores. |
-v valkey-data:/data | Lo monta en /data, la carpeta donde Valkey guarda sus archivos. |
valkey-server --appendonly yes | Activa la persistencia AOF: Valkey registra cada escritura en disco. |
docker stop + docker rm | Apaga ordenadamente antes de borrar. Con rm -f, el corte abrupto podría perder la última escritura. |
Bloque G · Desarrollo con bind mounts (pasos 10 y 11)
Paso 10 · Levantar un contenedor de desarrollo
Un bind mount monta una carpeta de tu máquina dentro del contenedor: editás en el host y el contenedor ve el cambio al instante.
cd ~/lab-valkey/app
docker run -d --name app-dev --network lab-net -p 3001:3000 \
-e VALKEY_URL=redis://valkey:6379 \
-v "$(pwd)":/app \
-v /app/node_modules \
lab-valkey-app:1.0.0 node --watch app.js
sleep 3
curl -s localhost:3001/ ; echo
Deberías ver: la app respondiendo, con el contador de visitas compartido con el 8080: las dos usan el mismo Valkey.
🔍 Qué hace cada parte
| Parte | Qué hace |
|---|---|
-v "$(pwd)":/app | Bind mount: tu carpeta actual reemplaza al código de la imagen en /app. |
-v /app/node_modules | Volumen anónimo que protege las dependencias de la imagen, para que el bind mount no las tape. |
node --watch app.js | Reemplaza el CMD: reinicia la app automáticamente cuando cambia el código. |
Paso 11 · Implementar la versión 2 en vivo
En la 🖥️ Terminal 1, seguí los logs de desarrollo:
docker logs -f app-dev
En la 🖥️ Terminal 2, agregá una ruta GET /api/stats que responda visitas (sin incrementarlas), cantidad de tareas, 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), tareas (LLEN de
"tareas"), 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 la versión 2
sed -i '/^app.listen/d' app.js
cat >> app.js << 'EOF'
app.get('/api/stats', requiereValkey, async (req, res) => {
const [visitas, tareas] = await Promise.all([
valkey.get('visitas'),
valkey.lLen('tareas'),
]);
res.json({ visitas: Number(visitas ?? 0), tareas, 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.
Deberías ver, en la Terminal 1, que node --watch reinicia la app sola. Salí de los logs con Ctrl+C y probá:
curl -s localhost:3001/api/stats ; echo
curl -s -w ' [HTTP %{http_code}]\n' localhost:8080/api/stats
Deberías ver: las estadísticas en el 3001 y un 404 en el 8080. El contenedor de producción no cambia porque su código vive dentro de la imagen, que es inmutable.
app (producción) | app-dev (desarrollo) | |
|---|---|---|
| Origen del código | Copiado en la imagen al construir | Bind mount de la carpeta del host |
| ¿Ve cambios en caliente? | No: hay que reconstruir y redesplegar | Sí |
| Uso adecuado | Entornos compartidos y producción | Tu máquina, mientras desarrollás |
Bloque H · Reconstruir y redesplegar (paso 12)
Paso 12 · Versionar, reconstruir y reemplazar
npm version 2.0.0 --no-git-tag-version
docker build -t lab-valkey-app:2.0.0 .
docker rm -f app
docker run -d --name app --network lab-net -p 8080:3000 \
-e VALKEY_URL=redis://valkey:6379 \
lab-valkey-app:2.0.0
sleep 3
curl -s localhost:8080/api/stats ; echo
Deberías ver: "version":"2.0.0" con las mismas visitas y tareas de antes. Reemplazaste por completo el contenedor de la aplicación sin perder nada, porque la app no guarda estado: todo vive en Valkey, y Valkey lo guarda en un volumen.
🔍 Qué hace cada parte
| Parte | 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. |
docker build -t lab-valkey-app:2.0.0 . | Construye la nueva versión. npm ci no sale de la caché porque cambió package.json. |
docker rm -f app + docker run ... :2.0.0 | Reemplaza el contenedor. La 1.0.0 sigue disponible: volver atrás es correr esa imagen. |
Si algo falla
| Síntoma | Qué hacer |
|---|---|
ECONNREFUSED 127.0.0.1:6379 | La app usa localhost dentro de un contenedor: pasá -e VALKEY_URL=redis://valkey:6379. |
ENOTFOUND valkey | La app no está en lab-net: revisá --network lab-net. |
/health responde 503 tras recrear Valkey | La app se está reconectando: esperá unos segundos o docker restart app. |
app-dev no se reinicia al editar | Revisá los montajes con docker inspect app-dev --format '{{json .Mounts}}'. |
port is already allocated o nombre en uso | docker ps para ver qué lo ocupa, y docker rm -f <nombre>. |
node: command not found en una terminal nueva | source ~/.nvm/nvm.sh o abrí otra terminal. |
Limpieza
docker rm -f app app-dev
docker stop valkey && docker rm valkey
docker network rm lab-net
docker volume rm valkey-data
docker volume prune -f
docker volume prune -f borra también los volúmenes anónimos que quedaron sin uso.
Cierre
- Generé una aplicación con IA a partir de un contrato y la validé.
- Sé por qué
localhostno conecta contenedores y cómo lo resuelve una red propia. - Persistí datos con un volumen nombrado.
- Desarrollé con un bind mount, sin reconstruir la imagen.
- Versioné, reconstruí y redesplegué sin perder datos.
La idea que atraviesa el laboratorio: el código viaja en imágenes inmutables, la configuración entra por variables de entorno y el estado vive en volúmenes. Con esa separación, los contenedores de aplicación se vuelven descartables.
¿Qué sigue? Todo lo que levantaste a mano, la red, el volumen y los contenedores con sus variables, se puede describir en un único archivo con Docker Compose.
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.