El módulo 10 terminó con una constatación incómoda: Escena Viva funciona. Reparte carga entre trabajadores, cachea el catálogo en Redis, procesa las colas de emisión de entradas en un proceso aparte, mide su propio percentil 99 y expone una API REST y otra GraphQL. Y todo eso sigue corriendo en un portátil, con los secretos en un fichero .env que solo existe en tu disco, sin registros centralizados, sin supervisor, sin contenedor y sin despliegue automático. Este módulo cierra esa distancia. Y empieza por donde hay que empezar: por la configuración. No porque sea lo más vistoso, sino porque es lo primero que se rompe cuando una aplicación sale del portátil. La misma base de código tiene que arrancar en tu máquina apuntando a una base de datos local, en el servidor de integración continua apuntando a contenedores efímeros, en preproducción apuntando a una copia de los datos reales y en producción apuntando a la base de datos donde Lucia y Marc compran de verdad sus entradas para el Festival de Jazz de Primavera. Una sola base de código, cuatro comportamientos. La diferencia está entera en la configuración.

Contenido

  1. Los doce factores aplicados a la configuración
  2. process.env de verdad
  3. dotenv y sus límites
  4. Validar al arrancar y fallar rápido
  5. Configuración por entorno sin duplicar ficheros
  6. Qué nunca va en una variable de entorno
  7. Gestión de secretos en serio
  8. Rotación de secretos sin parada
  9. Cuando un secreto se filtra
  10. Configuración en caliente frente a reinicio
  11. Tabla de referencia de las variables de Escena Viva

  1. Los doce factores aplicados a la configuración

The Twelve-Factor App es un manifiesto de 2011 que describe cómo debería construirse una aplicación que se despliega en servicios en la nube. Su factor III dice, en una frase: guarda la configuración en el entorno. La idea de fondo es separar lo que cambia entre despliegues de lo que no. El código de Escena Viva es idéntico en tu portátil y en producción: el mismo crearAplicacion, los mismos repositorios, el mismo cálculo de aforo. Lo que cambia es a qué base de datos se conecta, con qué clave firma los JWT, en qué puerto escucha y cuántos trabajadores arranca. Eso es configuración. La prueba práctica para distinguir una cosa de otra es la que los doce factores llaman la prueba del repositorio público:

¿Podrías hacer público el repositorio de Escena Viva ahora mismo, sin cambiar nada, sin comprometer ninguna credencial?

Si la respuesta es no, tienes configuración dentro del código. No importa si está en un config.json, en un objeto JavaScript o en un comentario: si está versionado y es secreto, has fallado la prueba. Ojo con el matiz: la prueba habla de credenciales, no de cualquier valor que varíe. El aforo máximo de la Sala Bóveda no es configuración por entorno: es una regla de negocio, y vive en el dominio o en la base de datos. Un error muy común es convertir en variable de entorno todo lo que en algún momento podría cambiar, y acabar con cuarenta variables que nadie sabe para qué sirven.

Es configuración No es configuración
URL de PostgreSQL, MongoDB, Redis Nombre de las tablas y colecciones
JWT_SECRETO, claves de API de terceros Duración de negocio de un token de refresco decidida por producto
Puerto de escucha, número de trabajadores Reglas de aforo, precios base, tipos de sala
Origen permitido por CORS Cabeceras de seguridad que siempre son las mismas
Nivel de registro Formato del identificador de entrada EV-<año>-<6 dígitos>

La regla mental: si dos despliegues del mismo código necesitan valores distintos, es configuración. Si todos los despliegues necesitan el mismo valor, es código.

  1. process.env de verdad

Node expone las variables de entorno del proceso en process.env. Es un objeto normal, pero tiene tres características que causan la mayoría de los errores. Todo son cadenas. Siempre. No hay números, ni booleanos, ni nulos.

// Suponiendo: PUERTO=3000 DEPURAR=false TRABAJADORES=0
typeof process.env.PUERTO;                    // 'string'
process.env.PUERTO + 1;                       // '30001'  (concatenacion, no suma)
Boolean(process.env.DEPURAR);                 // true  (la cadena 'false' es veraz)
Number(process.env.TRABAJADORES) || 4;        // 4  (el 0 se pierde)

Las tres líneas son errores reales que se ven en producción. La última es especialmente traicionera: || 4 parece un valor por defecto razonable hasta que alguien pide explícitamente cero trabajadores y obtiene cuatro. Por eso más adelante convertiremos tipos con un esquema, no a mano. undefined no es lo mismo que cadena vacía. Una variable que no existe da undefined; una variable declarada sin valor da ''. Ambas son falsas en un if, pero significan cosas distintas: la primera es «no me lo has dicho», la segunda es «te digo explícitamente que está vacío».

node -e "console.log(process.env.ORIGEN_CORS)"                              # undefined
ORIGEN_CORS= node -e "console.log(JSON.stringify(process.env.ORIGEN_CORS))" # ""

Mayúsculas por convención. No es una regla de Node, es una convención de Unix heredada: las variables de entorno van en MAYUSCULAS_CON_GUION_BAJO. Respétala aunque el resto del proyecto use camelCase; quien lea un docker-compose.yml o un panel de Heroku espera ese formato.

De dónde salen las variables

Esto es lo que suele quedar borroso. Las variables no vienen de un sitio: vienen del proceso padre, sea cual sea.

Origen Cuándo se usa Ejemplo
Shell interactiva Pruebas puntuales PUERTO=4000 npm start
Fichero .env + dotenv Desarrollo local .env en la raíz del proyecto
Unidad de systemd VPS o servidor propio Environment= o EnvironmentFile=
Ecosistema de PM2 / motor de contenedores Supervisor o Docker env_production (11-03), ENV/-e (11-04)
Panel del proveedor PaaS Heroku, Render, Fly Variables de configuración (lección 11-05)
Secretos de CI Tuberías secrets de GitHub Actions (lección 11-06)

Todos acaban en el mismo sitio: process.env. Por eso la aplicación no tiene que saber cuál de ellos la ha arrancado. Ese desacoplamiento es justo lo que hace que el mismo código funcione en los siete escenarios.

  1. dotenv y sus límites

En desarrollo, exportar quince variables a mano en cada terminal es insufrible. dotenv lee un fichero .env y vuelca su contenido en process.env. El fragmento inicial de src/config/index.js, ya existente desde el módulo 6, es simplemente require('dotenv').config();. Dos límites que hay que tener clarísimos:

  1. dotenv no sobrescribe lo que ya existe en process.env. Si el entorno real ya define PUERTO, el .env se ignora para esa variable. Es el comportamiento correcto: el entorno real manda sobre el fichero de comodidad.
  2. dotenv no es un gestor de secretos. Es un fichero de texto plano en tu disco. En producción no debe existir. Lo que hay en producción son variables inyectadas por el orquestador, el supervisor o la plataforma.

Desde Node 20 existe además node --env-file=.env, que hace lo mismo sin dependencia. Mantenemos dotenv en Escena Viva porque el proyecto ya lo usa y porque su comportamiento es idéntico en todas las versiones que soportamos, pero conviene saber que la alternativa nativa existe.

.env fuera, .env.example dentro

El .gitignore de Escena Viva ignora .env y todas sus variantes locales. Lo que sí se versiona es .env.example: la lista completa de variables que la aplicación necesita, con valores falsos o vacíos. Es documentación ejecutable, y es lo primero que mira alguien que se incorpora al equipo.

# .env.example — copialo a .env y rellena los valores.
# NUNCA pongas valores reales aqui: este fichero SI esta versionado.
NODE_ENV=development
PUERTO=3000
NIVEL_REGISTRO=debug

# Bases de datos
URL_POSTGRES=postgres://escena:escena@localhost:5432/escena_viva
URL_MONGO=mongodb://localhost:27017/escena_viva
URL_REDIS=redis://localhost:6379
# Seguridad — genera valores con: openssl rand -hex 32
JWT_SECRETO=
JWT_SECRETO_ANTERIOR=
SESION_SECRETO=
CSRF_SECRETO=
# Red, procesos y observabilidad (11-02)
ORIGEN_CORS=http://localhost:5173
CONFIAR_EN_PROXY=false
NUMERO_TRABAJADORES=0
CONCURRENCIA_COLA=5
TOKEN_METRICAS=

Regla operativa: cada vez que alguien añade una variable al código, la añade a .env.example en el mismo commit. Si no, el siguiente que clone el repositorio descubrirá la variable que falta cuando la aplicación reviente.

  1. Validar al arrancar y fallar rápido

Aquí está el corazón de la lección. El fallo clásico de configuración no es que una variable falte: es cuándo te enteras de que falta. Imagina que JWT_SECRETO no está definido en producción. Sin validación, la aplicación arranca perfectamente. Sirve el catálogo, muestra las siete sesiones del Festival de Jazz, deja navegar. Tres horas después, Lucia intenta iniciar sesión para comprar dos entradas y jsonwebtoken lanza secretOrPrivateKey must have a value. Un error 500, un cliente perdido y un registro que no dice nada útil. Con validación al arrancar, la aplicación no arranca. El despliegue falla en el primer segundo, la plataforma no envía tráfico a la instancia rota y el mensaje dice exactamente qué falta. Eso es fail fast. Ampliamos src/config/index.js con un esquema zod (la misma librería que ya usamos para validar peticiones en el módulo 6, así que no añadimos dependencias).

// src/config/index.js
'use strict';

require('dotenv').config();

const { z } = require('zod');

// Ayudas de conversion: recuerda que TODO llega como cadena.
const comoEntero = (porDefecto) =>
  z.coerce.number().int().nonnegative().default(porDefecto);

const comoBooleano = (porDefecto) =>
  z.enum(['true', 'false']).default(String(porDefecto)).transform((v) => v === 'true');

// Un secreto util tiene longitud suficiente: 32 caracteres hex como minimo.
const secretoFuerte = z.string().min(32, 'minimo 32 caracteres: openssl rand -hex 32');

const esquemaConfiguracion = z.object({
  NODE_ENV: z.enum(['development', 'test', 'staging', 'production']).default('development'),
  PUERTO: comoEntero(3000),
  NIVEL_REGISTRO: z.enum(['trace', 'debug', 'info', 'warn', 'error', 'fatal']).default('info'),

  URL_POSTGRES: z.string().url(),
  URL_MONGO: z.string().url(),
  URL_REDIS: z.string().url(),

  JWT_SECRETO: secretoFuerte,
  // Opcional: solo existe durante una rotacion (apartado 8).
  JWT_SECRETO_ANTERIOR: secretoFuerte.optional(),
  SESION_SECRETO: secretoFuerte,
  CSRF_SECRETO: secretoFuerte,

  ORIGEN_CORS: z.string().default('http://localhost:5173'),
  CONFIAR_EN_PROXY: comoBooleano(false),

  NUMERO_TRABAJADORES: comoEntero(0),
  CONCURRENCIA_COLA: comoEntero(5),

  TOKEN_METRICAS: z.string().min(16).optional(),
});

const resultado = esquemaConfiguracion.safeParse(process.env);

if (!resultado.success) {
  // Ni logger ni nada elaborado: aqui todavia no hay aplicacion.
  const problemas = resultado.error.issues
    .map((i) => `  - ${i.path.join('.')}: ${i.message}`)
    .join('\n');
  process.stderr.write(
    `\nConfiguracion invalida. Escena Viva no puede arrancar:\n${problemas}\n\n` +
      'Revisa .env.example y las variables del entorno de despliegue.\n\n'
  );
  process.exit(1);
}

const bruta = resultado.data;

// La forma del objeto NO es la del entorno: el resto del codigo pide
// configuracion.seguridad.jwtSecreto, nunca process.env.JWT_SECRETO.
const configuracion = Object.freeze({
  entorno: bruta.NODE_ENV,
  esProduccion: bruta.NODE_ENV === 'production',
  esPrueba: bruta.NODE_ENV === 'test',
  puerto: bruta.PUERTO,
  nivelRegistro: bruta.NIVEL_REGISTRO,
  confiarEnProxy: bruta.CONFIAR_EN_PROXY,
  origenesCors: bruta.ORIGEN_CORS.split(',').map((origen) => origen.trim()),
  baseDatos: Object.freeze({
    postgres: bruta.URL_POSTGRES, mongo: bruta.URL_MONGO, redis: bruta.URL_REDIS,
  }),
  seguridad: Object.freeze({
    jwtSecreto: bruta.JWT_SECRETO,
    jwtSecretoAnterior: bruta.JWT_SECRETO_ANTERIOR,
    sesionSecreto: bruta.SESION_SECRETO,
    csrfSecreto: bruta.CSRF_SECRETO,
  }),
  procesos: Object.freeze({
    numeroTrabajadores: bruta.NUMERO_TRABAJADORES,
    concurrenciaCola: bruta.CONCURRENCIA_COLA,
  }),
  observabilidad: Object.freeze({ tokenMetricas: bruta.TOKEN_METRICAS }),
});

module.exports = { configuracion };

Qué está haciendo cada decisión:

  • z.coerce.number() convierte la cadena a número dentro del esquema, así que nunca vuelves a escribir Number(process.env.ALGO) disperso por el código.
  • comoBooleano acepta solo 'true' o 'false': si alguien escribe CONFIAR_EN_PROXY=si, el arranque falla con un mensaje claro en lugar de interpretarlo como verdadero.
  • .default() centraliza los valores por defecto en un único sitio. El || 4 disperso desaparece, y con él el problema del cero.
  • safeParse + process.exit(1) convierte cualquier problema en una muerte inmediata con código de salida distinto de cero, que es lo que PM2, Docker y las PaaS interpretan como «arranque fallido».
  • Object.freeze en todos los niveles impide que un módulo modifique la configuración en caliente y deje al resto del proceso viendo otra cosa.
  • La forma del objeto exportado no es la del entorno. El resto del código pide configuracion.seguridad.jwtSecreto, no process.env.JWT_SECRETO. Si mañana renombramos la variable, se toca un fichero.

Con JWT_SECRETO ausente, el arranque produce Configuracion invalida. Escena Viva no puede arrancar: - JWT_SECRETO: Required. Cuarenta caracteres de salida que ahorran una tarde entera.

Regla de oro: process.env se lee exclusivamente en src/config/index.js. En cualquier otro fichero está prohibido. Puedes vigilarlo con una regla de ESLint (no-restricted-properties) o con un grep en la tubería de CI de la lección 11-06.

  1. Configuración por entorno sin duplicar ficheros

La tentación es crear config.development.js, config.staging.js y config.production.js. Es un error, porque duplica estructura: cuando añades una variable tienes que tocar tres ficheros y siempre olvidas uno, y porque la única diferencia real entre entornos son los valores, no la forma. En Escena Viva hay un solo esquema y los valores llegan de fuera:

Entorno NODE_ENV De dónde salen los valores
Desarrollo development .env local con dotenv
Prueba test .env.test (módulo 9) y servicios de CI
Preproducción staging Panel del proveedor o secretos del orquestador
Producción production Gestor de secretos + variables de la plataforma

Por qué NODE_ENV=production importa más de lo que parece

NODE_ENV no es una variable como las demás: media ecosistema la mira.

  • Express desactiva la traza de error en las respuestas, activa el caché de vistas y reduce trabajo por petición. Ejecutar Express con NODE_ENV sin definir en producción es más lento y más filtrón.
  • Muchas librerías (validadores, motores de plantillas, React en el frontal) desactivan comprobaciones de desarrollo y avisos.
  • npm: npm ci --omit=dev instala solo las dependencias de producción. Es lo que haremos en la imagen Docker de 11-04 y en la construcción de la PaaS de 11-05. Y por eso todo lo que la aplicación necesita en tiempo de ejecución tiene que estar en dependencies, nunca en devDependencies. Un require de un paquete de desarrollo revienta en producción y en ningún otro sitio.
  • Nuestro propio código: configuracion.esProduccion decide si pino-pretty está activo (11-02), si las cookies llevan secure, si se exponen las trazas.

Solo valen los cuatro valores del enum. Un NODE_ENV=prod mal escrito ya no arranca la aplicación en lugar de dejarla en modo desarrollo silenciosamente.

  1. Qué nunca va en una variable de entorno

Sí No
Cadenas de conexión, claves, tokens Ficheros grandes o binarios (certificados PEM completos)
Interruptores de comportamiento por entorno Lógica de negocio disfrazada de configuración
Nombres de host, puertos, orígenes Datos personales de usuarios
Nivel de registro, banderas de funcionalidad simples Estructuras complejas en JSON codificado

Sobre el último punto: CONFIG_SALAS={"teatro-almendra":{"aforo":800}} es una señal de alarma. Si la configuración necesita estructura, lo que necesitas es un fichero montado (que puede venir de un secreto del orquestador) o una tabla en la base de datos, no una variable de entorno con JSON dentro que nadie puede leer ni depurar.

  1. Gestión de secretos en serio

Las variables de entorno están mejor que el código versionado, pero no son seguras. Se filtran por sitios que no esperas:

  • Volcados de proceso. Un core dump contiene el bloque de entorno completo.
  • Registros y trazas. Un console.log(process.env) en una depuración de urgencia, o una librería que vuelca el contexto en un informe de error, y tu JWT_SECRETO acaba en el servicio de registro de terceros.
  • /proc/<pid>/environ. En Linux, cualquier proceso del mismo usuario puede leer el entorno de otro.
  • Imágenes de contenedor. Un ENV JWT_SECRETO=... en el Dockerfile queda grabado en una capa de la imagen, y la imagen se publica en un registro (11-04).
  • La salida de un error. Algunos clientes de base de datos incluyen la URL completa —con usuario y contraseña— en el mensaje de excepción.
  • Subprocesos. Todo hijo hereda el entorno del padre por defecto.

Por eso existen los gestores de secretos. El panorama honesto:

Solución Cómo funciona A favor En contra
Variables del proveedor (Heroku, Render, Fly) Panel o CLI, inyectadas al arrancar Cero infraestructura, inmediato Sin rotación automática ni auditoría fina
HashiCorp Vault Servicio dedicado; la app pide el secreto con un token Rotación, auditoría, secretos dinámicos y de vida corta Operar Vault es un proyecto en sí mismo
AWS Secrets Manager / GCP Secret Manager Servicio gestionado del proveedor, con IAM Integración nativa, rotación programada Ata al proveedor, coste por secreto y acceso
Ficheros montados (secretos de Kubernetes/Swarm) El secreto aparece como fichero en el contenedor No aparece en environ ni en la imagen Requiere orquestador; hay que leer el fichero
Cifrado en el repositorio (SOPS, git-crypt) Secretos cifrados versionados; se descifran al desplegar Historial y revisión por PR de los cambios La clave maestra sigue teniendo que vivir en algún sitio

Escena Viva empieza con variables del proveedor (11-05) porque el tamaño del proyecto no justifica operar Vault, y deja el camino abierto: como toda la lectura pasa por src/config/index.js, migrar a ficheros montados es cambiar quince líneas en un único fichero. Un patrón intermedio muy útil es el sufijo _FILE: si existe JWT_SECRETO_FILE, se lee el contenido de ese fichero; si no, se usa JWT_SECRETO. Así la misma imagen sirve para una PaaS con variables y para un orquestador con secretos montados.

  1. Rotación de secretos sin parada

Los secretos caducan. Se rotan porque alguien deja el equipo, porque lo exige una auditoría o porque se han filtrado. El problema es que cambiar JWT_SECRETO de golpe invalida todos los tokens en circulación: Lucia, Marc y los organizadores de las tres salas quedan desconectados a mitad de compra. La solución es aceptar dos claves durante la transición. Se firma siempre con la nueva y se verifica contra ambas.

// src/servicios/tokens.js (fragmento adaptado para la rotacion)
'use strict';

const jwt = require('jsonwebtoken');
const { configuracion } = require('../config/index.js');
const { ErrorAutenticacion } = require('../errores.js');
const { jwtSecreto, jwtSecretoAnterior } = configuracion.seguridad;

// Se firma SIEMPRE con el secreto vigente.
const firmarAcceso = (carga) => jwt.sign(carga, jwtSecreto, { expiresIn: '15m' });

function verificarAcceso(token) {
  // Orden importante: primero el vigente (caso mayoritario).
  for (const clave of [jwtSecreto, jwtSecretoAnterior].filter(Boolean)) {
    try {
      return jwt.verify(token, clave);
    } catch (error) {
      // Token caducado o malformado: no lo tapamos.
      if (error.name !== 'JsonWebTokenError') throw error;
    }
  }
  throw new ErrorAutenticacion('Token no valido');
}

module.exports = { firmarAcceso, verificarAcceso };

El procedimiento completo, con tokens de acceso de 15 minutos: (1) generar la clave nueva con openssl rand -hex 32; (2) poner el valor actual en JWT_SECRETO_ANTERIOR y el nuevo en JWT_SECRETO; (3) reiniciar con recarga sin cortes (lección 11-03), y desde ese instante se firma con la nueva y se acepta la vieja; (4) esperar más que la vida del token más largo —con acceso de 15 minutos y refresco de 7 días, hasta que caduquen los refrescos o se fuerce su rotación—; y (5) borrar JWT_SECRETO_ANTERIOR y reiniciar otra vez. Fíjate en que el esquema ya soporta esto: JWT_SECRETO_ANTERIOR es .optional(), así que la aplicación arranca igual con una clave o con dos. La rotación no requiere tocar código.

  1. Cuando un secreto se filtra

Ocurre. Alguien pega la URL de PostgreSQL en un chat, o hace commit del .env un viernes. El orden de las acciones importa:

  1. Revocar y rotar primero. El secreto filtrado es válido hasta que deja de serlo; todo lo demás puede esperar.
  2. Revisar accesos después, con los registros de auditoría del módulo 8: ¿hubo conexiones desde IP desconocidas?, ¿se emitieron tokens raros?
  3. Limpiar el historial al final, sabiendo que no basta. Reescribir el historial de Git (git filter-repo) no borra el secreto de los clones que ya existen, ni de los forks, ni de la caché de la interfaz web, ni de los rastreadores automáticos que escanean GitHub buscando credenciales. Un secreto que ha estado en un repositorio remoto está comprometido para siempre. Borrar el commit es higiene, no remedio.
  4. Evitar la reincidencia. Un gancho de pre-commit que detecte patrones de credenciales (el proyecto ya tiene husky desde el módulo 9) y escaneo de secretos en la tubería de CI (11-06).

  1. Configuración en caliente frente a reinicio

Hay dos formas de aplicar un cambio de configuración: recargarla en el proceso vivo, o reiniciar el proceso.

En caliente Reinicio
Complejidad Alta: cada módulo debe reaccionar al cambio Nula
Estado Riesgo de inconsistencia entre módulos Todo coherente desde el segundo cero
Corte de servicio Ninguno Ninguno si hay recarga sin cortes
Auditoría Difícil saber qué valores había en cada momento El arranque deja constancia

Escena Viva reinicia, y por eso configuracion está congelado. La razón es que ya tenemos apagado ordenado (módulo 6) y vamos a tener recarga secuencial de trabajadores (11-03): reiniciar cuesta segundos y no pierde ni una petición. La complejidad de recargar en caliente solo se justifica cuando el arranque es carísimo, que no es nuestro caso. Nota importante: una bandera de funcionalidad no es configuración de despliegue. Si quieres activar la venta anticipada del Festival de Jazz a las 10:00 sin reiniciar, eso va en base de datos o en un servicio de banderas, no en process.env.

  1. Tabla de referencia de las variables de Escena Viva

Esta tabla es la referencia que usaremos en el resto del módulo: el .env.example, el ecosystem.config.js de PM2, el docker-compose.yml, el panel de la PaaS y los secretos de CI derivan todos de aquí.

Variable Tipo Obligatoria Por defecto Dónde se usa
NODE_ENV enum No development Express, npm, configuracion.esProduccion
PUERTO entero No 3000 src/servidor.js
NIVEL_REGISTRO enum No info pino (11-02)
URL_POSTGRES url Sí — src/db/sequelize.js
URL_MONGO url Sí — src/db/conexion.js
URL_REDIS url Sí — src/db/redis.js, sesión, límites, colas
JWT_SECRETO secreto ≥32 Sí — src/servicios/tokens.js
JWT_SECRETO_ANTERIOR secreto ≥32 No — Rotación (apartado 8)
SESION_SECRETO secreto ≥32 Sí — src/middleware/sesion.js
CSRF_SECRETO secreto ≥32 Sí — src/middleware/csrf.js
ORIGEN_CORS lista No http://localhost:5173 src/middleware/cors.js
CONFIAR_EN_PROXY booleano No false trust proxy (11-05)
NUMERO_TRABAJADORES entero No 0 (= núcleos) src/cluster.js, pool de Sequelize
CONCURRENCIA_COLA entero No 5 src/procesos/consumidor-entradas.js
TOKEN_METRICAS secreto ≥16 No — /metricas protegido (11-02)

Errores Comunes y Consejos

  • Leer process.env fuera de src/config/index.js. Es el error que más daño hace a largo plazo: se pierde la validación, los valores por defecto se dispersan y nadie sabe qué variables usa la aplicación. Prohíbelo con lint.
  • Confundir «no está en el código» con «es seguro». Una variable de entorno es visible en /proc, en volcados y en cualquier vuelco accidental. Trátala como un secreto en tránsito, no como una caja fuerte.
  • Consejo: genera todos los secretos con openssl rand -hex 32 y nunca reutilices el mismo valor para JWT_SECRETO, SESION_SECRETO y CSRF_SECRETO: si uno se filtra, se filtran los tres. Y actualiza .env.example y la tabla del apartado 11 en el mismo commit en que añadas una variable.

Ejercicios

Ejercicio 1 — Diagnóstico de configuración

Escribe un script scripts/comprobar-configuracion.js que cargue src/config/index.js e imprima una tabla con todas las variables del esquema indicando, para cada una, si viene del entorno o de un valor por defecto, censurando los secretos. Debe salir con código 1 si la configuración es inválida (aprovechando el process.exit(1) que ya hace el módulo).

Ejercicio 2 — Soporte de _FILE

Amplía src/config/index.js para que cualquier variable pueda venir de un fichero: si existe <NOMBRE>_FILE, se lee el contenido de esa ruta (recortando el salto de línea final) y se usa como valor de <NOMBRE>. Aplícalo antes de validar con zod.

Ejercicio 3 — Rotación simulada

Escribe una prueba de integración que verifique que un token firmado con JWT_SECRETO_ANTERIOR sigue siendo aceptado por verificarAcceso, y que uno firmado con una tercera clave desconocida es rechazado con ErrorAutenticacion.

Soluciones

Ejercicio 1. La clave es no volver a leer process.env en el script salvo para saber el origen, y censurar por nombre:

// scripts/comprobar-configuracion.js
'use strict';

// Si la configuracion fuese invalida, este require ya habria salido con codigo 1.
const { configuracion } = require('../src/config/index.js');

const SENSIBLES = /SECRETO|TOKEN|PASSWORD|URL_POSTGRES|URL_MONGO/;
const NOMBRES = [
  'NODE_ENV', 'PUERTO', 'NIVEL_REGISTRO', 'URL_POSTGRES', 'URL_MONGO',
  'URL_REDIS', 'JWT_SECRETO', 'JWT_SECRETO_ANTERIOR', 'SESION_SECRETO',
  'CSRF_SECRETO', 'ORIGEN_CORS', 'CONFIAR_EN_PROXY', 'NUMERO_TRABAJADORES',
  'CONCURRENCIA_COLA', 'TOKEN_METRICAS',
];

const censurar = (nombre, valor) =>
  valor === undefined ? '(sin definir)' : SENSIBLES.test(nombre) ? '********' : String(valor);

console.table(NOMBRES.map((nombre) => ({
  variable: nombre,
  origen: process.env[nombre] === undefined ? 'por defecto' : 'entorno',
  valor: censurar(nombre, process.env[nombre]),
})));

Ejercicio 2. Se preprocesa process.env antes del safeParse:

const fs = require('node:fs');

function resolverFicheros(entorno) {
  const resuelto = { ...entorno };
  for (const [clave, valor] of Object.entries(entorno)) {
    if (!clave.endsWith('_FILE') || !valor) continue;
    try {
      // Lectura sincrona a proposito: estamos en el arranque.
      resuelto[clave.slice(0, -'_FILE'.length)] = fs.readFileSync(valor, 'utf8').trimEnd();
    } catch (error) {
      process.stderr.write(`No se pudo leer ${clave}=${valor}: ${error.message}\n`);
      process.exit(1);
    }
  }
  return resuelto;
}

const resultado = esquemaConfiguracion.safeParse(resolverFicheros(process.env));

Se lee de forma síncrona a propósito: estamos en el arranque, antes de que exista bucle de eventos con trabajo, y queremos fallar antes de continuar.

Ejercicio 3. Con mocha y chai, firmando a mano con cada clave:

const { expect } = require('chai');
const jwt = require('jsonwebtoken');
const { configuracion } = require('../../src/config/index.js');
const { verificarAcceso } = require('../../src/servicios/tokens.js');

describe('rotacion de JWT_SECRETO', () => {
  const carga = { sub: 'usr-lucia', rol: 'asistente' };

  it('acepta un token firmado con el secreto anterior', () => {
    const anterior = configuracion.seguridad.jwtSecretoAnterior;
    expect(anterior, 'define JWT_SECRETO_ANTERIOR en .env.test').to.be.a('string');
    const token = jwt.sign(carga, anterior, { expiresIn: '15m' });
    expect(verificarAcceso(token)).to.include({ sub: 'usr-lucia' });
  });
  it('rechaza un token firmado con una clave desconocida', () => {
    const token = jwt.sign(carga, 'x'.repeat(32), { expiresIn: '15m' });
    expect(() => verificarAcceso(token)).to.throw(/no valido/i);
  });
});

Conclusión

La configuración de Escena Viva ha dejado de ser un .env en tu portátil para convertirse en un contrato explícito: un esquema zod que enumera cada variable, convierte su tipo, aplica valores por defecto y mata el proceso con un mensaje comprensible si falta un secreto. Un único punto de lectura de process.env, un .env.example que documenta el contrato, una tabla de referencia que servirá de base para el fichero de PM2, el Dockerfile, el docker-compose.yml, el panel de la PaaS y los secretos de CI. Y, además, una estrategia de rotación que permite cambiar la clave de firma de los JWT sin desconectar a nadie. Con la configuración resuelta, aparece la siguiente pregunta incómoda: cuando algo falle en producción a las tres de la madrugada —y fallará—, ¿cómo te enteras? En la próxima lección, Registro y Monitorización en Producción, sustituimos los console.log por registro estructurado en JSON con pino, colgamos el registrador hijo del idPeticion que llevamos arrastrando desde el módulo 6 para poder seguir una compra fallida por todos sus rastros, exponemos métricas con prom-client —incluido el retraso del bucle de eventos que ya sabemos medir— e implementamos las sondas /salud/vivo y /salud/listo que el resto del módulo dará por supuestas.

Curso de Node.js: De Principiante a Avanzado

Módulo 1: Introducción a Node.js

Módulo 2: Conceptos Básicos

Módulo 3: Sistema de Archivos y E/S

Módulo 4: HTTP y Servidores Web

Módulo 5: NPM y Gestión de Paquetes

Módulo 6: Framework Express.js

Módulo 7: Bases de Datos y ORMs

Módulo 8: Autenticación y Autorización

Módulo 9: Pruebas y Depuración

Módulo 10: Temas Avanzados

Módulo 11: Despliegue y DevOps

Módulo 12: Proyectos del Mundo Real

© Copyright 2026. Todos los derechos reservados