Inicio / TypeScript / Node.js Backend con TypeScript / Variables de entorno y configuración

Variables de entorno y configuración

Gestión segura de secrets con dotenv, validación con Zod y config centralizada.

🔒 Solo lectura
📖

Estás en modo lectura

Puedes leer toda la lección, pero para marcar progreso, hacer ejercicios y ganar XP necesitas una cuenta Pro.

Desbloquear por $9/mes

Variables de entorno y configuración

El problema de la configuración hardcodeada

// ❌ Nunca hagas esto
const db = new PrismaClient({
  datasourceUrl: 'postgresql://user:password123@localhost:5432/mydb',
});

const jwt = sign(payload, 'mi-secreto-muy-inseguro');

Problemas:

  • Secretos expuestos en el repositorio
  • Configuración diferente por entorno (dev/test/prod) imposible de manejar
  • Escalar o rotar secretos requiere cambios de código

La solución: variables de entorno + validación al inicio.


dotenv: cargar archivos .env

pnpm add dotenv
# .env (NUNCA commitear al repositorio)
NODE_ENV=development
PORT=3000
DATABASE_URL=postgresql://sguser:password@localhost:5432/myapp_dev
JWT_SECRET=super-secret-key-with-at-least-32-characters-here
JWT_EXPIRES=7d
LOG_LEVEL=debug
# .env.example (SÍ commitear — sirve como documentación)
NODE_ENV=development
PORT=3000
DATABASE_URL=postgresql://USER:PASSWORD@HOST:PORT/DB_NAME
JWT_SECRET=   # mínimo 32 caracteres
JWT_EXPIRES=7d
LOG_LEVEL=info
// src/server.ts — cargar dotenv LO ANTES POSIBLE
import 'dotenv/config'; // shorthand: carga y aplica .env automáticamente

// O más explícito:
import dotenv from 'dotenv';
dotenv.config(); // carga .env del directorio actual
dotenv.config({ path: '.env.local', override: true }); // override con overrides locales

Patrón: objeto de configuración centralizado

En lugar de acceder a process.env directamente en todo el código, centraliza la configuración en un solo módulo:

// src/config/env.ts
import 'dotenv/config';
import { z } from 'zod';

const envSchema = z.object({
  // App
  NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
  PORT:     z.coerce.number().int().positive().default(3000),

  // Base de datos
  DATABASE_URL: z.string().url(),

  // JWT
  JWT_SECRET:          z.string().min(32),
  JWT_EXPIRES_IN:      z.string().default('15m'),  // access token corto
  JWT_REFRESH_EXPIRES: z.string().default('7d'),   // refresh token largo

  // Logging
  LOG_LEVEL: z.enum(['trace','debug','info','warn','error','fatal']).default('info'),

  // Redis (opcional)
  REDIS_URL: z.string().url().optional(),

  // Email (opcional)
  SMTP_HOST:     z.string().optional(),
  SMTP_PORT:     z.coerce.number().optional(),
  SMTP_USER:     z.string().optional(),
  SMTP_PASSWORD: z.string().optional(),
  EMAIL_FROM:    z.string().email().optional(),

  // Almacenamiento
  UPLOAD_MAX_SIZE_MB: z.coerce.number().positive().default(10),
  UPLOAD_DIR:         z.string().default('./storage/uploads'),
});

// Validar al inicio — si falla, la app no arranca
const parsed = envSchema.safeParse(process.env);

if (!parsed.success) {
  console.error('\n❌ Error en variables de entorno:\n');
  parsed.error.issues.forEach(({ path, message }) => {
    console.error(`  • ${path.join('.')}: ${message}`);
  });
  console.error('\nRevisa tu archivo .env\n');
  process.exit(1);
}

export const env = parsed.data;
// src/config/index.ts — objeto de configuración con estructura semántica
import { env } from './env.js';

export const config = {
  app: {
    env:  env.NODE_ENV,
    port: env.PORT,
    isDev:  env.NODE_ENV === 'development',
    isProd: env.NODE_ENV === 'production',
    isTest: env.NODE_ENV === 'test',
  },
  db: {
    url: env.DATABASE_URL,
  },
  jwt: {
    secret:         env.JWT_SECRET,
    expiresIn:      env.JWT_EXPIRES_IN,
    refreshExpires: env.JWT_REFRESH_EXPIRES,
  },
  log: {
    level: env.LOG_LEVEL,
  },
  redis: {
    url: env.REDIS_URL,
  },
  email: {
    host:     env.SMTP_HOST,
    port:     env.SMTP_PORT,
    user:     env.SMTP_USER,
    password: env.SMTP_PASSWORD,
    from:     env.EMAIL_FROM,
  },
  upload: {
    maxSizeMB: env.UPLOAD_MAX_SIZE_MB,
    dir:       env.UPLOAD_DIR,
  },
} as const;

// Uso en cualquier archivo:
// import { config } from '../config/index.js';
// const token = sign(payload, config.jwt.secret, { expiresIn: config.jwt.expiresIn });

Múltiples entornos

# Archivos por entorno (cargar el correspondiente según NODE_ENV)
.env                 # valores base / desarrollo
.env.local           # overrides locales (no commitear)
.env.test            # configuración para tests
.env.production      # NO usar — los secretos de prod van en el servidor, no en archivos
// src/config/env.ts — cargar el archivo correcto según el entorno
import { config as dotenvConfig } from 'dotenv';
import { resolve } from 'node:path';

const nodeEnv = process.env.NODE_ENV ?? 'development';

// Orden de prioridad: .env.local > .env.[NODE_ENV] > .env
dotenvConfig({ path: resolve(process.cwd(), '.env') });
dotenvConfig({ path: resolve(process.cwd(), `.env.${nodeEnv}`), override: true });
dotenvConfig({ path: resolve(process.cwd(), '.env.local'), override: true });

Variables de entorno en testing

// tests/setup.ts — configuración global para Jest/Vitest
process.env.NODE_ENV    = 'test';
process.env.DATABASE_URL = 'postgresql://test:test@localhost:5432/myapp_test';
process.env.JWT_SECRET  = 'test-secret-that-is-long-enough-to-pass-validation';
process.env.LOG_LEVEL   = 'silent'; // silenciar logs durante tests
// package.json
{
  "scripts": {
    "test": "NODE_ENV=test jest --forceExit",
    "test:watch": "NODE_ENV=test jest --watch"
  }
}

Secrets en producción

En producción nunca uses archivos .env. Usa el sistema de secretos de tu plataforma:

# Heroku / Railway / Render
heroku config:set DATABASE_URL=postgresql://...
heroku config:set JWT_SECRET=...

# Docker / docker-compose
docker run -e DATABASE_URL="..." -e JWT_SECRET="..." myapp

# Docker Compose
services:
  api:
    environment:
      DATABASE_URL: ${DATABASE_URL}
      JWT_SECRET: ${JWT_SECRET}

# Kubernetes (secrets)
kubectl create secret generic app-secrets \
  --from-literal=DATABASE_URL="..." \
  --from-literal=JWT_SECRET="..."

# AWS Parameter Store / Secrets Manager, GCP Secret Manager, Vault, etc.

.gitignore — qué nunca commitear

# Variables de entorno con secretos
.env
.env.local
.env.*.local

# Solo commitear el ejemplo
# .env.example  ← mantener en el repo

Resumen

Paso Qué hacer
1. Definir schema z.object({...}) con todas las variables requeridas
2. Validar al inicio safeParse(process.env)process.exit(1) si falla
3. Exportar objeto tipado export const env = parsed.data
4. Crear config/index.ts Objeto semántico agrupado por dominio
5. Nunca process.env directo Siempre importar desde config/
6. Documentar con .env.example Commitear el ejemplo, nunca los secretos reales

En la siguiente lección conectamos todo con Prisma ORM: setup, migraciones y operaciones CRUD completas.

🔒

Ejercicio práctico disponible

Gestiona y valida la configuración de la app

Desbloquear ejercicios
// Gestiona y valida la configuración de la app
// Desbloquea Pro para acceder a este ejercicio
// y ganar +50 XP al completarlo

function ejemplo() {
    // Tu código aquí...
}

¿Te gustó esta lección?

Con Pro puedes marcar progreso, hacer ejercicios, tomar quizzes, ganar XP y obtener tu constancia.

Ver planes desde $9/mes