StackPractices
beginner Por Mathias Paulenko

Variables de Entorno

Cómo leer, establecer y gestionar variables de entorno de forma segura en Python, JavaScript y Java.

Temas: devops

Visión general

Las variables de entorno son pares clave-valor establecidos fuera del código de tu aplicación, usados para configurar comportamiento sin modificar archivos fuente. Son la piedra angular de la metodología 12-Factor App y la forma estándar de gestionar secretos, claves API, URLs de base de datos y feature flags.

Separar la configuración del código hace que las aplicaciones sean portables entre entornos (dev, staging, producción) y evita que datos sensibles acaben en el control de versiones.

Antes de que las variables de entorno se convirtieran en el estándar, la configuración a menudo se incrustaba en el código fuente o se guardaba en archivos XML versionados en el repositorio. Eso hacía los despliegues frágiles: un cambio de contraseña de base de datos requería un commit, rebuild y redeploy. Las variables de entorno lo resuelven externalizando cada configuración específica del entorno, de modo que el mismo artefacto compilado corre en dev, staging y producción sin tocarlo — el principio “build once, deploy many” detrás de CI/CD moderno y los contenedores.

Cuándo usarlo

Usa esta recipe cuando:

  • Configuras apps por entorno (dev, staging, prod). Consulta Docker Basics para configuración de apps containerizadas.
  • Almacenas secretos como claves API y credenciales de base de datos. Consulta JWT Authentication para manejo seguro de tokens.
  • Habilitas o deshabilitas capacidades con feature flags. Consulta Feature Flags para gestión de toggles.
  • Gestionas configuración de aplicaciones containerizadas en Docker y Kubernetes. Consulta Docker Compose Local Dev para orquestación local de contenedores.
  • Evitas valores hard-codeados en el código fuente
  • Compartes configuración entre microservicios sin un servidor de configuración central
  • Cambias endpoints de base de datos entre primaria y réplica para escalar lecturas
  • Habilitas logging de depuración o profiling solo en entornos específicos

Solución

Python

El os.getenv de Python lee variables de entorno con un default opcional. El paquete python-dotenv carga variables desde un archivo .env en desarrollo, lo cual es conveniente para pruebas locales sin ensuciar el entorno de tu shell.

import os
from dotenv import load_dotenv

# Cargar desde archivo .env (solo en dev)
load_dotenv()

# Leer variables
api_key = os.getenv('API_KEY')
db_url = os.getenv('DATABASE_URL', 'sqlite:///default.db')  # con default
port = int(os.getenv('PORT', '8080'))
debug = os.getenv('DEBUG', 'false').lower() == 'true'

print(f"API_KEY={api_key}, PORT={port}, DEBUG={debug}")

JavaScript (Node.js)

Node.js expone variables de entorno a través de process.env. El paquete dotenv carga un archivo .env al inicio de la aplicación, pero solo funciona en Node — los entornos de navegador no tienen acceso a process.env en tiempo de ejecución.

require('dotenv').config(); // Cargar archivo .env

const apiKey = process.env.API_KEY;
const dbUrl = process.env.DATABASE_URL || 'mongodb://localhost:27017/default';
const port = parseInt(process.env.PORT || '8080', 10);
const debug = process.env.DEBUG === 'true';

console.log(`API_KEY=${apiKey}, PORT=${port}, DEBUG=${debug}`);

Java

El System.getenv() de Java devuelve un mapa inmutable del entorno del proceso. Usa getOrDefault para proporcionar valores de fallback para configuración opcional, y convierte strings a los tipos apropiados explícitamente.

public class Config {
    public static void main(String[] args) {
        String apiKey = System.getenv("API_KEY");
        String dbUrl = System.getenv().getOrDefault("DATABASE_URL", "jdbc:mysql://localhost/default");
        int port = Integer.parseInt(System.getenv().getOrDefault("PORT", "8080"));
        boolean debug = Boolean.parseBoolean(System.getenv().getOrDefault("DEBUG", "false"));

        System.out.println("API_KEY=" + apiKey + ", PORT=" + port + ", DEBUG=" + debug);
    }
}

Archivo .env

# .env — nunca subas este archivo al control de versiones
DATABASE_URL=postgres://user:pass@localhost:5432/mydb
API_KEY=sk-live-xxxxxxxxxxxx
PORT=3000
DEBUG=true

Agrega .env a .gitignore:

.env
.env.local
.env.*.local

Explicación

  • os.environ / process.env / System.getenv(): acceso en tiempo de ejecución a las variables de entorno, heredadas del proceso padre (shell, systemd, Docker). Los procesos hijos no pueden modificar el entorno del padre.
  • load_dotenv() / require('dotenv').config(): carga variables desde un archivo .env en desarrollo. No subas ese archivo al repo — es una conveniencia solo para desarrollo local.
  • Defaults: define defaults para lo trivial (puertos, flags de debug); nunca para secretos. Si falta una variable requerida, falla rápido al inicio con un error claro en vez de caerte después con algo críptico.
  • Coerción de tipos: las variables de entorno son siempre strings — convierte a int/boolean explícitamente. En JavaScript, "false" se evalúa como verdadero si no lo comparas.
  • Ámbito: las variables definidas en el shell son visibles para el proceso actual y sus hijos. Usa export en Bash o setx en Windows para persistirlas entre sesiones.

Variantes

Go

El os.Getenv de Go retorna un string vacío cuando la clave no existe. Usa os.LookupEnv para distinguir entre no definida y vacía:

package main

import (
    "fmt"
    "os"
    "strconv"
)

func getEnv(key, fallback string) string {
    if val, ok := os.LookupEnv(key); ok {
        return val
    }
    return fallback
}

func main() {
    apiKey := os.Getenv("API_KEY")
    dbURL := getEnv("DATABASE_URL", "postgres://localhost:5432/default")
    port, _ := strconv.Atoi(getEnv("PORT", "8080"))
    debug := getEnv("DEBUG", "false") == "true"

    fmt.Printf("API_KEY=%s, DB=%s, PORT=%d, DEBUG=%v\n", apiKey, dbURL, port, debug)
}

Bash

#!/bin/bash
# Cargar .env si existe
if [ -f .env ]; then
  set -a
  source .env
  set +a
fi

# Leer variables con defaults
API_KEY="${API_KEY:-default-key}"
DB_URL="${DATABASE_URL:-postgres://localhost:5432/default}"
PORT="${PORT:-8080}"
DEBUG="${DEBUG:-false}"

echo "API_KEY=$API_KEY, PORT=$PORT, DEBUG=$DEBUG"

# Validar variables requeridas
if [ -z "$API_KEY" ]; then
  echo "ERROR: API_KEY es requerida" >&2
  exit 1
fi

Docker

# Dockerfile — definir defaults en build time
ENV NODE_ENV=production
ENV PORT=3000
# Sobrescribir en runtime: docker run -e PORT=8080 myapp

# Usar ARG para variables solo de build time
ARG BUILD_VERSION=latest
ENV APP_VERSION=$BUILD_VERSION
# Pasar env vars en runtime
$ docker run -e DATABASE_URL=postgres://prod-db:5432/myapp -e API_KEY=sk-live-xxx myapp:v1

# Cargar desde archivo
$ docker run --env-file .env.production myapp:v1

# Docker Compose
$ docker compose --env-file .env.production up
# docker-compose.yml
services:
  app:
    image: myapp:v1
    environment:
      - DATABASE_URL=postgres://db:5432/myapp
      - API_KEY=${API_KEY}
      - PORT=3000
    env_file:
      - .env.production

Kubernetes

apiVersion: v1
kind: ConfigMap
metadata:
  name: app-config
data:
  DATABASE_URL: postgres://db-svc:5432/mydb
  PORT: "8080"
  DEBUG: "false"
---
apiVersion: v1
kind: Secret
metadata:
  name: app-secrets
type: Opaque
stringData:
  API_KEY: sk-live-xxxxxxxxxxxx
  DB_PASSWORD: super-secret-password
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: api-server
spec:
  template:
    spec:
      containers:
      - name: api
        image: myapp:v1
        envFrom:
        - configMapRef:
            name: app-config
        - secretRef:
            name: app-secrets
        env:
        - name: POD_NAME
          valueFrom:
            fieldRef:
              fieldPath: metadata.name

Validación con schema — pydantic-settings (Python)

from pydantic_settings import BaseSettings
from pydantic import Field, ValidationError

class Settings(BaseSettings):
    api_key: str = Field(..., min_length=10)
    database_url: str = Field(..., min_length=1)
    port: int = Field(default=8080, ge=1, le=65535)
    debug: bool = False
    allowed_origins: list[str] = ["localhost"]

    class Config:
        env_file = ".env"
        env_file_encoding = "utf-8"

try:
    settings = Settings()
    print(f"Loaded: port={settings.port}, debug={settings.debug}")
except ValidationError as e:
    print(f"Configuration error: {e}")
    raise SystemExit(1)

Validación con schema — envalid (Node.js)

const { cleanEnv, str, port, bool, email } = require("envalid");

const env = cleanEnv(process.env, {
  API_KEY: str({ minLength: 10 }),
  DATABASE_URL: str({ default: "postgres://localhost:5432/default" }),
  PORT: port({ default: 8080 }),
  DEBUG: bool({ default: false }),
  ADMIN_EMAIL: email({ default: "admin@example.com" }),
});

// env ahora está tipado y validado
console.log(`API_KEY set: ${env.API_KEY.length > 0}`);
console.log(`PORT: ${env.PORT}`);

Archivos .env por entorno

# .env.development
DATABASE_URL=postgres://localhost:5432/dev_db
DEBUG=true
LOG_LEVEL=debug

# .env.staging
DATABASE_URL=postgres://staging-db:5432/staging_db
DEBUG=false
LOG_LEVEL=info

# .env.production
DATABASE_URL=postgres://prod-db:5432/prod_db
DEBUG=false
LOG_LEVEL=warning
# Python: cargar .env específico por entorno
import os
from dotenv import load_dotenv

env = os.getenv("APP_ENV", "development")
load_dotenv(f".env.{env}")
load_dotenv(".env", override=False)  # Fallback, no sobrescribir lo específico
// Node.js: dotenv-flow para carga jerárquica de .env
require("dotenv-flow").config();

// Carga en orden: .env.{NODE_ENV}.local > .env.{NODE_ENV} > .env.local > .env

Lo que funciona

  • Nunca subas secretos al repo: agrega .env a .gitignore desde el primer día. Un .env con credenciales de producción queda en el historial de Git para siempre — borrar el archivo no lo elimina.
  • Publica un .env.example: documenta las variables requeridas sin valores reales, para que un dev nuevo lo copie a .env y complete sus propias credenciales.
  • Valida al inicio: falla rápido cuando falte una variable requerida; una app a medias configurada falla después con errores imposibles de rastrear.
  • Ámbito por entorno: .env.development, .env.production. Muchos frameworks los cargan automáticamente según NODE_ENV o equivalente.
  • Usa un secrets manager en producción: AWS Secrets Manager, Azure Key Vault y HashiCorp Vault te dan rotación, logs de auditoría y control de acceso que los archivos planos no pueden.
  • Loguea configuración, no secretos: una línea como DATABASE_URL=*** te dice que la variable está definida sin filtrar credenciales.
  • Prefija las variables públicas en frontend: Vite, Next.js y Create React App solo exponen VITE_*, NEXT_PUBLIC_* o REACT_APP_* al navegador; todo lo demás queda del lado del servidor.
  • Rota secretos regularmente: incluso el mejor almacenamiento se puede comprometer. Pon la rotación de claves API y contraseñas en un calendario trimestral.

Errores comunes

  • Subir .env con secretos reales: borrar el archivo no lo saca del historial de Git. Límpialo con git filter-repo o BFG Repo-Cleaner si ya pasó.
  • Asumir que las variables existen sin defaults: la app se cae con errores confusos. Valida las requeridas y da defaults a las opcionales.
  • No validar al inicio: la configuración faltante sale a la superficie en lo profundo del call stack, lejos de la variable ausente.
  • Meter datos estructurados en env vars: son strings clave-valor planos. Para config anidada, usa un archivo JSON o YAML y apunta a su ruta con una variable de entorno.
  • Confundir variables de build time y runtime en frontend: los valores se incrustan en el bundle del cliente al compilar; cambiar la variable después no hace nada.
  • Imprimir secretos en mensajes de error: los atacantes escanean stack traces y páginas de error públicas buscando exactamente esto.
  • Reutilizar los mismos secretos en todos los entornos: dev y producción deben usar credenciales distintas — una contraseña de dev filtrada no debería abrir producción.

Tips de Rendimiento

  1. Lee las env vars una vez al inicio. Las llamadas repetidas a os.getenv() tienen un overhead mínimo, pero cachear en un objeto de config es más limpio:
# Leer una vez
_config = Settings()

# Usar en todas partes
def get_db_url():
    return _config.database_url
  1. No parses valores complejos en cada llamada. Parsea una vez y cachea el resultado:
# Parsear una vez
_allowed_origins = os.getenv("ALLOWED_ORIGINS", "").split(",")

# Usar lista cacheada
def is_origin_allowed(origin):
    return origin in _allowed_origins
  1. Carga perezosa para secretos. Obtén los secretos de AWS Secrets Manager en el primer uso y cachea:
import boto3
from functools import lru_cache

@lru_cache(maxsize=1)
def get_db_password():
    client = boto3.client("secretsmanager")
    response = client.get_secret_value(SecretId="prod/db/password")
    return response["SecretString"]

Ver también

Preguntas frecuentes

¿Puedo usar variables de entorno en el navegador?

Solo en build time mediante sustitución del bundler — el navegador no puede ver el entorno del servidor. Nunca expongas secretos del servidor en código de cliente; usa el prefijo público de tu framework (VITE_, NEXT_PUBLIC_, REACT_APP_).

¿Qué es el principio de configuración de 12-Factor App?

Almacena la configuración en variables de entorno para que el mismo artefacto se despliegue en cualquier sitio sin cambios de código. Una imagen Docker puede correr dev, staging y prod con variables distintas.

¿Cómo gestiono secretos en un contenedor Docker?

Pásalos en runtime con flags -e, Docker secrets o como archivos montados — nunca los incrustes en la imagen. Si el registro se filtra, cada secreto embebido se filtra con él.

¿Cuál es la diferencia entre .env y exports de shell?

Los archivos .env los carga la aplicación al inicio y solo afectan a ese proceso. export VAR=value afecta toda la sesión de shell y sus procesos hijos. Usa .env por proyecto y exports para herramientas globales.

¿Valido las variables a mano o con una librería de schema?

Para proyectos pequeños, validar a mano al inicio basta. A escala, envalid (Node), pydantic-settings (Python) o @ConfigurationProperties de Spring te dan tipos, defaults y validación automática.

¿Qué pasa si falta una variable requerida?

Lo que tú decidas — así que haz que falle rápido. Lee las requeridas al inicio, lanza un error claro nombrando la clave ausente y sal antes de servir tráfico.

¿Archivos .env o secrets manager en producción?

Los archivos sirven para dev y despliegues pequeños. En producción, un secrets manager añade rotación, logs de auditoría y control de acceso — y mantiene los secretos en texto plano fuera del disco.