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.
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
- Rota la clave inmediatamente — no esperes, ve al panel de la API y genera una nueva
- Revoca la clave comprometida — desactívala en el proveedor
- 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
- Añade
gitleaksa tu flujo para detectar futuros problemas antes de hacer push:
# Pre-commit hook con gitleaks
gitleaks detect --source . --exit-code 1
Herramientas del ecosistema
| Herramienta | Para qué sirve |
|---|---|
| dotenv | Cargar .env en Node.js |
| @t3-oss/env-nextjs | Validación de env con Zod integrada para Next.js |
| zod | Validar el schema de variables |
| 1Password CLI | Inyectar secretos desde un gestor de contraseñas |
| Doppler | Gestión centralizada de secretos para equipos |
| gitleaks | Detectar secretos en código fuera del repositorio |
| HashiCorp Vault | Gestió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.