Tutorial

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.

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.

Contenedores conectados

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

ParteQué hace
-d --name valkey-devCorre el contenedor en segundo plano, con un nombre fijo.
-p 6379:6379Publica 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 / LRANGEVerificar 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:

RutaComportamiento
GET /Incrementa visitas en Valkey y responde JSON con mensaje, version (de package.json), host y visitas.
GET /api/tareasResponde el array de la lista tareas.
POST /api/tareasRecibe { "texto": "..." }, lo agrega a tareas y responde 201; sin texto, 400.
GET /healthok 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

PiezaQué hace
createClient({ url: VALKEY_URL, ... })Crea el cliente. redis://host:puerto indica a qué servidor conectarse.
disableOfflineQueue: trueSi 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.
requiereValkeyMiddleware 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

ParteQué hace
npm installDescarga las dependencias en node_modules y genera package-lock.json.
curl -s URLPedido 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.
; echoAgrega 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ónQué hace
.dockerignoreExcluye del build las dependencias del host, el historial de Git, los logs y los secretos (.env).
COPY package*.json + RUN npm ci --omit=devInstala dependencias de producción antes de copiar el código, para aprovechar la caché.
USER nodeLa app corre sin privilegios de administrador.
HEALTHCHECKCada 10 segundos consulta /health. Usa wget porque la imagen Alpine no trae curl.
-t lab-valkey-app:1.0.0Nombre 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.

ErrorQué significa
ENOTFOUNDEl nombre no se pudo resolver: problema de DNS o de red.
ECONNREFUSEDEl 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

ParteQué hace
docker network create lab-netCrea una red bridge con DNS interno.
--network lab-netConecta el contenedor a esa red.
valkey sin -pLa base de datos no se publica: solo es alcanzable desde dentro de lab-net. Menos superficie expuesta.
-e VALKEY_URL=redis://valkey:6379La 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

ParteQué hace
docker volume create valkey-dataCrea un volumen nombrado: almacenamiento administrado por Docker, independiente de los contenedores.
-v valkey-data:/dataLo monta en /data, la carpeta donde Valkey guarda sus archivos.
valkey-server --appendonly yesActiva la persistencia AOF: Valkey registra cada escritura en disco.
docker stop + docker rmApaga 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

ParteQué hace
-v "$(pwd)":/appBind mount: tu carpeta actual reemplaza al código de la imagen en /app.
-v /app/node_modulesVolumen anónimo que protege las dependencias de la imagen, para que el bind mount no las tape.
node --watch app.jsReemplaza 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ódigoCopiado en la imagen al construirBind mount de la carpeta del host
¿Ve cambios en caliente?No: hay que reconstruir y redesplegarSí
Uso adecuadoEntornos compartidos y producciónTu 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

ParteQué 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.
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.0Reemplaza el contenedor. La 1.0.0 sigue disponible: volver atrás es correr esa imagen.

Si algo falla

SíntomaQué hacer
ECONNREFUSED 127.0.0.1:6379La app usa localhost dentro de un contenedor: pasá -e VALKEY_URL=redis://valkey:6379.
ENOTFOUND valkeyLa app no está en lab-net: revisá --network lab-net.
/health responde 503 tras recrear ValkeyLa app se está reconectando: esperá unos segundos o docker restart app.
app-dev no se reinicia al editarRevisá los montajes con docker inspect app-dev --format '{{json .Mounts}}'.
port is already allocated o nombre en usodocker ps para ver qué lo ocupa, y docker rm -f <nombre>.
node: command not found en una terminal nuevasource ~/.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é localhost no 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

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