Configuración y variables de entorno

Por: Artiko
nestjsbuenas-practicasconfiguracionvariables-de-entornodoce-factoresseguridadconfig-module

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:

  1. Config leída en runtime, no en arranque. process.env.X se 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.
  2. Config dispersa. process.env.API_KEY aparece en 14 archivos. Renombrar la variable es una cacería. Nadie sabe qué variables necesita el servicio sin hacer grep.
  3. Config sin tipo. Todo lo que sale de process.env es string | undefined. PORT es "3000", no 3000. FEATURE_ENABLED es "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 {}

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
});
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:

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á:

// 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:

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
# .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:

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"]

Checklist

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 →