| 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
- 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 (
.env copiado por COPY . .)
Patrón de fallback para tokens API (implementado en server.js de dieta):
function getNanToken() {
if (process.env.NAN_API) return process.env.NAN_API;
if (process.env.NTIZAR_API) return process.env.NTIZAR_API;
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):
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');
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
EXPOSE del 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.mjs como 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.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 → 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):
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) { \