- name
- esios-nan-deploy
- description
- Procedimiento de deploy de proyectos Node.js en NaN.builders — Dockerfile, Kaniko, variables de entorno, puertos y troubleshooting.
- version
- 2.5.0
- author
- Ntizar
- tags
- ["esios","deploy","nan","docker"]
- related_skills
- ["nan-puerto-desajuste","nan-pending-root-cause"]
# 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) { \
Auf GitHub ansehen