Deploy en NaN.builders
Guía para desplegar proyectos Node.js en la plataforma NaN.builders (microVMs KVM/QEMU).
Estructura del deploy
- Push a GitHub → NaN detecta el cambio y hace build con Kaniko
- Kaniko build → construye la imagen Docker sin daemon
- Auto-deploy → si el build funciona, se despliega automáticamente
Patrón de archivos .env (CRÍTICO)
Cada proyecto necesita estos 3 archivos:
proyecto/
├── .env ← local, NO en Git (desarrollo)
├── .env.example ← SÍ en Git (documentación de variables)
├── .dockerignore ← excluye node_modules, .git (NO .env — se copia en contenedor)
└── .gitignore ← excluye .env
⚠️ Pitfall: NAN_API no se hereda en el contenedor de NaN
Las variables de entorno del host (process.env.NAN_API en la sesión de Hermes) NO están disponibles dentro del contenedor Docker de NaN. El contenedor solo tiene:
- Variables configuradas en la pestaña Env del dashboard NaN
- Variables copiadas desde archivos del proyecto (
.envcopiado porCOPY . .)
Patrón de fallback para tokens API (implementado en server.js de dieta):
function getNanToken() {
// 1) process.env.NAN_API (NaN dashboard Env)
if (process.env.NAN_API) return process.env.NAN_API;
// 2) NTIZAR_API (otro nombre posible)
if (process.env.NTIZAR_API) return process.env.NTIZAR_API;
// 3) Leer .env del proyecto (fallback local)
try {
const envContent = fs.readFileSync(path.join(__dirname, '.env'), 'utf8');
const match = envContent.match(/^NAN_API=(.+)$/m);
if (match) return match[1].trim();
} catch (e) {}
return '';
}
Configuración para deploy:
- Crear
.envcon el token real en el directorio del proyecto - Añadir
.enva.gitignore(NUNCA subir a Git) - NO añadir
.enva.dockerignore(se necesita en el contenedor) - El Dockerfile
COPY . .lo copiará automáticamente
.env.example — template con nombres y placeholders:
ESIOS_API_TOKEN=tu_token_aqui
PORT=4000
NAN_API_KEY=tu_clave_aqui
.env — valores reales (solo local):
ESIOS_API_TOKEN=abc123...
PORT=4000
Dónde configurar variables en producción
- NaN: pestaña Env en la web de NaN → dashboard del espacio
- NUNCA en el código, commits, o .env en Git
- Se acceden via
process.env.VAR_NAMEen Node.js
Validación en código (3 patrones)
Patrón A — Exit early (recomendado para obligatorias):
// src/config/env.js
const REQUIRED = ['ESIOS_API_TOKEN'];
function loadEnv() {
const missing = REQUIRED.filter(k => !process.env[k]);
if (missing.length > 0) { console.error(`Faltan: ${missing.join(', ')}`); process.exit(1); }
return { ESIOS_TOKEN: process.env.ESIOS_API_TOKEN, ... };
}
Patrón B — Health endpoint con checks:
app.get('/readyz', (req, res) => {
const esiosReady = Boolean(process.env.ESIOS_API_TOKEN);
res.status(esiosReady ? 200 : 503).json({
status: esiosReady ? 'ready' : 'degraded',
checks: { esios_api_token: esiosReady }
});
});
Patrón C — Fallback con generación temporal:
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD
|| crypto.randomBytes(24).toString('base64url'); // Temporal
Dockerfile mínimo para NaN
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=4000
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
COPY --from=deps --chown=appuser:appgroup /app/node_modules ./node_modules
COPY package.json ./
COPY server.js ./
COPY src/ ./src/
COPY public/ ./public/
COPY data/ ./data/
USER appuser
EXPOSE 4000
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget -qO- http://localhost:4000/healthz || exit 1
CMD ["node", "server.js"]
🚧 Primer deploy: crear el espacio en NaN
Antes de que cualquier URL funcione, hay que crear el espacio en la web de NaN:
- Ir a cloud.nan.builders e iniciar sesión
- Crear un nuevo Space apuntando al repositorio GitHub
- Configurar el puerto (debe coincidir con
EXPOSEdel Dockerfile) - El espacio tarda segundos en aprovisionarse
Síntoma de espacio no creado: La URL (<app>-<org>-<project>.apps.nan.builders) devuelve 404 directamente de Cloudflare (no 502, no timeout). El DNS resuelve a IPs de Cloudflare pero no hay backend.
⚠️ No basta con tener el Dockerfile en el repo. El espacio debe existir en NaN para que el auto-deploy por polling funcione. Sin espacio, el push a GitHub no tiene efecto visible.
⚠️ CRÍTICO: Renombrar repo en GitHub rompe deploy NaN
Cuando renombras un repositorio en GitHub (Ntizar/TimeIneco → Ntizar/Time), el espacio NaN existente deja de funcionar porque el DNS de NaN apunta al nombre viejo. El espacio NaN sigue vinculado al repo viejo y el polling de GitHub ya no lo detecta.
Síntomas:
- URL anterior (
timeineco-ntizar-ntizar.apps.nan.builders) devuelve 404 o 502 - URL nueva (
time-ntizar-ntizar.apps.nan.builders) no existe - Push al repo renombrado no genera redeploy
Fix:
- Ir a cloud.nan.builders
- Crear un nuevo espacio apuntando al repo renombrado (
Ntizar/Time) - Configurar variables de entorno (ORS_API_KEY, NAP_API_KEY, PORT)
- Configurar Container Port (debe coincidir con EXPOSE del Dockerfile)
- Esperar primer build Kaniko (~2-5 min)
- Verificar:
curl -s https://time-ntizar-ntizar.apps.nan.builders/healthz
NO se puede renombrar un espacio NaN — hay que crear uno nuevo y (opcionalmente) borrar el viejo tras confirmar que el nuevo funciona.
Patrón de URL: <espacio>-<owner>-<owner>.apps.nan.builders — el owner aparece duplicado en la URL.
Patrón de URL
NaN.builders asigna URLs con el patrón:
<app-name>-<owner>-<owner>.apps.nan.builders
Donde app-name es el nombre del espacio en NaN, y owner es el usuario/organización de GitHub. El owner aparece duplicado en la URL.
Opciones de hosting alternativas
Si el espacio NaN aún no existe y la URL es urgente:
| Opción | Requisito | Comando |
|---|---|---|
| GitHub Pages | Repo público (o plan pago para privado) | gh repo edit --visibility public && gh api -X POST repos/:owner/:repo/pages -f source.branch=gh-pages |
| Surge.sh | Login interactivo (email + password) primero | npm i -g surge && surge ./dist nombre.surge.sh |
| Vercel/Netlify | Login vía CLI | npx vercel deploy --prebuilt --prod |
⚠️ .env en .dockerignore mata tokens de API
Si añades .env a .dockerignore, el archivo NO se copia al contenedor Docker. El servidor arranca pero process.env.NAN_API no existe (NaN no hereda env vars del host) y el fallback a .env local también falla (no está en el contenedor). Todos los endpoints de IA (estimación comida, ejercicio, coach) devuelven "Token no configurado".
Síntoma: App funciona, login OK, datos se guardan, pero todo lo que usa IA falla silenciosamente.
Fix: Quitar .env de .dockerignore. El .env DEBE estar en el contenedor para que el fallback fs.readFileSync('.env') funcione. El .env ya está en .gitignore (no se sube a GitHub), pero el COPY . . del Dockerfile lo copia al contenedor.
# ❌ MAL — .env no llega al contenedor
node_modules/
.env
# ✅ BIEN — .env se copia al contenedor
node_modules/
.git
.gitignore
⚠️ CRÍTICO: "type": "module" + require() = crash silencioso
Si package.json tiene "type": "module", Node.js ejecuta TODOS los .js como ESM. Si el server usa require() (CommonJS), el contenedor crashea al arrancar y el pod se queda en Pending — sin error visible en el dashboard de NaN.
Síntomas: Build Kaniko exitoso, Current Image se llena, pero URL siempre devuelve 503 o 404.
Causa típica: Dockerfile con RUN echo 'require("http")...' > server.js (inline CommonJS) en un proyecto Vite que tiene "type": "module".
Fix:
- Crear
server.mjscomo archivo separado en el repo (ESM puro:import http from "node:http") - Usar
COPY --chown=appuser:appgroup server.mjs ./en el Dockerfile - Cambiar
CMD ["node", "server.js"]→CMD ["node", "server.mjs"] - NUNCA hacer
echo 'require(...)' > server.jsen un proyecto con"type": "module"
Verificación: grep '"type"' package.json + grep 'require(' server.js — si ambos dan resultado, hay conflicto ESM/CJS.
Puerto
- El espacio de NaN puede estar configurado en cualquier puerto (3500, 3700, 4000, 4500, 6000, etc.)
- El Dockerfile EXPOSE debe coincidir con el puerto del espacio
- El server.js debe usar
process.env.PORT || <puerto>como default - Para sitios estáticos con nginx: el EXPOSE y el
listenen nginx.conf deben coincidir — no asumir que nginx escucha en 80 por defecto - Si no coinciden → 502 Bad Gateway
- ⚠️ NUNCA intentar escuchar en puerto 80 desde un contenedor no-root. Cuando el Dockerfile usa
USER appuser(que es obligatorio), el bind a puerto 80 falla silenciosamente — Node.js no levanta, el proceso muere, y el pod se queda en Pending. Solo escuchar en el puerto configurado:http.createServer(handler).listen(process.env.PORT || 3700, "0.0.0.0") - HEALTHCHECK debe apuntar al puerto configurado, no a 80:
CMD wget -qO- http://localhost:3700/healthz || exit 1
Usuario no-root (crítico)
- NaN BLOQUEA contenedores que ejecutan como root — el pod se queda en "Pending" indefinidamente aunque Kaniko construya la imagen correctamente
- Siempre crear usuario no-root:
RUN addgroup -S appgroup && adduser -S appuser -G appgroup - Siempre cambiar antes del CMD:
USER appuser - Ajustar permisos de todos los archivos copiados:
RUN chown -R appuser:appgroup /app - Es el mismo patrón indispensable que usa esios-dashboard
- Síntoma: Kaniko build exitoso (se ve en logs) pero URL devuelve 404 (Cloudflare) → el pod está en Pending → revisar que el Dockerfile use no-root
NaN auto-polling (semi-automático)
- NaN NO usa webhooks de GitHub
- NaN SÍ hace polling periódico del repositorio GitHub (cada ~1-5 minutos, depende de carga)
- Cuando detecta un nuevo commit en
main, reconstruye y redeploya automáticamente - Si la build anterior falló (ej: faltaba Dockerfile, o el contenedor era root), NaN reintenta con el siguiente commit — no hace falta ir al dashboard obligatoriamente
- Para forzar inmediatamente: dashboard → cloud.nan.builders → Redeploy
git commit --allow-empty -m "trigger redeploy" && git pushSÍ funciona indirectamente: el push a GitHub → NaN detecta cambio en su próximo ciclo de polling → reconstruye
Variables de entorno
- Se configuran en la web de NaN → pestaña Env
- NO se suben por Git
- Se necesitan para: API keys, tokens, URLs
.env.exampleen el repo sirve como documentación
Kaniko
- No soporta
--build-argpara secrets - Las secrets VAN en variables de entorno, no en el Dockerfile
- El build puede fallar si
package-lock.jsonno coincide conpackage.json - ⚠️
npm cifalla si package-lock.json está desincronizado — si añades nuevas dependencias (bcryptjs, express-session, etc.), el lockfile queda obsoleto. Regenerar connpm installantes de commit. Síntoma: build Kaniko exitoso pero contenedor crash al arrancar porque faltan módulos. - Error
error resolving dockerfile path: please provide a valid path to a Dockerfile within the build context with --dockerfile→ No hay Dockerfile en la raíz del repo, o el Dockerfile está mal nombrado (debe llamarse exactamenteDockerfile) - Si el build falla (ej: no existía Dockerfile), NaN reintenta automáticamente con el siguiente commit detectado por polling
Dockerfile single-stage con build interno + Node.js (Vite static)
Para proyectos Vite que necesitan build y node:alpine en una sola etapa (más simple que multi-etapa):
FROM node:20-alpine
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3700
# 1. Dependencias
COPY package.json package-lock.json ./
RUN npm ci --include=dev # --include=dev para que Vite esté disponible en build
# 2. Código fuente
COPY index.html vite.config.js ./
COPY public/ ./public/
COPY src/ ./src/
# 3. Build
RUN npx vite build
# 4. Usuario no-root (requisito NaN)
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
# 5. Servidor HTTP Node.js embebido (SPA + multi-puerto)
RUN echo 'const http = require("http"); \
const fs = require("fs"); \
const path = require("path"); \
const DIST = path.join(__dirname, "dist"); \
const MIME = { \
".html": "text/html", ".css": "text/css", \
".js": "application/javascript", ".json": "application/json", \
".png": "image/png", ".jpg": "image/jpeg", ".svg": "image/svg+xml", \
".ico": "image/x-icon", ".woff2": "font/woff2" \
}; \
function handler(req, res) { \
if (req.url === "/healthz") { \
res.writeHead(200, {"Content-Type": "application/json"}); \
res.end(JSON.stringify({status:"ok",uptime:process.uptime()})); \
return; \
} \
let filePath = path.join(DIST, req.url === "/" ? "index.html" : req.url); \
const ext = path.extname(filePath); \
fs.readFile(filePath, (err, data) => { \
if (err) { \
fs.readFile(path.join(DIST, "index.html"), (e2, d2) => { \
if (e2) { res.writeHead(500); res.end("Error"); return; } \
res.writeHead(200, {"Content-Type": "text/html"}); \
res.end(d2); \
}); \
} else { \
res.writeHead(200, {"Content-Type": MIME[ext] || "application/octet-stream"}); \
res.end(data); \
} \
}); \
} \
http.createServer(handler).listen(process.env.PORT || 3700, "0.0.0.0", () => console.log("App en puerto " + (process.env.PORT || 3700)));' > server.js
# 6. Ajustar permisos y cambiar a usuario no-root
RUN chown -R appuser:appgroup /app
USER appuser
# 7. Healthcheck en el puerto configurado (NO 80 — appuser no puede bind <1024)
HEALTHCHECK --interval=15s --timeout=3s --start-period=5s --retries=3 \
CMD wget -qO- http://localhost:3700/healthz || exit 1
EXPOSE 3700
CMD ["node", "server.js"]
Ventajas sobre multi-etapa: Sin nginx, sin COPY entre etapas, código del servidor inlineado en el propio Dockerfile. El SPA fallback (cualquier ruta → index.html) va incluido.
Diagnóstico de despliegue
Estado "Pending" en NaN Builders
- Qué significa: NaN está construyendo la imagen Docker por primera vez o tras un nuevo commit. Es NORMAL la primera vez.
- Tiempo típico: 2-5 minutos para el primer build Kaniko. Builds posteriores: 1-2 min.
- Cómo verificar progreso:
curl -s https://<app>.apps.nan.builders/healthz— si responde (200 o 404), el servidor ya está vivo. Si responde 404 de Cloudflare en texto plano ("404 page not found"), el espacio aún no existe o el build aún no terminó. - No hay webhook de GitHub: NaN usa polling cada 1-5 min. Un push a
maindesencadena un nuevo build automáticamente. No hace falta hacer nada manual. - Si el estado se queda en "Pending" > 10 min: Ir al dashboard → Redeploy manualmente.
Diagnóstico de despliegue
| Síntoma | Causa | Solución |
|---|---|---|
| Cloudflare 404 (no 502) — URL responde con "404 page not found" en texto plano | Espacio NaN no creado — DNS resuelve a Cloudflare pero no hay backend | Crear el espacio en cloud.nan.builders primero. No basta con push a GitHub. |
| Pending persistente (> 10 min), Kaniko build sin error aparente, Curren Image se genera pero URL 503 | Contenedor crash al arrancar — ESM/CJS mismatch | "type": "module" en package.json + require() en server.js → Node crash por require is not defined. Crear server.mjs en ESM puro. Ver sec. ⚠️ CRÍTICO. |
| Build OK (Kaniko exitoso) → URL 404 / Pending | Contenedor ejecuta como root — NaN bloquea pods root | Añadir USER appuser al Dockerfile (ver reglas críticas). Push nuevo commit → NaN detecta por polling → reconstruye. Síntoma clásico: build history muestra "succeeded", Current Image se genera, pero status sigue "pending" > 10 min. Es la causa #1 de este síntoma. |
| Build OK, contenedor arranca (logs muestran puerto real), pero URL da 502 | Puerto desalineado — NaN inyecta PORT=<container-port> como env var, y el server escucha en otro puerto |
Alinear EXPOSE del Dockerfile, process.env.PORT default en server.js, y Container Port en NaN UI. Los tres deben ser el mismo número. |
| Build OK, contenedor arranca pero se reinicia en bucle cada ~30s, URL da 502 | Healthcheck apunta a endpoint con auth (401) → NaN mata contenedor por unhealthy | Crear endpoint público /healthz ANTES del middleware de auth. Apuntar HEALTHCHECK a /healthz. |
| Build OK, contenedor running, 502 persistente (>5 min), healthz no responde | Healthcheck apunta a puerto distinto del que NaN inyecta como PORT |
Alinear EXPOSE, process.env.PORT default, y Container Port de NaN. O aplicar patrón de doble puerto (punto 28). Ver skill dedicado: nan-puerto-desajuste. |
| App responde 200, HTML se sirve, pero muestra loading eterno | El proveedor de tiles/API externa falla y nunca dispara load |
Añadir fallback automático en map.on('error') + timeout de 12s que oculta loading y crea mapa fallback. Ver references/loading-screen-eterno.md. |
Pending eterno (> 15 min), Current Image: -, build Kaniko nunca arranca |
NaN sin espacio en disco/recursos — cluster sobrecargado por muchas apps | Liberar espacio desde panel NaN (borrar apps no usadas). Multi-stage Dockerfile ayuda pero no elimina el problema. Hacer un push nuevo para re-trigger build. |
| 502 Bad Gateway (< 2s) | App crash al arrancar | Verificar token API en pestaña Env de NaN |
| 502 Bad Gateway (2-30s) | Request lenta / hanging | Timeout en API externa, verificar red |
| 502 Bad Gateway (> 30s) | Timeout Cloudflare/NaN | Backend demasiado lento, optimizar consultas |
| Datos stale en frontend | Cache de navegador | Enviar Cache-Control: no-cache en HTML/JS |
/readyz muestra degraded |
Token faltante | Configurar en NaN Env |
| Error 401 en API externa | Token inválido | Revisar token en web del proveedor |
| Error 429 | Rate limit API externa | Cache más agresivo o delay entre requests |
| Cloudflare 404 en todas las variantes de nombre | El nombre del espacio en NaN no coincide con el repo | Probar variantes: minúscula, mayúscula, con guiones: curl -s -o /dev/null -w '%{http_code}' \"https://<variante>-<owner>-<owner>.apps.nan.builders\". Si todas dan 404, el espacio no existe o tiene otro nombre. Verificar en cloud.nan.builders el nombre exacto del espacio. |
| Fondo blanco, 200 OK en HTML — Vite | vite.config.js tiene base: '/repo-name/' (de GitHub Pages) pero NaN sirve desde raíz |
Cambiar base: '/' en vite.config.js. Reconstruir con npm run build, push y esperar polling de NaN. |
| Fondo blanco tras fix, sigue igual | Service Worker roto — cache.addAll() falla con 404 → nueva versión SW nunca se activa |
Ver ## Service Workers más abajo. Causa: el SW lista un archivo inexistente en STATIC_ASSETS → cache.addAll() falla → install event nunca completa → SW nuevo no se activa → navegador usa caché vieja. Fix: (1) eliminar la referencia rota del SW, (2) bump CACHE_NAME (ej sef-cache-v4.0), (3) push + hard refresh o Unregister SW en DevTools. |
Diagnóstico
# Health check
curl https://<app>.apps.nan.builders/healthz
# Readiness check (verifica tokens)
curl https://<app>.apps.nan.builders/readyz
# Verificar que es un deploy RECIENTE (uptime < 120s = recién deployado)
curl -s https://<app>.apps.nan.builders/healthz | python3 -c "import json,sys; d=json.load(sys.stdin); print(f'Uptime: {d[\"uptime\"]:.0f}s', '(FRESCO ✅' if d['uptime']<120 else '')"
# Verificar que responde JSON real, no HTML de error
curl -s https://<app>.apps.nan.builders/api/esios/summary?fecha=2026-05-25 | head -c 100
Trigger redeploy NaN
NaN NO tiene webhooks de GitHub. Para redeployar:
- Ir al dashboard: cloud.nan.builders
- Encontrar la app → botón Redeploy o Rebuild
- Kaniko construye de nuevo desde el repo
- Verificar con
curl <url>.apps.nan.builders
git commit --allow-empty -m "trigger redeploy"SÍ funciona en NaN — aunque no hay webhook, NaN hace polling periódico de GitHub (cada1-5 min) y detecta el nuevo commit, lo que desencadena rebuild + redeploy. Es más lento que un clic manual (2-5 min vs instantáneo) pero sirve como trigger programático. Verificar concurl <url>/healthzy comprobar queuptimees bajo (< 60s = fresco ✅).⚠️ Si el uptime es alto (> 300s = 5 min) tras un push: el polling aún no ha detectado el cambio o el container no se reconstruyó. Esperar 1-2 min y reintentar. Si sigue alto, forzar con otro
git commit --allow-empty -m "chore: force redeploy" && git push.
Service Workers — Pitfall crítico en sitios estáticos
Los Service Workers con estrategia "cache first" pueden atascar la actualización de un sitio si la lista de assets incluye un archivo inexistente.
Mecanismo de fallo
sw.js STATIC_ASSETS → incluye '/css/ntizar.next.css' (no existe)
→ cache.addAll() → falla con 404
→ install event nunca completa
→ SW nuevo NUNCA se activa
→ navegador sigue usando caché vieja (sef-cache-v3.4)
→ FONDO BLANCO (caché vieja + assets nuevos = incompatibilidad)
Fix obligatorio (3 pasos)
- Eliminar la referencia rota del archivo
sw.js→STATIC_ASSETS - Bump CACHE_NAME — ej
sef-cache-v3.4→sef-cache-v4.0para forzar re-cache completo - Push + instructar al usuario: hard refresh (
Ctrl+Shift+R) o DevTools → Application → Service Workers → Unregister
Verificación post-fix
# Confirmar que el archivo eliminado NO está en STATIC_ASSETS
grep 'ntizar.next.css' sw.js # Debe dar vacío
# Confirmar que el cache name cambió
grep 'CACHE_NAME' sw.js
Prevención
- Cuando se elimina un archivo CSS/JS del repo, buscar también en
sw.jsla referencia cache.addAll()es atómico: si 1 de N archivos falla, TODOS los assets se pierden de caché- El error de
cache.addAll()es silencioso en consola del usuario (solo visible en DevTools → Application → Service Workers)
Patrones aprendidos
- Siempre exponer el puerto correcto — NaN usa el EXPOSE del Dockerfile para mapear el tráfico
- Siempre usar
process.env.PORT— permite que el contenedor reciba el puerto del entorno - NUNCA escuchar en puerto 80 desde appuser —
USER appuserno tiene permisos para bind a puertos <1024. El bind falla silenciosamente, el proceso muere, y el pod se queda en Pending. Solo escuchar en el puerto configurado:http.createServer(handler).listen(process.env.PORT || 3700, "0.0.0.0"). Si el HEALTHCHECK necesita sondear, apuntar al mismo puerto configurado. - Usuario no-root es REQUISITO — sin
USER appuserel pod se queda en Pending indefinidamente aunque Kaniko construya bien la imagen. Es la causa #1 de "build OK → no carga" - Health endpoint —
/healthzdevuelve{"status":"ok"}para verificar que el app está vivo - Healthcheck en puerto configurado — el HEALTHCHECK del Dockerfile debe apuntar al mismo puerto que el server (ej:
localhost:3700/healthz), no a 80. NaN no sondea en 80 internamente — esa es una suposición errónea que crashea el contenedor. - Readiness endpoint —
/readyzverifica que los tokens están configurados - npm ci en vez de npm install — usa package-lock.json para builds reproducibles
.dockerignore— siempre excluir.env,node_modules,.git.env.example— siempre en el repo como documentación de variables necesarias- Nginx static sites — Para sitios estáticos (HTML+CSS+JS):
FROM nginx:alpine, sed para cambiar listen port al puerto correcto,COPY . .en/usr/share/nginx/html/,.dockerignoreexcluye.git,node_modules,*.md,docs/. El HEALTHCHECK usawget -qO- http://localhost:<port>/
12. Vite static sites (multi-etapa) — Para proyectos Vite: usar FROM node:20-alpine AS builder con npx vite build, luego FROM nginx:alpine copiando /app/dist. Ver referencia completa en references/dockerfile-spa-static.md
24. Persistencia de datos en NaN — GitHub Contents API
NaN containers pierden el filesystem en redeploy (Kaniko reconstruye la imagen). Si la app guarda datos en JSON/SQLite dentro del contenedor, se pierden al redeployar.
Solución: sincronizar cambios a GitHub vía Contents API (no necesita git en el contenedor):
async function syncToGitHub(db, token, owner, repo, path) {
// 1. Obtener SHA actual del archivo
const getRes = await fetch(`https://api.github.com/repos/${owner}/${repo}/contents/${path}`, {
headers: { 'Authorization': `Bearer ${token}`, 'Accept': 'application/vnd.github.v3+json' }
});
if (!getRes.ok) return;
const { sha } = await getRes.json();
// 2. Subir contenido actualizado (base64)
const b64 = Buffer.from(JSON.stringify(db, null, 2)).toString('base64');
await fetch(`https://api.github.com/repos/${owner}/${repo}/contents/${path}`, {
method: 'PUT',
headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
message: `Auto-sync ${new Date().toISOString().slice(0,19)}`,
content: b64, sha
})
});
}
Llamar en TODOS los endpoints de mutación (POST, PUT, DELETE). El sync es async y no bloquea la respuesta — si falla, el dato queda local pero no en GitHub.
Requisito: el token NAN_API debe tener permisos de escritura en el repo. Verificar: curl -H "Authorization: Bearer $TOKEN" https://api.github.com/repos/OWNER/REPO.
Pitfall: el SHA es obligatorio — sin él, GitHub devuelve 422 "sha must be provided". Si el archivo es nuevo (no existe), no hay SHA y se usa CREATE en vez de UPDATE.
12a. Mejorar app existente vs crear nueva — Cuando el usuario pide "mejorar" una app ya desplegada, modificar el repo existente (patch dashboard.html, extender server.js), NO crear proyecto nuevo. Ver references/improving-existing-app.md.
12b. Vite + Node.js server (3-stage, recomendado para NaN) — MEJOR que nginx para NaN:
# === Stage 1: Instalar TODAS las dependencias (incluidas dev para Vite) ===
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --include=dev
# === Stage 2: Build con Vite ===
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY index.html vite.config.js ./
COPY public/ ./public/
COPY src/ ./src/
RUN npx vite build
# === Stage 3: Producción (solo prod deps) ===
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3030
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
# Re-instalar solo producción (npm ci --omit=dev)
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
# Copiar build estático
COPY --from=builder --chown=appuser:appgroup /app/dist ./dist
# Servidor en archivo separado (NO inline echo)
COPY --chown=appuser:appgroup server.mjs ./
USER appuser
HEALTHCHECK --interval=15s --timeout=3s --start-period=5s --retries=3 \
CMD wget -qO- http://localhost:3030/healthz || exit 1
EXPOSE 3030
CMD ["node", "server.mjs"]
Ventajas: El server.mjs es un archivo real en el repo (no inline), se puede lintear/testear, y evita el pitfall de "type": "module" (ver sección ⚠️).
13. SPA con Node.js (recomendado para NaN) — Más fiable que nginx multi-etapa. Server embebido con SPA fallback y multi-puerto. Ver references/dockerfile-spa-static.md
14. npx serve sin clipboard — Al servir localmente con npx serve, usar --no-clipboard para evitar errores de permisos
15. Verificar archivos estáticos tras deploy — Después de un push, comprobar que todos los <link href> y <script src> devuelven 200: curl -sI https://<app>.apps.nan.builders/css/<archivo>.css | head -3. Si alguno devuelve 404, el HTML referencia un archivo que no existe en el contenedor.
16. deploy.yml job name mismatch — Si deploy.yml referencia un job que no existe en el workflow CI real (ej lint-and-test cuando los jobs se llaman test y build), el deploy nunca se ejecuta. Verificar con: gh run list --repo OWNER/REPO --json name,status y comparar con los job names en deploy.yml. Fix: actualizar needs: en deploy.yml para que coincida con los job names reales.
17. Vite base path para NaN — Si el proyecto Vite se configuró para GitHub Pages (base: '/repo-name/'), cambiar a base: '/' antes de deployar en NaN. Si no, los assets JS/CSS se buscan en /repo-name/assets/... pero NaN sirve desde raíz → 404 silencioso → fondo blanco. Verificar que dist/index.html use rutas /assets/... (no /repo-name/assets/...) tras el build.
18. NaN URL discovery — Cuando una URL NaN devuelve 404 de Cloudflare y el usuario dice que el espacio existe, probar múltiples variantes del nombre: minúscula, mayúscula, con guiones vs sin guiones. El nombre del espacio en NaN puede diferir del nombre del repo. Comando: for name in variante1 variante2; do curl -s -o /dev/null -w "%{http_code}" "https://\${name}-<owner>-<owner>.apps.nan.builders"; done
19. Estado "Pending" es normal — NaN tarda 2-5 min en el primer build Kaniko. La URL puede responder 404 (Cloudflare) antes de que termine el build. Verificar con curl -s https://<app>.apps.nan.builders/healthz — si responde (200 o 404 de app, no de Cloudflare), el servidor ya está vivo. No hacer nada manual, el auto-deploy funciona por polling cada 1-5 min.
20. Pantalla de carga eterna en SPAs con mapas — Si hideLoading() solo se llama en map.on('load') y el proveedor de tiles falla, el loading nunca se oculta. Añadir fallback automático en map.on('error') + timeout de seguridad (12s). Ver references/loading-screen-eterno.md.
21. Mejorar app existente vs crear nueva — CORRECCIÓN CRÍTICA — Cuando el usuario pide "mejorar" o "añadir algo a" una app ya desplegada en NaN, NUNCA crear un proyecto nuevo. Modificar el repo existente con patch/write_file sobre los archivos existentes (dashboard.html, server.js, etc.). El usuario quiere evolución, no reinvención. Síntoma de fallo: crear dieta-nan/ con package.json, server.js, index.html nuevos cuando el usuario pidió mejorar dieta-ntizar.apps.nan.builders. Fix: trabajar sobre el repo existente, añadir tabs/features al HTML existente, extender server.js con nuevos endpoints, actualizar Dockerfile si es necesario.
22. Dockerfile existente sin USER appuser — Un repo ya desplegado puede tener un Dockerfile que falta USER appuser, lo que hace que el pod se quede en Pending. Si el Dockerfile de un repo existente no tiene USER appuser, añadirlo inmediatamente con patch.
23. Debugging "Unexpected token '<'" — Cuando la IA del dashboard devuelve este error, es siempre porque el servidor devuelve HTML de error (401/500) en vez de JSON. Causa típica: token API faltante en el contenedor. Ver references/debugging-unexpected-token-html.md.
37. Verificar si el deploy tiene el código nuevo vs viejo
Cuando el deploy parece actualizado (healthz responde) pero un endpoint falla, no asumir que está desactualizado — verificar por el mensaje de error del código.
Técnica: Llamar al endpoint y comparar el mensaje de error con lo que devolvería el código viejo vs el nuevo.
# El código nuevo de NAP dice "NAP_API_KEY no configurada"
# El código viejo diría "No se encontró archivo GTFS"
curl -s -X POST https://timeineco-ntizar-ntizar.apps.nan.builders/nap-download-gtfs \
-H "Content-Type: application/json" -d '{"datasetId":"1567"}' | python3 -c "import sys,json; d=json.load(sys.stdin); print('código nuevo' if 'NAP_API_KEY' in d.get('error','') else 'código viejo')"
Síntoma de código nuevo desplegado pero API key faltante: El endpoint funciona (no 404 de Cloudflare) y el error es específico del nuevo código ("no configurada", "no encontrado", etc.). Esto confirma que el redeploy funcionó pero falta una variable de entorno.
38. NAP API — Patrón de redirect S3
La API NAP (transportes.gob.es) NO devuelve datos directamente. Devuelve un JSON con un enlace temporal a S3 que hay que seguir:
POST /api/v2/fichero/{id}/descarga
→ {"success":true,"data":{"enlaceDescarga":"https://mfomwpronapdata.s3.eu-west-1.amazonaws.com/...?X-Amz-Expires=900&..."}}
→ HTTPS GET del enlaceDescarga → ZIP del GTFS
El enlace expira en 900 segundos (15 min). Hay que seguir el redirect (302) para obtener el ZIP real.
Implementación en server.mjs:
- Llamar a
/api/v2/fichero/{id}/descargaconApiKeyheader - Parsear JSON y extraer
data.enlaceDescarga - HTTPS GET del enlace (con
follow-redirectso manual) - El servidor S3 puede devolver 302 adicional → seguir también
- Buffer del ZIP → servir al cliente
Ver references/nap-s3-redirect.md para código completo.
40. Timeout en proxies nativos (https.request) — Cloudflare 502 silencioso
Cuando el servidor de NaN actúa como proxy hacia una API externa (ORS, NAP, etc.) usando https.request nativo sin Express, la petición no tiene timeout por defecto. Si la API externa tarda >25-30s, Cloudflare/NaN corta la conexión con un 502 sin error visible en el servidor.
Síntomas:
- El endpoint funciona con rangos pequeños (600s) pero da 502 con rangos grandes (>1800s)
curldirecto a la API externa funciona (con--max-time 30), pero el proxy devuelve 502- Sin logs de error en el servidor
Fix: proxyReq.setTimeout(25000) con destrucción y respuesta 504:
proxyReq.setTimeout(25000, () => {
proxyReq.destroy();
res.writeHead(504);
res.end(JSON.stringify({ error: 'Timeout contacting API (25s)', fallback: true }));
});
Llamar ANTES de proxyReq.write()/proxyReq.end().
40b. Cadena de timeouts: frontend < proxy < API externa
Pitfall crítico: El frontend (JS en navegador) tiene su propio timeout para fetch(). Si el timeout del frontend es menor que el del server proxy, el frontend aborta la request ANTES de que el proxy tenga tiempo de responder. El proxy sigue procesando pero nadie escucha.
frontend fetch timeout: 10s ← ¡MUERE AQUÍ!
→ proxy Node.js timeout: 25s
→ API externa (ORS, NAP): 20-30s
Síntoma: El endpoint funciona con curl desde terminal (no tiene timeout estricto) pero falla en el navegador. El servidor nunca registra un error porque la request del proxy se completa, pero el cliente ya no está escuchando.
Fix: La cadena de timeouts debe ser monótona creciente:
// FRONTEND (js/main.js) — timeout más largo que el proxy
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 35000); // 35s > proxy
fetch('/api/isochrone', {
method: 'POST',
signal: controller.signal,
body: JSON.stringify({...})
}).then(res => {
clearTimeout(timeoutId);
// ...
}).catch(err => {
clearTimeout(timeoutId);
if (err.name === 'AbortError') {
showFallback('La API de rutas está tardando...');
}
});
// BACKEND (server.mjs) — proxy timeout < frontend timeout
proxyReq.setTimeout(25000, () => { // 25s < 35s
proxyReq.destroy();
res.writeHead(504);
res.end(JSON.stringify({ error: 'Timeout contacting API (25s)', fallback: true }));
});
Regla: timeout_frontend >= timeout_proxy + 10s. Si el proxy tiene timeout de 25s, el frontend debe tener al menos 35s. Siempre dejar un margen de 10s para latencia de red y serialización.
Verificación:
# Buscar timeouts en frontend
grep -n 'timeout\|abort\|AbortController' js/main.js js/isochrones.js 2>/dev/null
# Buscar timeouts en backend
grep -n 'setTimeout\|timeout\|setTimeout' server.mjs server.js 2>/dev/null
# Comparar: frontend timeout DEBE ser >= backend timeout + 10s
41. Vendor JS ausente del repo — librería que el HTML carga pero no está en Git
Cuando index.html carga <script src="js/vendor/xxx.js">, el archivo DEBE estar en el repositorio. Si solo existe localmente, el build de NaN no lo incluirá.
Síntoma: Función (DOCX, JSZip, etc.) falla silenciosamente — Uncaught ReferenceError o botón inerte.
Diagnóstico:
# Archivo en repo?
git ls-files js/vendor/
# MIME type correcto en deploy?
curl -sI https://app.apps.nan.builders/js/vendor/docx.umd.js | grep -i 'content-type'
# Debe ser application/javascript, NO text/html
Prevención: Tras añadir dependencias JS, verificar que todos los <script src> del HTML responden 200 con MIME correcto.
41b. CDN > vendor local en NaN — SPA fallback enmascara archivos ausentes
Pitfall: NaN containers sirven el SPA fallback (index.html) para cualquier ruta no encontrada. Si un archivo vendor (js/vendor/docx.umd.js) no está en el contenedor, NaN devuelve HTML con status 200 en vez de 404. El navegador ejecuta el HTML como si fuera JS → error silencioso o SyntaxError.
# Cómo detectar SPA fallback
curl -sI "https://app.apps.nan.builders/js/vendor/docx.umd.js" | grep -i 'content-type'
# content-type: text/html ← ⚠️ SPA FALLBACK! El archivo NO está en el contenedor
# Lo correcto
curl -sI "https://app.apps.nan.builders/js/vendor/docx.umd.js" | grep -i 'content-type'
# content-type: application/javascript ← ✅ CDN/lib real
Solución preferida: CDN en vez de vendor local
Para librerías JS pesadas (docx, JSZip, Leaflet, Three.js):
- NO copiar a
js/vendor/local - Usar CDN directo en el HTML:
<script src="https://cdn.jsdelivr.net/npm/docx@9.7.1/dist/index.umd.cjs"></script> - Verificar que la URL CDN existe con
curlantes de pushear:curl -sI "https://cdn.jsdelivr.net/npm/docx@9.7.1/dist/index.umd.cjs" | head -5 # 200 OK, content-type: application/javascript - Añadir version query al CDN:
@9.7.1(no usar@latest— rompe en builds)
Cuándo usar CDN vs vendor local:
| Situación | Recomendación |
|---|---|
| Librería grande (>100KB, docx, JSZip) | CDN ✅ |
| Librería pequeña (<5KB, polyfill, helper) | Local o inline |
| Librería sin CDN confiable | Local + verificar content-type |
| Librería que necesita ES module import | CDN con type="module" o local vía importmap |
Verificación post-deploy de todos los assets:
# Listar todos los scripts del HTML y verificar content-type
grep -oP 'src="([^"]+)"' index.html | while read -r src; do
url=$(echo "$src" | sed 's/src="//;s/"//')
if echo "$url" | grep -q '^https\?://'; then
echo "CDN: $url"
else
fullurl="https://app.apps.nan.builders/$url"
ctype=$(curl -sI
…(truncated)