Tutorial

De Node.js a Docker Hub: construí, publicá y ejecutá tu imagen

Con tu copiloto de IA, construí una aplicación de clima en Node.js que consume Open-Meteo y su Dockerfile; publicá la imagen en un registry local y en Docker Hub, y levantá dos contenedores de la misma imagen en puertos distintos para comparar sus logs.

Construí una aplicación de clima en Node.js y su Dockerfile con tu copiloto de IA, publicá la imagen en un registry local y en Docker Hub, y levantá dos contenedores de la misma imagen para comparar sus logs.

La aplicación de clima

Objetivo del laboratorio

Vas a recorrer el ciclo completo de una imagen: construirla, publicarla y ejecutarla. La aplicación es un buscador de clima que consume Open-Meteo, una API gratuita que no necesita registro ni API key. El código lo escribe tu copiloto; vos lo especificás, lo revisás y lo llevás a contenedores.

Construir, publicar y ejecutar

Al terminar vas a poder:

  • Generar una aplicación y su Dockerfile a partir de un contrato, y validarlos antes de construir.
  • Publicar una imagen en un registry local y en Docker Hub, con versiones explícitas.
  • Levantar varios contenedores de una misma imagen, configurados distinto por variables de entorno.
  • Leer, seguir y filtrar logs con docker logs.

Antes de empezar

Todos los comandos se ejecutan en docker-01, como el usuario laborant. Tres herramientas del playground te van a acompañar:

  • Terminales. Abrí una segunda pestaña con el botón +: una para procesos y logs en vivo, otra para los comandos.
  • IDE. La pestaña IDE es un editor en el navegador: ahí guardás el código que genera tu copiloto.
  • Exponer puertos. Para abrir la app en tu navegador: menú ⋮ → Expose HTTP port → el puerto → Expose, y abrí la URL que aparece.

La máquina tiene Node 24 para que pruebes la app antes de contenerizarla. Si node no aparece, esperá unos segundos y abrí una terminal nueva.

Tu copiloto DevOps

En este laboratorio la IA trabaja como un copiloto: escribe el código y el Dockerfile, 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 claro, revisar lo que genera y probar con un comando. Nunca pegues secretos en un prompt, y menos tu token de Docker Hub.

Paso 1 · Preparar el proyecto

mkdir -p ~/clima-app/public
cd ~/clima-app
node --version

Corré también cd ~/clima-app en la otra terminal.

Paso 2 · Generar la aplicación

Open-Meteo se consulta en dos pasos: primero se busca la ciudad para obtener sus coordenadas, y después se pide el clima de esas coordenadas. El prompt le explica ese recorrido a tu copiloto, junto con el contrato de la aplicación.

🤖 Prompt · Generar la aplicación
Contexto: aplicación web de clima para un laboratorio de Docker. Node.js 24,
CommonJS y Express 5. Usa la API gratuita de Open-Meteo, que no necesita API key.

Cómo consultar Open-Meteo:
1. Buscar la ciudad: GET https://geocoding-api.open-meteo.com/v1/search
   ?name=<ciudad>&count=1&language=es&format=json → results[0] con name,
   admin1, country, latitude y longitude. Sin resultados, la ciudad no existe.
2. Pedir el clima: GET https://api.open-meteo.com/v1/forecast con latitude,
   longitude, current=temperature_2m,apparent_temperature,relative_humidity_2m,
   wind_speed_10m,weather_code, daily=weather_code,temperature_2m_max,
   temperature_2m_min,precipitation_probability_max, timezone=auto y forecast_days=7.

Contrato:
- GET /health → el texto "ok".
- GET /api/info → { instancia, host, version }. instancia sale de la variable
  INSTANCIA (por defecto, os.hostname()); version, de package.json.
- GET /api/clima?ciudad=<nombre> → { ciudad, region, pais, latitud, longitud,
  actual: { temperatura, sensacion, humedad, viento, codigo, descripcion, icono },
  pronostico: [7 elementos { fecha, maxima, minima, lluvia, codigo, descripcion,
  icono }], instancia }. Traducí weather_code (códigos WMO) a una descripción en
  español y un emoji.
  Sin ciudad o con más de 60 caracteres: 400. Ciudad inexistente: 404. Si
  Open-Meteo falla o tarda más de 5 segundos: 502. Los errores, siempre como { error }.
- GET / sirve public/index.html.

Caché y logs:
- Caché en memoria por ciudad (sin distinguir mayúsculas) durante 10 minutos.
- Un log por pedido en la salida estándar, con este formato:
  [<instancia>] <MÉTODO> <ruta> → <estado> en <ms> ms (cache: hit|miss)
  La parte de la caché va solo en /api/clima.
- Al arrancar: [<instancia>] Escuchando en el puerto <puerto> · versión <versión>
- Al recibir SIGTERM o SIGINT: loguear [<instancia>] Recibí <señal>, cerrando,
  cerrar el servidor y terminar el proceso.

Frontend (public/index.html, con CSS y JS embebidos, sin librerías externas):
buscador de ciudad, tarjeta del clima actual con emoji grande, temperatura,
sensación, humedad y viento, pronóstico de 7 días y un indicador
"Atendido por <instancia>". Modo oscuro. Mostrá los textos de la API con
textContent. Crédito visible: "Datos del clima de Open-Meteo.com".

Restricciones: solo express como dependencia. Puerto: variable PORT, por defecto 3000.

Salida: clima-app/package.json, clima-app/clima.js (Open-Meteo, códigos y caché),
clima-app/server.js (rutas, logs y arranque) y clima-app/public/index.html.
Formato: un bloque de código por archivo, con la ruta como título justo antes
del bloque (por ejemplo: ### clima-app/server.js). Nunca pongas la ruta como
comentario dentro del archivo.

Guardá los archivos en ~/clima-app desde el IDE. Antes de correrla, validá lo que generó tu copiloto e instalá las dependencias:

cd ~/clima-app
node -e "require('./package.json')" && echo "package.json válido"
node --check clima.js && node --check server.js && echo "sintaxis correcta"
npm install

En la Terminal 1, arrancala:

cd ~/clima-app && npm start

Deberías ver: [<nombre de la máquina>] Escuchando en el puerto 3000 · versión 1.0.0.

En la Terminal 2, probá el contrato:

curl -s localhost:3000/health ; echo
curl -s "localhost:3000/api/clima?ciudad=Madrid" | head -c 300 ; echo
curl -s -w ' [HTTP %{http_code}]\n' "localhost:3000/api/clima?ciudad=CiudadQueNoExiste"

Deberías ver: ok, el comienzo del JSON con el clima de Madrid y un 404 con su mensaje de error. En la Terminal 1 aparece un log por cada pedido: el de Madrid dice cache: miss. Si lo repetís, cache: hit.

Cuando la tarea se complete, pará la app en la Terminal 1 con Ctrl+C. Fijate que el log dice Recibí SIGINT, cerrando: el cierre ordenado funciona.

Paso 3 · Generar el Dockerfile

🤖 Prompt · Generar el Dockerfile
Escribí un Dockerfile y un .dockerignore para esta aplicación Node.js 24 con
Express (archivos: package.json, package-lock.json, clima.js, server.js, public/).

Criterios:
- Imagen base node:24-alpine.
- Dependencias antes que el código, para aprovechar la caché de capas,
  instaladas con npm ci --omit=dev.
- Ejecutar como el usuario node, no como root.
- EXPOSE 3000 y HEALTHCHECK contra /health con wget a http://127.0.0.1:3000
  (la imagen no trae curl, y localhost puede resolver primero a IPv6).
- CMD en formato exec y sin npm start, para que node reciba SIGTERM directamente.
- .dockerignore que excluya node_modules, .env, .git y los archivos de Docker.
Debajo, explicá cada instrucción en una línea.

Formato: un bloque de código por archivo, con la ruta como título justo antes
del bloque (por ejemplo: ### clima-app/Dockerfile). Nunca pongas la ruta como
comentario dentro del archivo.

Leé la explicación antes de usarlo. El punto más fino es el CMD: con npm start o en formato shell, la señal de docker stop le llega a npm o a sh y no a node, el cierre ordenado no se ejecuta y Docker termina matando el proceso a los 10 segundos.

Construí la imagen y probala:

cd ~/clima-app
docker build -t clima-app:1.0.0 .
docker image ls clima-app
docker inspect clima-app:1.0.0 --format 'Usuario: {{.Config.User}}'
docker run -d --name prueba -p 3001:3000 clima-app:1.0.0
sleep 3
curl -s localhost:3001/api/info ; echo
docker rm -f prueba

Deberías ver: la imagen con su tamaño, Usuario: node y la info de la instancia. Como no pasaste INSTANCIA, usa el nombre del contenedor: un id tipo hash.

Paso 4 · Publicar en un registry local

Un registry es un servidor que guarda imágenes. Docker Hub es uno público, pero podés levantar el tuyo: es un contenedor más.

docker run -d --name registry -p 5000:5000 registry:2
docker tag clima-app:1.0.0 localhost:5000/clima-app:1.0.0
docker push localhost:5000/clima-app:1.0.0
curl -s localhost:5000/v2/_catalog ; echo
curl -s localhost:5000/v2/clima-app/tags/list ; echo

Deberías ver: las capas subiendo, el digest sha256:... al final, {"repositories":["clima-app"]} y {"name":"clima-app","tags":["1.0.0"]}.

El nombre de una imagen dice dónde vive:

localhost:5000 / clima-app : 1.0.0
└── registry ─┘ └─nombre─┘ └─tag─┘

docker tag no copia nada: le pone un segundo nombre a la misma imagen. Sin registry en el nombre, Docker asume Docker Hub.

Paso 5 · Publicar en Docker Hub

Este paso necesita una cuenta de Docker Hub. Si no tenés una, podés saltearlo: el resto del laboratorio usa el registry local.

Primero, creá un token de acceso en lugar de usar tu contraseña: en Docker Hub, Account settings → Personal access tokens → Generate new token, con permiso Read & Write. Un token se puede revocar sin cambiar la contraseña, y si se filtra, el daño queda acotado.

export DOCKERHUB_USER=<tu-usuario>
docker login -u "$DOCKERHUB_USER"

Cuando pida la contraseña, pegá el token. Después, etiquetá y publicá:

docker tag clima-app:1.0.0 "$DOCKERHUB_USER/clima-app:1.0.0"
docker tag clima-app:1.0.0 "$DOCKERHUB_USER/clima-app:latest"
docker push "$DOCKERHUB_USER/clima-app:1.0.0"
docker push "$DOCKERHUB_USER/clima-app:latest"

Deberías ver: el primer push sube las capas; el segundo dice Layer already exists en todas, porque es la misma imagen con otro tag. Abrí https://hub.docker.com/r/<tu-usuario>/clima-app y vas a ver los dos tags.

Cerrá la sesión al terminar:

docker logout
Important

Dos cuidados con Docker Hub. Los repositorios se crean públicos por defecto: cualquiera puede descargar tu imagen, así que nunca incluyas secretos ni archivos .env en ella. Y docker login guarda la credencial en ~/.docker/config.json sin cifrar: en una máquina compartida como esta, docker logout no es opcional.

Paso 6 · Dos instancias de la misma imagen

Una imagen es una plantilla: de ella podés crear todos los contenedores que quieras, cada uno con su propia configuración. Para comprobar que la imagen del registry es autosuficiente, primero borrá las copias locales:

docker rmi clima-app:1.0.0 localhost:5000/clima-app:1.0.0
docker run -d --name clima-1 -p 8081:3000 -e INSTANCIA=clima-1 localhost:5000/clima-app:1.0.0
docker run -d --name clima-2 -p 8082:3000 -e INSTANCIA=clima-2 localhost:5000/clima-app:1.0.0
docker ps --format 'table {{.Names}}\t{{.Image}}\t{{.Ports}}\t{{.Status}}'

Deberías ver: Unable to find image ... locally y la descarga desde localhost:5000 en el primer docker run; el segundo ya la encuentra. Después, los dos contenedores con la misma imagen y puertos distintos, (healthy) a los pocos segundos.

curl -s localhost:8081/api/info ; echo
curl -s localhost:8082/api/info ; echo

Deberías ver: clima-1 y clima-2, con hosts distintos. Misma imagen, misma versión, distinta configuración.

Exponé los puertos 8081 y 8082 y abrí cada URL en una pestaña. Buscá la misma ciudad en las dos: el indicador Atendido por muestra qué contenedor respondió.

Si publicaste en Docker Hub, podés levantar las instancias desde ahí: reemplazá localhost:5000/clima-app:1.0.0 por <tu-usuario>/clima-app:1.0.0.

Paso 7 · Ver los logs

Docker guarda todo lo que un contenedor escribe en la salida estándar. Por eso la app loguea con console.log y no en un archivo: docker logs lo muestra sin entrar al contenedor.

En la Terminal 1, seguí los logs de clima-1 en vivo:

docker logs -f clima-1

En la Terminal 2, hacé pedidos a las dos instancias:

curl -s "localhost:8081/api/clima?ciudad=Lima" >/dev/null
curl -s "localhost:8081/api/clima?ciudad=Lima" >/dev/null
curl -s "localhost:8082/api/clima?ciudad=Lima" >/dev/null
docker logs clima-2

Deberías ver: en la Terminal 1, el primer pedido a clima-1 con cache: miss y el segundo con cache: hit. En los logs de clima-2, cache: miss: aunque sea la misma imagen, cada contenedor tiene su propia memoria. Si querés una caché compartida, tiene que vivir afuera, por ejemplo en Valkey.

Probá las opciones más útiles de docker logs:

docker logs --tail 3 clima-1
docker logs -t --tail 3 clima-1
docker logs --since 2m clima-2
docker logs clima-1 2>&1 | grep 'cache: miss'
OpciónQué hace
-fSigue los logs en vivo. Ctrl+C deja de seguirlos sin detener el contenedor.
--tail NMuestra solo las últimas N líneas.
-tAgrega la fecha y hora en que Docker recibió cada línea.
--since 2mSolo lo de los últimos 2 minutos. También acepta fechas.
2>&1 | grepUne la salida de error con la estándar y filtra.

Por último, detené clima-2 y mirá sus logs:

docker stop clima-2
docker logs --tail 2 clima-2

Deberías ver: [clima-2] Recibí SIGTERM, cerrando, y que docker stop terminó en menos de un segundo. Los logs siguen disponibles con el contenedor detenido: se borran recién con docker rm.

En la Terminal 1, salí de los logs con Ctrl+C.

Si te trabás: código de referencia

Si tu copiloto no logra cumplir el contrato después de un par de intentos, usá esta implementación. Copiá el bloque entero en la terminal: crea todos los archivos en ~/clima-app.

Código de referencia completo
mkdir -p ~/clima-app/public && cd ~/clima-app

cat > package.json << 'EOF'
{
  "name": "clima-app",
  "version": "1.0.0",
  "private": true,
  "description": "Clima actual y pronóstico con Open-Meteo",
  "main": "server.js",
  "scripts": {
    "start": "node server.js"
  },
  "dependencies": {
    "express": "^5.1.0"
  }
}
EOF

cat > clima.js << 'EOF'
// Consulta a Open-Meteo: primero busca la ciudad (geocoding) y después
// pide el clima actual y el pronóstico. No necesita API key.
const GEOCODING_URL = 'https://geocoding-api.open-meteo.com/v1/search';
const FORECAST_URL = 'https://api.open-meteo.com/v1/forecast';
const TTL_MS = 10 * 60 * 1000; // 10 minutos

// Caché en memoria: cada contenedor tiene la suya
const cache = new Map();

const CODIGOS = {
  0: ['Despejado', '☀️'], 1: ['Mayormente despejado', '🌤️'], 2: ['Parcialmente nublado', '⛅'],
  3: ['Nublado', '☁️'], 45: ['Niebla', '🌫️'], 48: ['Niebla con escarcha', '🌫️'],
  51: ['Llovizna leve', '🌦️'], 53: ['Llovizna', '🌦️'], 55: ['Llovizna intensa', '🌧️'],
  56: ['Llovizna helada', '🌧️'], 57: ['Llovizna helada intensa', '🌧️'],
  61: ['Lluvia leve', '🌦️'], 63: ['Lluvia', '🌧️'], 65: ['Lluvia intensa', '🌧️'],
  66: ['Lluvia helada', '🌧️'], 67: ['Lluvia helada intensa', '🌧️'],
  71: ['Nevada leve', '🌨️'], 73: ['Nevada', '🌨️'], 75: ['Nevada intensa', '❄️'], 77: ['Granos de nieve', '🌨️'],
  80: ['Chubascos leves', '🌦️'], 81: ['Chubascos', '🌧️'], 82: ['Chubascos fuertes', '⛈️'],
  85: ['Chubascos de nieve', '🌨️'], 86: ['Chubascos de nieve fuertes', '❄️'],
  95: ['Tormenta', '⛈️'], 96: ['Tormenta con granizo', '⛈️'], 99: ['Tormenta con granizo fuerte', '⛈️'],
};

function describir(codigo) {
  const [descripcion, icono] = CODIGOS[codigo] || ['Sin datos', '❔'];
  return { codigo, descripcion, icono };
}

class ErrorClima extends Error {
  constructor(status, mensaje) {
    super(mensaje);
    this.status = status;
  }
}

async function pedirJson(url) {
  let res;
  try {
    res = await fetch(url, { signal: AbortSignal.timeout(5000) });
  } catch {
    throw new ErrorClima(502, 'No se pudo consultar Open-Meteo');
  }
  if (!res.ok) throw new ErrorClima(502, `Open-Meteo respondió ${res.status}`);
  return res.json();
}

async function buscarCiudad(nombre) {
  const url = `${GEOCODING_URL}?name=${encodeURIComponent(nombre)}&count=1&language=es&format=json`;
  const datos = await pedirJson(url);
  const lugar = datos.results?.[0];
  if (!lugar) throw new ErrorClima(404, `No encontré la ciudad "${nombre}"`);
  return lugar;
}

async function obtenerClima(nombre) {
  const clave = nombre.trim().toLowerCase();
  const guardado = cache.get(clave);
  if (guardado && guardado.vence > Date.now()) return { datos: guardado.datos, cache: 'hit' };

  const lugar = await buscarCiudad(nombre);
  const parametros = new URLSearchParams({
    latitude: lugar.latitude,
    longitude: lugar.longitude,
    current: 'temperature_2m,apparent_temperature,relative_humidity_2m,wind_speed_10m,weather_code',
    daily: 'weather_code,temperature_2m_max,temperature_2m_min,precipitation_probability_max',
    timezone: 'auto',
    forecast_days: '7',
  });
  const c = await pedirJson(`${FORECAST_URL}?${parametros}`);

  const datos = {
    ciudad: lugar.name,
    region: lugar.admin1 || null,
    pais: lugar.country || null,
    latitud: lugar.latitude,
    longitud: lugar.longitude,
    actual: {
      temperatura: c.current.temperature_2m,
      sensacion: c.current.apparent_temperature,
      humedad: c.current.relative_humidity_2m,
      viento: c.current.wind_speed_10m,
      ...describir(c.current.weather_code),
    },
    pronostico: c.daily.time.map((fecha, i) => ({
      fecha,
      maxima: c.daily.temperature_2m_max[i],
      minima: c.daily.temperature_2m_min[i],
      lluvia: c.daily.precipitation_probability_max?.[i] ?? null,
      ...describir(c.daily.weather_code[i]),
    })),
  };

  cache.set(clave, { datos, vence: Date.now() + TTL_MS });
  return { datos, cache: 'miss' };
}

module.exports = { obtenerClima, describir, ErrorClima };
EOF

cat > server.js << 'EOF'
// Servidor HTTP: sirve la página, expone la API y escribe un log por pedido.
const express = require('express');
const os = require('os');
const path = require('path');
const { obtenerClima, ErrorClima } = require('./clima');
const { version } = require('./package.json');

const PORT = process.env.PORT || 3000;
const INSTANCIA = process.env.INSTANCIA || os.hostname();

const log = (mensaje) => console.log(`[${INSTANCIA}] ${mensaje}`);

const app = express();
app.disable('x-powered-by');

// Un log por pedido: método, ruta, estado, duración y si salió de la caché
app.use((req, res, next) => {
  const inicio = Date.now();
  res.on('finish', () => {
    const cache = res.locals.cache ? ` (cache: ${res.locals.cache})` : '';
    log(`${req.method} ${req.originalUrl} → ${res.statusCode} en ${Date.now() - inicio} ms${cache}`);
  });
  next();
});

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

app.get('/health', (req, res) => res.send('ok'));

app.get('/api/info', (req, res) => {
  res.json({ instancia: INSTANCIA, host: os.hostname(), version });
});

app.get('/api/clima', async (req, res) => {
  const ciudad = String(req.query.ciudad ?? '').trim();
  if (!ciudad || ciudad.length > 60) {
    return res.status(400).json({ error: 'Indicá una ciudad de hasta 60 caracteres' });
  }
  const { datos, cache } = await obtenerClima(ciudad);
  res.locals.cache = cache;
  res.json({ ...datos, instancia: INSTANCIA });
});

app.use((err, req, res, next) => {
  if (err instanceof ErrorClima) return res.status(err.status).json({ error: err.message });
  log(`Error inesperado: ${err.message}`);
  res.status(500).json({ error: 'Error interno' });
});

const servidor = app.listen(PORT, () => log(`Escuchando en el puerto ${PORT} · versión ${version}`));

// docker stop envía SIGTERM: se cierra ordenadamente y queda registrado en los logs
for (const senal of ['SIGTERM', 'SIGINT']) {
  process.on(senal, () => {
    log(`Recibí ${senal}, cerrando`);
    servidor.close(() => process.exit(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>Clima · Open-Meteo</title>
  <style>
    :root {
      --texto: #eef2ff; --suave: #9fb0d0; --borde: rgba(255, 255, 255, .14);
      --tarjeta: rgba(255, 255, 255, .07); --acento: #38bdf8; --acento-2: #818cf8; --error: #fca5a5;
    }
    * { box-sizing: border-box; }
    html { background: #0b1226; }
    body {
      margin: 0; min-height: 100vh; display: flex; flex-direction: column;
      font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; color: var(--texto);
      background:
        radial-gradient(circle at 15% 0%, rgba(56, 189, 248, .22), transparent 40%),
        radial-gradient(circle at 90% 100%, rgba(129, 140, 248, .25), transparent 45%),
        linear-gradient(160deg, #0b1226, #172554);
      background-attachment: fixed;
    }
    main { flex: 1; width: 100%; max-width: 900px; margin: 0 auto; padding: 36px 20px; }
    h1 { margin: 0 0 6px; font-size: clamp(1.8rem, 4vw, 2.6rem); }
    h1 span { color: var(--acento); }
    .bajada { margin: 0 0 24px; color: var(--suave); }
    form { display: flex; gap: 10px; flex-wrap: wrap; }
    input {
      flex: 1; min-width: 220px; padding: 13px 16px; border-radius: 14px; font: inherit;
      border: 1px solid var(--borde); background: rgba(0, 0, 0, .3); color: var(--texto);
    }
    input:focus { outline: 2px solid var(--acento); outline-offset: 2px; }
    button {
      cursor: pointer; border: 0; border-radius: 14px; padding: 13px 24px; font: inherit; font-weight: 700;
      color: #0b1226; background: linear-gradient(90deg, var(--acento), var(--acento-2));
    }
    button:disabled { opacity: .6; cursor: wait; }
    .error { color: var(--error); min-height: 1.4em; margin: 12px 0 0; }
    .actual {
      display: grid; grid-template-columns: auto 1fr; gap: 24px; align-items: center;
      margin-top: 20px; padding: 28px; border-radius: 24px; background: var(--tarjeta); border: 1px solid var(--borde);
    }
    .icono { font-size: 5rem; line-height: 1; }
    .lugar { margin: 0; font-size: 1.4rem; font-weight: 700; }
    .region { margin: 2px 0 10px; color: var(--suave); font-size: .9rem; }
    .temperatura { font-size: 3.2rem; font-weight: 800; line-height: 1; }
    .descripcion { margin: 6px 0 0; color: var(--suave); }
    .detalles { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin-top: 16px; }
    .detalle { padding: 12px; border-radius: 14px; background: rgba(0, 0, 0, .25); text-align: center; }
    .detalle span { display: block; font-size: .7rem; letter-spacing: 1px; text-transform: uppercase; color: var(--suave); }
    .detalle strong { display: block; margin-top: 4px; font-size: 1.1rem; }
    .pronostico { display: grid; grid-template-columns: repeat(7, 1fr); gap: 10px; margin-top: 18px; }
    .dia { padding: 14px 8px; border-radius: 16px; text-align: center; background: var(--tarjeta); border: 1px solid var(--borde); }
    .dia .nombre { font-size: .8rem; color: var(--suave); text-transform: capitalize; }
    .dia .emoji { font-size: 1.8rem; margin: 6px 0; }
    .dia .max { font-weight: 700; }
    .dia .min { color: var(--suave); font-size: .9rem; }
    .dia .lluvia { margin-top: 4px; font-size: .75rem; color: var(--acento); }
    [hidden] { display: none !important; }
    .instancia {
      display: inline-flex; align-items: center; gap: 8px; margin-top: 22px; padding: 8px 14px;
      border-radius: 999px; border: 1px solid var(--borde); font-size: .85rem; color: var(--suave);
    }
    .instancia b { color: var(--texto); font-family: ui-monospace, Menlo, monospace; }
    .punto { width: 9px; height: 9px; border-radius: 50%; background: #22c55e; box-shadow: 0 0 10px #22c55e; }
    footer { text-align: center; padding: 18px; font-size: .8rem; color: var(--suave); border-top: 1px solid var(--borde); }
    footer a { color: var(--texto); }
    footer b { color: var(--acento); }
    @media (max-width: 720px) {
      .pronostico { grid-template-columns: repeat(4, 1fr); }
      .actual { grid-template-columns: 1fr; text-align: center; }
      .detalles { grid-template-columns: 1fr; }
    }
  </style>
</head>
<body>
  <main>
    <h1>El clima, <span>en contenedores</span></h1>
    <p class="bajada">Buscá una ciudad para ver el clima actual y el pronóstico de 7 días.</p>

    <form id="formulario">
      <input id="ciudad" value="Buenos Aires" maxlength="60" placeholder="Ciudad" autocomplete="off" required>
      <button id="buscar" type="submit">Buscar</button>
    </form>
    <p class="error" id="error" role="alert"></p>

    <section class="actual" id="actual" hidden>
      <div class="icono" id="icono"></div>
      <div>
        <p class="lugar" id="lugar"></p>
        <p class="region" id="region"></p>
        <div class="temperatura" id="temperatura"></div>
        <p class="descripcion" id="descripcion"></p>
        <div class="detalles">
          <div class="detalle"><span>Sensación</span><strong id="sensacion"></strong></div>
          <div class="detalle"><span>Humedad</span><strong id="humedad"></strong></div>
          <div class="detalle"><span>Viento</span><strong id="viento"></strong></div>
        </div>
      </div>
    </section>

    <section class="pronostico" id="pronostico"></section>

    <div class="instancia" title="El contenedor que atendió este pedido">
      <span class="punto"></span>Atendido por <b id="instancia">-</b>
    </div>
  </main>

  <footer>
    Datos del clima de <a href="https://open-meteo.com" target="_blank" rel="noopener">Open-Meteo.com</a>
    · Hecho por <b>Roxs</b> 🔥
  </footer>

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

    function crear(etiqueta, clase, texto) {
      const el = document.createElement(etiqueta);
      el.className = clase;
      el.textContent = texto;
      return el;
    }

    function nombreDelDia(fecha) {
      return new Date(`${fecha}T12:00:00`).toLocaleDateString('es', { weekday: 'short' });
    }

    async function buscar(ciudad) {
      $('error').textContent = '';
      $('buscar').disabled = true;
      try {
        const res = await fetch(`/api/clima?ciudad=${encodeURIComponent(ciudad)}`);
        const datos = await res.json();
        if (!res.ok) throw new Error(datos.error || `Error ${res.status}`);

        const a = datos.actual;
        $('icono').textContent = a.icono;
        $('lugar').textContent = datos.ciudad;
        $('region').textContent = [datos.region, datos.pais].filter(Boolean).join(', ');
        $('temperatura').textContent = `${Math.round(a.temperatura)}°C`;
        $('descripcion').textContent = a.descripcion;
        $('sensacion').textContent = `${Math.round(a.sensacion)}°C`;
        $('humedad').textContent = `${a.humedad}%`;
        $('viento').textContent = `${Math.round(a.viento)} km/h`;
        $('actual').hidden = false;

        $('pronostico').replaceChildren(...datos.pronostico.map((d) => {
          const dia = crear('div', 'dia', '');
          dia.append(
            crear('div', 'nombre', nombreDelDia(d.fecha)),
            crear('div', 'emoji', d.icono),
            crear('div', 'max', `${Math.round(d.maxima)}°`),
            crear('div', 'min', `${Math.round(d.minima)}°`),
            crear('div', 'lluvia', d.lluvia === null ? '' : `💧 ${d.lluvia}%`),
          );
          dia.title = d.descripcion;
          return dia;
        }));

        $('instancia').textContent = datos.instancia;
      } catch (err) {
        $('error').textContent = err.message;
      } finally {
        $('buscar').disabled = false;
      }
    }

    $('formulario').addEventListener('submit', (evento) => {
      evento.preventDefault();
      buscar($('ciudad').value);
    });

    fetch('/api/info').then((r) => r.json()).then((i) => { $('instancia').textContent = i.instancia; }).catch(() => {});
    buscar($('ciudad').value);
  </script>
</body>
</html>
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=5s --retries=3 \
  CMD wget -qO- http://127.0.0.1:3000/health || exit 1
CMD ["node", "server.js"]
EOF

cat > .dockerignore << 'EOF'
node_modules
npm-debug.log
.env
.git
Dockerfile
.dockerignore
EOF

npm install

Si algo falla

SíntomaQué hacer
502 No se pudo consultar Open-MeteoProbá la conexión con curl -sI https://api.open-meteo.com.
npm ci falla al construirFalta package-lock.json: corré npm install en ~/clima-app antes del build.
port is already allocatedQuedó corriendo npm start o el contenedor prueba: Ctrl+C o docker rm -f prueba.
El contenedor queda unhealthyEl HEALTHCHECK usa localhost: cambialo por 127.0.0.1.
docker stop tarda 10 segundosEl CMD usa npm start o formato shell: usá CMD ["node", "server.js"].
denied: requested access to the resource is deniedEn Docker Hub, el nombre tiene que empezar con tu usuario, y tenés que estar con docker login.
exec format error en otra computadoraLa imagen se construyó para otra arquitectura. Para varias a la vez: docker buildx build --platform linux/amd64,linux/arm64.

Limpieza

docker rm -f clima-1 clima-2
docker rm -fv registry
docker rmi localhost:5000/clima-app:1.0.0 2>/dev/null
docker image prune -f

El -v de docker rm -fv registry borra también el volumen anónimo donde el registry guardaba las imágenes.

Cierre

Recorriste el ciclo completo de una imagen: tu copiloto escribió la aplicación y el Dockerfile, vos los especificaste y verificaste, la publicaste en dos registries y la ejecutaste dos veces con configuraciones distintas. La idea de fondo: una imagen se construye una vez y se ejecuta en cualquier lugar, y lo que cambia entre entornos entra por variables de entorno, no por el código.


Datos del clima de Open-Meteo.com (licencia CC BY 4.0).

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

Using Go for Systems Programming (cover image)

Using Go for Systems Programming

Discover how Go functions under the hood as a modern systems programming language. Learn how Go makes system calls directly, resulting in self-contained binaries that have no libc dependencies.

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