# Esios Nan Deploy

> Procedimiento de deploy de proyectos Node.js en NaN.builders — Dockerfile, Kaniko, variables de entorno, puertos y troubleshooting.

- Skill: `ntizar/esios-nan-deploy` (Agent Skill, multi-file: 10 files)
- Install (CLI): `npx skillmds@latest add ntizar/esios-nan-deploy`
- Raw SKILL.md: https://api.skillmd.com/api/skills/ntizar/esios-nan-deploy/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: DevOps & Infra
- Author: ntizar (https://skillmd.com/u/ntizar)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/ntizar/esios-nan-deploy

---


# Deploy en NaN.builders

Guía para desplegar proyectos Node.js en la plataforma NaN.builders (microVMs KVM/QEMU).

## Estructura del deploy

1. **Push a GitHub** → NaN detecta el cambio y hace build con Kaniko
2. **Kaniko build** → construye la imagen Docker sin daemon
3. **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:
1. Variables configuradas en la pestaña **Env** del dashboard NaN
2. Variables copiadas desde archivos del proyecto (`.env` copiado por `COPY . .`)

**Patrón de fallback para tokens API** (implementado en server.js de dieta):
```javascript
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 `.env` con el token real en el directorio del proyecto
- Añadir `.env` a `.gitignore` (NUNCA subir a Git)
- **NO** añadir `.env` a `.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_NAME` en Node.js

### Validación en código (3 patrones)

**Patrón A — Exit early (recomendado para obligatorias):**
```javascript
// 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:**
```javascript
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:**
```javascript
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD
  || crypto.randomBytes(24).toString('base64url'); // Temporal
```

## Dockerfile mínimo para NaN

```dockerfile
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**:

1. Ir a [cloud.nan.builders](https://cloud.nan.builders) e iniciar sesión
2. Crear un nuevo **Space** apuntando al repositorio GitHub
3. Configurar el puerto (debe coincidir con `EXPOSE` del Dockerfile)
4. 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:**
1. Ir a [cloud.nan.builders](https://cloud.nan.builders)
2. Crear un **nuevo espacio** apuntando al repo renombrado (`Ntizar/Time`)
3. Configurar variables de entorno (ORS_API_KEY, NAP_API_KEY, PORT)
4. Configurar Container Port (debe coincidir con EXPOSE del Dockerfile)
5. Esperar primer build Kaniko (~2-5 min)
6. 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.

```dockerignore
# ❌ 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:** 
1. Crear `server.mjs` como archivo separado en el repo (ESM puro: `import http from "node:http"`)
2. Usar `COPY --chown=appuser:appgroup server.mjs ./` en el Dockerfile
3. Cambiar `CMD ["node", "server.js"]` → `CMD ["node", "server.mjs"]`
4. **NUNCA** hacer `echo 'require(...)' > server.js` en 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 `listen` en 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](https://cloud.nan.builders) → **Redeploy**
- `git commit --allow-empty -m "trigger redeploy" && git push` **SÍ 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.example` en el repo sirve como documentación

### Kaniko
- No soporta `--build-arg` para secrets
- Las secrets VAN en variables de entorno, no en el Dockerfile
- El build puede fallar si `package-lock.json` no coincide con `package.json`
- **⚠️ `npm ci` falla si package-lock.json está desincronizado** — si añades nuevas dependencias (bcryptjs, express-session, etc.), el lockfile queda obsoleto. Regenerar con `npm install` antes 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 exactamente `Dockerfile`)
- 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):

```dockerfile
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 `main` desencadena 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](https://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`.** | **Typo en SQL: `CREATE INDEX IF NOT` (sin `EXISTS`)** | **SQLite lanza `SQLITE_ERROR: near "idx_...": syntax error`. Verificar todos los `CREATE INDEX` en server.js. El correcto es `CREATE INDEX IF NOT EXISTS`.** |
| **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](https://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

```bash
# 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:

1. **Ir al dashboard**: [cloud.nan.builders](https://cloud.nan.builders)
2. **Encontrar la app** → botón **Redeploy** o **Rebuild**
3. Kaniko construye de nuevo desde el repo
4. 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 (cada ~1-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 con `curl <url>/healthz` y comprobar que `uptime` es 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)

1. **Eliminar la referencia rota** del archivo `sw.js` → `STATIC_ASSETS`
2. **Bump CACHE_NAME** — ej `sef-cache-v3.4` → `sef-cache-v4.0` para forzar re-cache completo
3. **Push + instructar al usuario**: hard refresh (`Ctrl+Shift+R`) o DevTools → Application → Service Workers → Unregister

### Verificación post-fix

```bash
# 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.js`** la 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

1. **Siempre exponer el puerto correcto** — NaN usa el EXPOSE del Dockerfile para mapear el tráfico
2. **Siempre usar `process.env.PORT`** — permite que el contenedor reciba el puerto del entorno
3. **NUNCA escuchar en puerto 80 desde appuser** — `USER appuser` no 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.
4. **Usuario no-root es REQUISITO** — sin `USER appuser` el pod se queda en Pending indefinidamente aunque Kaniko construya bien la imagen. Es la causa #1 de "build OK → no carga"
5. **Health endpoint** — `/healthz` devuelve `{"status":"ok"}` para verificar que el app está vivo
6. **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.
7. **Readiness endpoint** — `/readyz` verifica que los tokens están configurados
8. **npm ci en vez de npm install** — usa package-lock.json para builds reproducibles
9. **`.dockerignore`** — siempre excluir `.env`, `node_modules`, `.git`
10. **`.env.example`** — siempre en el repo como documentación de variables necesarias
11. **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/`, `.dockerignore` excluye `.git`, `node_modules`, `*.md`, `docs/`. El HEALTHCHECK usa `wget -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):

```javascript
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:
```dockerfile
# === 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.

```bash
# 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:**
1. Llamar a `/api/v2/fichero/{id}/descarga` con `ApiKey` header
2. Parsear JSON y extraer `data.enlaceDescarga`
3. HTTPS GET del enlace (con `follow-redirects` o manual)
4. El servidor S3 puede devolver 302 adicional → seguir también
5. 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)
- `curl` directo 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:

```javascript
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**:

```javascript
// 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...');
  }
});
```

```javascript
// 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:**
```bash
# 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:**
```bash
# 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`.

```bash
# 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):

1. **NO** copiar a `js/vendor/` local
2. Usar CDN directo en el HTML: `<script src="https://cdn.jsdelivr.net/npm/docx@9.7.1/dist/index.umd.cjs"></script>`
3. **Verificar que la URL CDN existe** con `curl` antes de pushear:
   ```bash
   curl -sI "https://cdn.jsdelivr.net/npm/docx@9.7.1/dist/index.umd.cjs" | head -5
   # 200 OK, content-type: application/javascript
   ```
4. **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:**

```bash
# 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)
