Configuración y variables de entorno
Configuración y variables de entorno
La configuración es el problema silencioso de todo backend. Funciona en tu máquina, pasa los tests, y a las tres semanas el servicio se cae en producción a las 3 de la mañana porque REDIS_URL no estaba definida y alguien recién llegó a ese código. El síntoma es siempre el mismo: un undefined que viajó desde process.env hasta el fondo de un service, se convirtió en NaN o en una URL rota, y explotó lejos de su origen.
Este capítulo trata de mover ese fallo hacia adelante en el tiempo: que la app se caiga al arrancar si le falta configuración, con un mensaje claro, en vez de degradarse a mitad de un request. Y de darle a la configuración un solo lugar tipado por donde entrar, en vez de esparcir lecturas crudas de process.env por todo el código. Todo esto se apoya en la disciplina de los 12 factores, que el capítulo referencia constantemente.
El problema de calidad que atacamos
Tres cosas rompen la configuración de un servicio NestJS:
- Config leída en runtime, no en arranque.
process.env.Xse evalúa cuando se ejecuta esa línea. Si esa línea vive en un handler que se ejecuta una vez por hora, el fallo por variable ausente aparece una hora después del deploy. - Config dispersa.
process.env.API_KEYaparece en 14 archivos. Renombrar la variable es una cacería. Nadie sabe qué variables necesita el servicio sin hacergrep. - Config sin tipo. Todo lo que sale de
process.envesstring | undefined.PORTes"3000", no3000.FEATURE_ENABLEDes"false", que es truthy. Los bugs de coerción son endémicos.
@nestjs/config resuelve los tres si lo usás con disciplina. Sin disciplina, es un process.env con pasos extra.
@nestjs/config: el punto de entrada
ConfigModule carga variables de entorno (desde .env y desde el entorno real del proceso) y las expone vía ConfigService. La instalación es directa:
bun add @nestjs/config
El registro mínimo, y la primera decisión importante: isGlobal.
// app.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true, // ConfigService disponible en toda la app sin re-importar
cache: true, // cachea process.env; get() deja de tocar el objeto en cada lectura
}),
],
})
export class AppModule {}
isGlobal: trueregistraConfigModuleen el contenedor global. Sin esto, cada módulo que quieraConfigServicetiene que importarConfigModuleexplícitamente. Para configuración raíz, global es lo idiomático.cache: truehace queConfigService#getlea de un objeto cacheado en lugar de acceder aprocess.enven cada llamada. El acceso aprocess.enven Node.js no es gratis (invoca un getter nativo); si leés config en un hot path, cachear reduce ese costo. Con validación yloadactivos, además garantiza que leés siempre los valores ya procesados.
Carga de .env por entorno
El orden de precedencia importa: las variables ya presentes en el entorno del proceso ganan sobre las del archivo .env. Eso es exactamente lo que querés en producción, donde la config la inyecta el orquestador (Kubernetes, ECS, Railway) y el .env no existe.
ConfigModule.forRoot({
isGlobal: true,
cache: true,
envFilePath: [`.env.${process.env.NODE_ENV ?? 'development'}`, '.env'],
expandVariables: true, // permite ${VAR} dentro de otras variables en el .env
});
envFilePathacepta un array; el primer archivo que defina una variable gana. Así tenés.env.development,.env.test,.env.productioncon un.envde base como fallback.expandVariables: truehabilita interpolación tipoDATABASE_URL=postgres://user:pass@${DB_HOST}:${DB_PORT}/db. Útil para no repetir host/puerto.- En producción real vas a querer
ignoreEnvFile: true: no hay archivo.env, la config viene del entorno. Esto es el factor III de los 12 factores — config en el entorno.
flowchart LR
A[Proceso arranca] --> B{ignoreEnvFile?}
B -- "false (dev)" --> C["Lee envFilePath\n.env.development, .env"]
B -- "true (prod)" --> D[Salta archivos]
C --> E[Merge con process.env\nel entorno real gana]
D --> E
E --> F["validate() / validationSchema"]
Validación al arranque: fail-fast
Este es el corazón del capítulo. La app tiene que negarse a arrancar si la configuración es inválida o está incompleta. No hay un escenario razonable en el que quieras un servicio corriendo con DATABASE_URL ausente “por si acaso funciona”. Un proceso que no arranca es un incidente que ves en el deploy; un undefined que viaja es un incidente que ve tu cliente.
Por qué fail-fast y no defaults
El antipatrón es defenderse en runtime:
// ANTIPATRÓN: default peligroso, fallo diferido
const timeout = Number(process.env.HTTP_TIMEOUT) || 30000;
const dbUrl = process.env.DATABASE_URL ?? 'postgres://localhost:5432/dev';
El dbUrl con default a localhost es una trampa: en producción, si olvidás setear DATABASE_URL, el servicio arranca feliz apuntando a una base que no existe, y falla en el primer query con un error de conexión que no menciona configuración. Peor todavía si el default apunta a una base que sí existe pero es la de desarrollo. El default silencioso convierte un error de config en un error de datos.
Fail-fast invierte la lógica: si falta o es inválida, el proceso muere en el bootstrap() con un mensaje que nombra la variable.
Tenés tres formas de validar. Elegí una y usala consistentemente.
Opción A: Joi (schema declarativo)
Joi es la opción histórica de la documentación de Nest. validationSchema recibe un objeto Joi; Nest valida el entorno completo contra él al arrancar.
bun add joi
import * as Joi from 'joi';
ConfigModule.forRoot({
isGlobal: true,
validationSchema: Joi.object({
NODE_ENV: Joi.string()
.valid('development', 'production', 'test', 'provision')
.default('development'),
PORT: Joi.number().port().default(3000),
DATABASE_URL: Joi.string().uri().required(),
JWT_SECRET: Joi.string().min(32).required(),
REDIS_URL: Joi.string().uri().required(),
}),
validationOptions: {
allowUnknown: true, // permite variables del sistema que no modelás
abortEarly: false, // reporta TODOS los errores, no solo el primero
},
});
Dos opciones que valen oro:
abortEarly: falsete lista todas las variables mal en un solo arranque. Contrue, arreglás una, redeployás, descubrís la siguiente. Confalse, las ves todas juntas.allowUnknown: trueevita que variables del sistema (PATH,HOME, las que inyecta el orquestador) rompan la validación. Si querés un entorno hermético, ponéfalsey modelá todo — más estricto, más ruidoso.
Opción B: class-validator (schema como clase)
Si ya usás class-validator para DTOs (ver validación y transformación), reutilizás el mismo mental model. Definís una clase y una función validate:
// config/env.validation.ts
import { plainToInstance } from 'class-transformer';
import { IsEnum, IsInt, IsString, IsUrl, MinLength, Max, Min, validateSync } from 'class-validator';
enum Environment {
Development = 'development',
Production = 'production',
Test = 'test',
}
class EnvironmentVariables {
@IsEnum(Environment)
NODE_ENV: Environment;
@IsInt()
@Min(0)
@Max(65535)
PORT: number;
@IsUrl({ require_tld: false })
DATABASE_URL: string;
@IsString()
@MinLength(32)
JWT_SECRET: string;
}
export function validate(config: Record<string, unknown>) {
const validated = plainToInstance(EnvironmentVariables, config, {
enableImplicitConversion: true, // "3000" -> 3000
});
const errors = validateSync(validated, { skipMissingProperties: false });
if (errors.length > 0) {
throw new Error(errors.toString());
}
return validated;
}
import { validate } from './config/env.validation';
ConfigModule.forRoot({ isGlobal: true, validate });
enableImplicitConversion: true es lo que convierte los string de process.env a los tipos declarados (number, boolean). Sin eso, PORT sigue siendo "3000" y @IsInt() falla.
Opción C: Zod (recomendada hoy)
Zod se volvió el estándar de facto para validación con inferencia de tipos en TypeScript, y NestJS 12 avanza hacia Standard Schema (la interfaz común que implementan Zod, Valibot y ArkType). En Nest 11 lo integrás con la misma puerta validate:
bun add zod
// config/env.schema.ts
import { z } from 'zod';
export const envSchema = z.object({
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
PORT: z.coerce.number().int().min(0).max(65535).default(3000),
DATABASE_URL: z.string().url(),
JWT_SECRET: z.string().min(32),
REDIS_URL: z.string().url(),
});
// Tipo derivado del schema: una sola fuente de verdad
export type Env = z.infer<typeof envSchema>;
export function validate(config: Record<string, unknown>): Env {
const parsed = envSchema.safeParse(config);
if (!parsed.success) {
// Mensaje agrupado y legible en el arranque
throw new Error(
`Configuración inválida:\n${z.prettifyError(parsed.error)}`,
);
}
return parsed.data;
}
z.coerce.number() resuelve la coerción de tipos ("3000" → 3000), y z.infer te da el tipo Env gratis, sin duplicar la forma. Esa unión de validación runtime + tipo estático es la razón por la que Zod desplazó a Joi en proyectos nuevos.
Cualquiera de las tres cumple el objetivo: el proceso no llega a escuchar en el puerto si la config está mal.
Configuración tipada por namespace: registerAs
Validar el entorno es la mitad. La otra mitad es cómo consumís esos valores. El antipatrón es leer process.env (o incluso configService.get('DATABASE_URL') con strings mágicos) desde cualquier lado.
El antipatrón: process.env disperso
// ANTIPATRÓN: acoplamiento a process.env por todo el código
@Injectable()
export class PaymentService {
async charge(amount: number) {
const key = process.env.STRIPE_KEY; // sin tipo, sin validar aquí
const timeout = Number(process.env.STRIPE_TIMEOUT); // coerción repetida
// ...
}
}
Problemas concretos: el servicio depende directamente de process.env (imposible de testear sin mutar el entorno global), el nombre de la variable está hardcodeado como string, y la coerción se repite en cada punto de lectura.
El patrón: registerAs + ConfigType
registerAs agrupa un conjunto de variables bajo un namespace con su forma ya resuelta. La función factory se evalúa una vez y devuelve un objeto tipado.
// config/stripe.config.ts
import { registerAs } from '@nestjs/config';
export default registerAs('stripe', () => ({
apiKey: process.env.STRIPE_KEY!,
timeoutMs: Number(process.env.STRIPE_TIMEOUT ?? 10_000),
webhookSecret: process.env.STRIPE_WEBHOOK_SECRET!,
}));
Lo cargás en el módulo raíz, junto con la validación del entorno:
import stripeConfig from './config/stripe.config';
import databaseConfig from './config/database.config';
ConfigModule.forRoot({
isGlobal: true,
validate, // valida process.env crudo (fail-fast)
load: [stripeConfig, databaseConfig], // agrupa en namespaces tipados
});
Y ahora el servicio inyecta solo su namespace, con tipo completo, sin tocar process.env ni ConfigService:
import { Inject, Injectable } from '@nestjs/common';
import { ConfigType } from '@nestjs/config';
import stripeConfig from '../config/stripe.config';
@Injectable()
export class PaymentService {
constructor(
@Inject(stripeConfig.KEY)
private readonly config: ConfigType<typeof stripeConfig>,
) {}
async charge(amount: number) {
// config.apiKey es string, config.timeoutMs es number. Autocompletado real.
// ...
}
}
registerAs('stripe', factory) expone .KEY (el token de inyección) y ConfigType<typeof stripeConfig> deriva el tipo del return de la factory. El servicio ya no sabe que existe process.env: depende de una abstracción tipada, que es inyectable y trivial de mockear en tests. Esto es Inversión de Dependencias del capítulo SOLID en NestJS aplicada a la configuración.
ConfigService tipado y getOrThrow
Cuando no querés un namespace entero sino un valor suelto, tipá el ConfigService y usá getOrThrow para valores que no pueden faltar:
constructor(private readonly config: ConfigService<Env, true>) {}
// El segundo genérico `true` marca el servicio como "validado":
// get() devuelve el tipo exacto en vez de `T | undefined`.
const port = this.config.get('PORT', { infer: true }); // number
// getOrThrow lanza si falta, en vez de devolver undefined
const dbUrl = this.config.getOrThrow('DATABASE_URL', { infer: true });
getOrThrow es la contrapartida runtime del fail-fast: si por algún camino un valor requerido no está, preferís una excepción explícita a un undefined propagándose.
Config parcial por feature module: forFeature
Un feature module puede registrar su propia porción de config sin cargarla globalmente, con ConfigModule.forFeature:
// payments/payments.module.ts
@Module({
imports: [ConfigModule.forFeature(stripeConfig)],
providers: [PaymentService],
})
export class PaymentsModule {}
forFeature registra el namespace stripe solo para los providers de este módulo. Útil cuando una configuración pertenece conceptualmente a un dominio y no querés inflar el registro global. La validación del entorno sigue viviendo en el forRoot raíz; forFeature solo agrupa y expone.
flowchart TD
subgraph Bootstrap
E["process.env + .env"] --> V["validate() fail-fast\nJoi / class-validator / Zod"]
V -->|inválido| X["throw -> el proceso no arranca"]
V -->|válido| L["load: registerAs('db'), registerAs('stripe')"]
end
L --> NS1["namespace 'database' tipado"]
L --> NS2["namespace 'stripe' tipado"]
NS1 -->|"@Inject(dbConfig.KEY)"| S1[DatabaseService]
NS2 -->|"@Inject(stripeConfig.KEY)"| S2[PaymentService]
Alineación con los 12 factores
La configuración de un servicio NestJS bien hecho es, literalmente, el factor III de los 12 factores implementado. Vale traer los tres principios que más impactan acá:
- Config en el entorno, no en el código. Nada de valores de conexión, claves o feature flags embebidos en archivos
.ts. Todo entra porprocess.env, validado en el arranque. El mismo artefacto (la misma imagen de Docker) corre en dev, staging y prod; lo único que cambia es el entorno inyectado. - Paridad dev/prod. Los tres entornos leen las mismas variables con el mismo schema de validación.
.env.developmenty las variables inyectadas en prod difieren en valores, no en forma. Si en prod hace faltaREDIS_URLy en dev no la modelaste, tenés drift de configuración: eso es lo que el schema único previene. - Nada de
if (env === 'production')disperso. El antipatrón clásico:
// ANTIPATRÓN: comportamiento ramificado por entorno, esparcido
if (process.env.NODE_ENV === 'production') {
app.enableCors({ origin: 'https://app.com' });
} else {
app.enableCors({ origin: '*' });
}
El problema no es la rama en sí, es que se replica en 20 lugares y el comportamiento de producción se vuelve imposible de razonar. La alternativa es tratar la diferencia como un valor de configuración, no como una rama:
// MEJOR: el entorno provee el valor, el código no ramifica
const corsOrigin = config.getOrThrow('CORS_ORIGIN', { infer: true });
app.enableCors({ origin: corsOrigin });
Ahora CORS_ORIGIN es una variable como cualquier otra: validada, tipada, distinta por entorno, y sin if de por medio. El código deja de conocer los nombres de los entornos.
Configuración vs secretos
No todo lo que entra por process.env es lo mismo. Hay una diferencia operativa entre configuración y secretos:
- Configuración:
PORT,LOG_LEVEL,CORS_ORIGIN,NODE_ENV. No es sensible. Filtrarla no compromete nada. Puede vivir en.env.example, en eldocker-compose.yml, en el README. - Secretos:
JWT_SECRET,DATABASE_PASSWORD,STRIPE_KEY, tokens de API. Filtrarlos es un incidente de seguridad. Nunca van al repositorio, ni siquiera en un.env(que puede terminar commiteado por accidente).
Ambos entran a la app por el mismo canal (process.env, validado por ConfigModule), pero su origen y su manejo difieren.
La regla del .gitignore y el .env.example
# .gitignore
.env
.env.*
!.env.example
.envnunca se commitea. Es la fuente más común de filtración de secretos en GitHub. Ungit add .distraído y tuSTRIPE_KEYestá en el historial para siempre (borrarlo del historial es reescribir la historia, nogit rm)..env.examplesí se commitea. Es la documentación viva de qué variables necesita el servicio, con valores de ejemplo o vacíos, sin secretos reales:
# .env.example — plantilla, SIN valores sensibles reales
NODE_ENV=development
PORT=3000
DATABASE_URL=postgres://user:password@localhost:5432/appdb
JWT_SECRET=change-me-min-32-chars-000000000000
REDIS_URL=redis://localhost:6379
El .env.example cierra el círculo con la validación: la lista de variables del schema y la lista del .env.example deberían coincidir. Un nuevo integrante copia .env.example a .env, rellena los valores, y el schema le dice al arrancar qué le falta.
Secret managers
En producción los secretos no viven en archivos ni en variables planas del orquestador: viven en un secret manager que los cifra en reposo, los rota y audita quién los lee. El patrón es cargarlos antes del ConfigModule o mediante una factory async, de modo que para cuando la app valida su config, los secretos ya están en el entorno.
sequenceDiagram
participant K as Secret Manager<br/>(Vault / AWS SM / Doppler)
participant O as Orquestador / Runtime
participant N as NestJS bootstrap
O->>K: solicita secretos (identidad del pod/tarea)
K-->>O: secretos descifrados
O->>N: inyecta en process.env
N->>N: ConfigModule valida (fail-fast)
N->>N: registerAs expone namespaces tipados
Opciones habituales del ecosistema:
- HashiCorp Vault: secretos dinámicos y rotación. Un sidecar o un init step los materializa en el entorno o en un archivo que el runtime lee.
- AWS Secrets Manager / SSM Parameter Store: nativo en AWS. Se inyecta vía ECS/EKS o se lee con el SDK en un provider async al arrancar.
- Doppler: capa de gestión de secretos multi-entorno;
doppler run -- bun startinyecta los secretos como variables de entorno sin que el código sepa de Doppler.
La clave arquitectónica: la app no conoce el secret manager. Recibe secretos por process.env como cualquier otra variable. Cambiar de Vault a Doppler no toca una línea de tu código NestJS — solo cambia cómo el entorno se puebla antes del arranque. Eso es, otra vez, config en el entorno.
Todo lo relativo a proteger esos secretos una vez dentro de la app (no loguearlos, no exponerlos en respuestas de error, rotación) se cubre en seguridad.
Catálogo de antipatrones
flowchart LR
A["process.env disperso"] --> A2["namespace tipado con registerAs"]
B["secreto hardcodeado"] --> B2["secret manager -> process.env"]
C["sin validación de env"] --> C2["validate fail-fast en forRoot"]
D["config duplicada"] --> D2["una fuente: el schema"]
E["default peligroso en prod"] --> E2["required + getOrThrow"]
process.envpor todos lados. Cada lectura cruda acopla el código al entorno global, sin tipo ni validación local. → Namespaces conregisterAs+ConfigType.- Secretos hardcodeados.
const key = 'sk_live_...'en el código. Queda en el historial de git, se comparte en cada clone, no se puede rotar sin deploy. → Secret manager que pueblaprocess.env. - Sin validación de entorno. La app arranca con config incompleta y falla lejos, en runtime. →
validate/validationSchemaconrequired, fail-fast en elbootstrap. - Config duplicada. El mismo valor definido en dos lados (un default en el código y una variable de entorno) que se contradicen. → Una sola fuente de verdad: el schema de validación, con
.env.examplecomo espejo. - Defaults peligrosos en producción.
?? 'localhost',?? 'dev-secret'. Convierten un error de config en un error de datos o en un hueco de seguridad silencioso. → Sin default para lo que es obligatorio;required()y que el proceso muera si falta. - Ramas
if (NODE_ENV === 'production')esparcidas. Comportamiento de prod imposible de razonar. → El entorno provee el valor; el código no ramifica por nombre de entorno.
Checklist
-
ConfigModule.forRoot({ isGlobal: true, cache: true })en el módulo raíz. - Validación de entorno con fail-fast:
validate(Zod/class-validator) ovalidationSchema(Joi). Elegiste una y la usás en todo el proyecto. - Con Joi,
abortEarly: falsepara ver todos los errores de config en un solo arranque. - Ningún
process.envfuera de los archivosregisterAs/validate. El resto del código lee config tipada. - Cada dominio con su namespace vía
registerAs, inyectado con@Inject(cfg.KEY)yConfigType. - Valores obligatorios con
required()en el schema ygetOrThrowal leerlos. Sin defaults peligrosos. -
.enven.gitignore;.env.examplecommiteado y sincronizado con el schema. - Cero secretos en el repositorio ni en el código. Los secretos llegan por el entorno desde un secret manager.
- Sin
if (NODE_ENV === 'production')disperso: las diferencias por entorno son valores de config, no ramas. - Paridad dev/prod: los tres entornos leen el mismo schema; solo cambian los valores.
Con la configuración validada, tipada y fuera del código, el servicio arranca de forma predecible. Lo que sigue es ver qué hace en producción: logging estructurado, trazas, métricas y health checks. Eso es el próximo capítulo: Observabilidad →