De Node.js a Docker Hub: construí, publicá y ejecutá tu imagen
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.

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.

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
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ón | Qué hace |
|---|---|
-f | Sigue los logs en vivo. Ctrl+C deja de seguirlos sin detener el contenedor. |
--tail N | Muestra solo las últimas N líneas. |
-t | Agrega la fecha y hora en que Docker recibió cada línea. |
--since 2m | Solo lo de los últimos 2 minutos. También acepta fechas. |
2>&1 | grep | Une 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íntoma | Qué hacer |
|---|---|
502 No se pudo consultar Open-Meteo | Probá la conexión con curl -sI https://api.open-meteo.com. |
npm ci falla al construir | Falta package-lock.json: corré npm install en ~/clima-app antes del build. |
port is already allocated | Quedó corriendo npm start o el contenedor prueba: Ctrl+C o docker rm -f prueba. |
El contenedor queda unhealthy | El HEALTHCHECK usa localhost: cambialo por 127.0.0.1. |
docker stop tarda 10 segundos | El CMD usa npm start o formato shell: usá CMD ["node", "server.js"]. |
denied: requested access to the resource is denied | En Docker Hub, el nombre tiene que empezar con tu usuario, y tenés que estar con docker login. |
exec format error en otra computadora | La 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
More tutorials you might like

How Servers Work: A Hands-On Introduction to TCP Sockets
Learn how servers actually work by building a tiny TCP server and client from scratch. A hands-on introduction to sockets, TCP, and the network programming model every backend, DevOps, and platform engineer should go through at least once.

Build a Container from Scratch in Go (Liz Rice GOTO 2018)
Follow along with Liz Rice's classic GOTO 2018 presentation and build your own container runtime in under 100 lines of Go using Linux namespaces, chroot, and cgroups.

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.

Linux Processes: From a Program to a Process
Explore what a program is, when it actually becomes a process and how the Linux scheduler manages process execution.
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.