Saltar al contenido principal

Variables de Entorno en Node.js y Next.js: La Guía Completa para No Filtrar Secretos

Aprende a gestionar variables de entorno en Node.js, Next.js y Vite correctamente: .env files, seguridad, validación con Zod, y cómo evitar filtrar claves API a producción o al repositorio.

Fran Cobos 7 min de lectura 1322 palabras

Tabla de contenidos

Cometí este error en mi primer proyecto: subí el .env a GitHub con la clave de la API de OpenAI. En 20 minutos había bots escaneando el repo y consumiendo mi crédito. La lección fue cara.

Esta guía cubre todo lo que necesitas saber sobre variables de entorno para no cometer los mismos errores.

Lo primero: el .gitignore correcto

Antes de cualquier otra cosa, asegúrate de que tu .gitignore tiene esto:

# Variables de entorno — NUNCA subir al repo
.env
.env.local
.env.*.local
.env.development.local
.env.test.local
.env.production.local

# Sí puedes subir estos (sin secretos reales):
# .env.example
# .env.development (solo si no tiene secretos)

El archivo .env.example es la convención para documentar qué variables necesita el proyecto sin revelar los valores reales:

# .env.example — sí va al repositorio
DATABASE_URL=postgresql://user:password@localhost:5432/miapp
OPENAI_API_KEY=sk-...
JWT_SECRET=cambia-esto-por-una-cadena-aleatoria-larga
REDIS_URL=redis://localhost:6379
NEXT_PUBLIC_APP_URL=http://localhost:3000

Node.js: cómo cargar variables de entorno

Opción 1: Node.js 20.6+ nativo (sin dependencias)

node --env-file=.env src/index.js

# Para múltiples archivos
node --env-file=.env --env-file=.env.local src/index.js

Opción 2: dotenv (la forma clásica)

npm install dotenv
// src/index.js — primera línea del entry point
require("dotenv").config();

// Si usas ES Modules
import "dotenv/config";

// Ya puedes acceder a las variables
const dbUrl = process.env.DATABASE_URL;
const apiKey = process.env.OPENAI_API_KEY;

Opción 3: dotenv con configuración avanzada

import dotenv from "dotenv";
import path from "path";

dotenv.config({
  path: path.resolve(process.cwd(), `.env.${process.env.NODE_ENV || "development"}`),
});

// Carga .env.development, .env.production, etc.

Next.js: el sistema de variables de entorno automático

Next.js carga las variables automáticamente según el entorno, sin instalar dotenv:

.env                  → Siempre (base)
.env.local            → Siempre (sobreescribe, no va a git)
.env.development      → Solo con next dev
.env.production       → Solo con next build / next start
.env.test             → Solo con jest / vitest

La regla de oro de Next.js: solo las variables con prefijo NEXT_PUBLIC_ llegan al cliente.

# .env
DATABASE_URL=postgresql://...          # Solo servidor ✅
JWT_SECRET=mi-secreto                  # Solo servidor ✅
OPENAI_API_KEY=sk-...                  # Solo servidor ✅

NEXT_PUBLIC_APP_URL=https://mi-app.com # Cliente Y servidor ⚠️
NEXT_PUBLIC_STRIPE_KEY=pk_live_...     # Cliente Y servidor ⚠️
// En un Server Component o API Route — seguro
const secreto = process.env.JWT_SECRET; // ✅

// En un Client Component — PELIGRO
const secreto = process.env.JWT_SECRET; // undefined en cliente, no explota pero no funciona
const publica = process.env.NEXT_PUBLIC_APP_URL; // ✅ Funciona en cliente

Validar variables de entorno con Zod (imprescindible)

El problema con process.env es que siempre devuelve string | undefined. Si falta una variable crítica, tu app falla en runtime con un error críptico, no al arrancar.

La solución: validar las variables al arrancar la app con Zod.

// src/env.ts — valida al importar, falla rápido si falta algo
import { z } from "zod";

const envSchema = z.object({
  // Base de datos
  DATABASE_URL: z.string().url("DATABASE_URL debe ser una URL válida"),
  
  // Auth
  JWT_SECRET: z.string().min(32, "JWT_SECRET debe tener al menos 32 caracteres"),
  
  // APIs externas
  OPENAI_API_KEY: z.string().startsWith("sk-"),
  
  // Opcionales con defaults
  PORT: z.coerce.number().default(3000),
  NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
  
  // Variables públicas (Next.js)
  NEXT_PUBLIC_APP_URL: z.string().url().optional(),
});

// Valida al importar — si falta algo, falla inmediatamente con mensaje claro
const parsed = envSchema.safeParse(process.env);

if (!parsed.success) {
  console.error("❌ Variables de entorno inválidas:");
  console.error(parsed.error.flatten().fieldErrors);
  process.exit(1);
}

export const env = parsed.data;

Ahora en lugar de process.env.DATABASE_URL (string | undefined), usas env.DATABASE_URL (string garantizado):

import { env } from "@/env";

// TypeScript sabe que es string, no puede ser undefined
const db = new PrismaClient({ datasources: { db: { url: env.DATABASE_URL } } });

Si usas TypeScript en tu proyecto, este patrón es especialmente poderoso porque obtienes autocompletado de todas tus variables de entorno.


Gestión de secretos en producción

El .env es solo para desarrollo local. En producción, nunca subas un archivo .env al servidor.

En Vercel

# Por interfaz web o CLI
vercel env add DATABASE_URL production
vercel env add JWT_SECRET production

En Netlify

netlify env:set DATABASE_URL "postgresql://..."
netlify env:set JWT_SECRET "mi-secreto-largo"

En Docker / VPS

# docker-compose.yml — nunca hardcodees valores reales aquí
services:
  app:
    environment:
      - DATABASE_URL=${DATABASE_URL}
      - JWT_SECRET=${JWT_SECRET}
    env_file:
      - .env.production  # Este archivo va en el servidor, no en el repo

Si haces self-hosting con Coolify, tiene una interfaz para gestionar variables de entorno por proyecto directamente desde el panel.

En GitHub Actions

# .github/workflows/deploy.yml
env:
  DATABASE_URL: ${{ secrets.DATABASE_URL }}
  JWT_SECRET: ${{ secrets.JWT_SECRET }}

Los secretos se configuran en GitHub → Repositorio → Settings → Secrets and variables → Actions.


Organizar variables por entorno

Para proyectos con múltiples entornos (development, staging, production):

# Estructura recomendada
.env.example          # Documentación — sí al repo
.env                  # Valores locales — NO al repo
.env.staging          # Valores staging — NO al repo (o sí si no tiene secretos reales)
.env.production       # NUNCA en el repo

Un truco para cambiar entre entornos en local:

# package.json
{
  "scripts": {
    "dev": "node --env-file=.env src/index.js",
    "dev:staging": "node --env-file=.env.staging src/index.js",
    "build": "tsc",
    "start": "NODE_ENV=production node dist/index.js"
  }
}

Seguridad: errores que no debes cometer

❌ Error 1: Loguear las variables de entorno

// NUNCA hagas esto en producción
console.log("Config:", process.env); // Filtra todos los secretos a los logs

// Bien
console.log("Database conectado:", env.DATABASE_URL.split("@")[1]); // Solo el host

❌ Error 2: Exponer variables en el frontend

// ❌ MAL — en Next.js, esto expone la clave al bundle del cliente
export const config = {
  apiKey: process.env.OPENAI_API_KEY, // Undefined en cliente, pero el nombre ya da info
};

// ✅ BIEN — crear un proxy API en el servidor
// pages/api/chat.ts
export default async function handler(req, res) {
  const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY }); // Solo servidor
  // ...
}

Esto es especialmente importante si usas APIs de IA como explico en usar la API de ChatGPT o Claude — la clave nunca debe llegar al cliente.

❌ Error 3: Hardcodear secretos aunque sean temporales

// NUNCA — aunque sea "solo para probar"
const apiKey = "sk-proj-abc123..."; // Acaba en git, acaba filtrado

// SIEMPRE variables de entorno
const apiKey = process.env.OPENAI_API_KEY;

❌ Error 4: Usar el mismo secreto en dev y producción

# .env (desarrollo)
JWT_SECRET=desarrollo-secreto-simple

# .env.production (producción — secreto diferente y largo)
JWT_SECRET=xK9mP2vQ8nR4tY7wZ1bA6cD3eF5gH0iJ

Los secretos de producción deben ser distintos, aleatorios y largos. Puedes generarlos con:

node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"

Qué hacer si filtraste un secreto a GitHub

  1. Rota la clave inmediatamente — no esperes, ve al panel de la API y genera una nueva
  2. Revoca la clave comprometida — desactívala en el proveedor
  3. Limpia el historial de git:
# Instala BFG Repo Cleaner
# https://rtyley.github.io/bfg-repo-cleaner/

# Borra el archivo del historial completo
java -jar bfg.jar --delete-files .env .git

# O borra un valor específico
java -jar bfg.jar --replace-text passwords.txt .git

git reflog expire --expire=now --all
git gc --prune=now --aggressive
git push --force
  1. Añade gitleaks a tu flujo para detectar futuros problemas antes de hacer push:
# Pre-commit hook con gitleaks
gitleaks detect --source . --exit-code 1

Herramientas del ecosistema

HerramientaPara qué sirve
dotenvCargar .env en Node.js
@t3-oss/env-nextjsValidación de env con Zod integrada para Next.js
zodValidar el schema de variables
1Password CLIInyectar secretos desde un gestor de contraseñas
DopplerGestión centralizada de secretos para equipos
gitleaksDetectar secretos en código fuera del repositorio
HashiCorp VaultGestión enterprise de secretos

Para proyectos personales, la combinación Zod + .env.local + variables en Vercel/Netlify es más que suficiente.

Para equipos o proyectos con múltiples servicios, considera Doppler o 1Password Teams para centralizar los secretos.

Si usas protección de API con JWT en Node.js, la gestión correcta del JWT_SECRET que aquí describes es el primer paso crítico de seguridad.

Fran Cobos

Fran Cobos

Desarrollador Full Stack especializado en IA aplicada, automatización y desarrollo web. Escribo sobre herramientas, tutoriales y casos reales para programadores.

¿Necesitas desarrollo a medida?

Apps web, IA, módulos ERP — cuéntame tu proyecto.