33 24. Necesitamos la misma app en tres lugares distintos
Tu máquina, CI y prod tienen BD, secretos y orígenes distintos
El mismo código corre en tu laptop (localhost:5432), en CI (una BD desechable) y en producción (RDS con TLS y un secreto que jamás toca tu editor). La diferencia no puede vivir en el código: vive en variables de entorno, validadas al arrancar para que la app reviente antes de aceptar tráfico si falta algo — no a las 3am.
33.1 El problema
DATABASE_URL = "postgres://postgres:postgres@localhost:5432/taskflow" funciona perfecto… en tu máquina. En prod esa línea es: el host que no existe, la contraseña que está en el repo para siempre, y el código que hay que editar para desplegar (cada deploy una rama distinta — el caos garantizado). Y el modo silencioso de morir: sin validación, la app arranca feliz y explota en el primer request que toca la BD.
Un repositorio (repo) es la carpeta del proyecto con todo su historial Git adentro. GitHub/GitLab son servicios que hospedan repos para compartirlos y respaldarlos.
Un puerto es una puerta numerada dentro de una computadora: la IP llega al edificio, el puerto al departamento. :3000 = “la app que escucha en el departamento 3000”. Postgres suele usar 5432, HTTP el 80, HTTPS el 443.
localhost significa “esta misma máquina” — es la dirección que tu computadora usa para hablarse a sí misma. Cuando desarrollas, el “servidor” y el “cliente” viven en tu laptop: por eso todo es localhost:3000.
Una base de datos es el programa que guarda datos de forma permanente y los responde rápido. Relacional (Postgres): tablas con relaciones. No relacional: documentos, clave-valor, grafos — cada una para una forma de dato distinta.
33.2 Cómo lo resuelve un equipo
Un equipo serio aplica dos ideas de 12-factor:
- La configuración vive en el entorno, no en el código — el mismo artefacto (binario/imagen) corre en dev, CI y prod; lo que cambia es el set de variables. Ningún secreto toca el repositorio:
.envestá en.gitignore,.env.exampledocumenta las llaves sin valores. - Fail fast al arranque — al boot, la app parsea y valida toda su config: si falta
DATABASE_URLoJWT_SECRET, revienta inmediatamente con un error claro. Un deploy que no arranca es un deploy que no hace daño; uno que arranca roto es una 3am.
Una variable es una caja con nombre donde guardas un valor: let total = 42 guarda el 42 bajo el nombre total. Puedes leerla y cambiarla después (total = 50). const = caja que no se puede reemplazar.
Una variable de entorno es configuración que vive fuera del código: DATABASE_URL, JWT_SECRET. El mismo binario corre en dev y prod con distintas vars — los secretos nunca se escriben en el código.
Un entorno es una instancia completa donde corre tu app con su propia config y datos: local (tu máquina), staging (réplica de prueba), producción (la real). Cada entorno tiene sus propias llaves y su propia base de datos.
33.3 Conceptos nuevos
- U Variables de entorno y 12-factor: la configuración vive fuera del código
- TS
process.env+ zod · Pypydantic-settings· Goos.Getenv+ validación manual - U
.env,.env.example, y por qué.envjamás entra a git - U Fail fast: la app revienta al arrancar si falta
DATABASE_URL, no a las 3 AM
33.4 La explicación visual
MISMO artefacto (mismo binario/imagen/commit)
┌──────────────┬──────────────────┬──────────────────┐
ENTORNO dev (tu laptop) CI (tests) prod
DATABASE_URL localhost:5432 postgres://ci_db postgres://rds...?sslmode=require
JWT_SECRET dev-secret-123 test-secret (secret manager)
CORS_ORIGIN http://localhost http://localhost https://taskflow.app
:5173 :5173
LOG_LEVEL debug info info
El código nunca pregunta “¿dónde estoy?” — pregunta “¿cuál es mi DATABASE_URL?” y el entorno responde.
33.5 Implementación
Módulo config que valida todo al arranque — si algo falta, la app no abre el puerto:
import { z } from "zod";
const Env = z.object({
DATABASE_URL: z.string().url(),
JWT_SECRET: z.string().min(32),
PORT: z.coerce.number().default(3000),
CORS_ORIGIN: z.string().url(),
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
});
export const env = Env.parse(process.env); // throws at boot if anything's wrong
// import-time crash → "DATABASE_URL: Required" — before the port opensfrom pydantic_settings import BaseSettings
from pydantic import Field
class Settings(BaseSettings):
database_url: str
jwt_secret: str = Field(min_length=32)
port: int = 8000
cors_origin: str
environment: str = "development"
class Config:
env_file = ".env" # dev convenience; prod injects real env
settings = Settings() # ValidationError at import → app never startstype Config struct {
DatabaseURL string
JWTSecret string
Port int
CORSOrigin string
}
func LoadConfig() (Config, error) {
cfg := Config{
DatabaseURL: os.Getenv("DATABASE_URL"),
JWTSecret: os.Getenv("JWT_SECRET"),
CORSOrigin: os.Getenv("CORS_ORIGIN"),
Port: 8080,
}
if cfg.DatabaseURL == "" { return cfg, errors.New("DATABASE_URL is required") }
if len(cfg.JWTSecret) < 32 { return cfg, errors.New("JWT_SECRET must be 32+ chars") }
if cfg.CORSOrigin == "" { return cfg, errors.New("CORS_ORIGIN is required") }
return cfg, nil
}
// main(): cfg, err := LoadConfig(); if err != nil { log.Fatal(err) } — never reaches ListenAndServeCentraliza la configuración de mi API [Express/FastAPI/net-http] en un
módulo config. Requisitos: leer DATABASE_URL, JWT_SECRET, PORT,
CORS_ORIGIN y ENV de variables de entorno; validarlas al arranque
(zod/pydantic-settings/manual) con tipos y mínimos (JWT_SECRET ≥ 32
chars) y que la app falle al boot con mensaje claro si falta alguna;
defaults solo para cosas seguras (PORT); .env para dev en .gitignore y
.env.example con las llaves documentadas sin valores; el resto del
código importa config, nunca lee process.env/os.environ directamente.
33.6 ¿Por qué cada stack lo hace así?
Lo único que necesitas llevarte: un solo módulo lee el entorno, lo valida completo al arranque, y el resto del código importa config — nunca toca process.env/os.environ directamente. Los defaults solo existen para lo seguro (PORT); los secretos no tienen default.
| Alternativa | Qué es | Qué te cuesta | Cuándo |
|---|---|---|---|
| Variables de entorno | Valores por proceso, estándar 12-factor | Nada tipado por naturaleza — por eso se valida al boot | El default, todos los entornos |
.env file |
Las env vars escritas en un archivo para dev | Tentador de commitear → .gitignore siempre |
Solo dev/CI local |
| Secret manager | AWS SM, Vault, Doppler — secretos con rotación y auditoría | Servicio extra | Prod serio (nu-04) |
| Archivo de config (yaml/toml) | Config versionada en el repo | Los secretos no pueden entrar ahí | Config no secreta (features, límites) |
Regla: secretos → entorno/secret manager; preferencias → lo que quieras; nada secreto → jamás al repo.
33.7 Errores comunes
| Error | Por qué pasa | Fix |
|---|---|---|
.env commiteado |
“Es solo para dev” | .gitignore + .env.example — y si ya entró, rotar el secreto (el historial lo guarda) |
| Leer env vars dispersas por el código | os.environ["X"] en 14 archivos |
Un módulo config — la única puerta al entorno |
| Defaults para secretos | os.getenv("JWT_SECRET", "changeme") |
Sin default → la app no arranca sin secreto real |
| Validar al primer uso | “Si falta, el request falla” | Validar al boot — el deploy roto se detecta al deployar, no a las 3am |
| Config distinta por entorno en el código | if prod: url = ... |
El código no sabe dónde está — el entorno decide |
33.8 Buenas prácticas
.env.exampleactualizado en cada PR que agrega una variable — es la documentación viva de qué necesita la app para arrancar.- El objeto config se inyecta, no se importa globalmente donde se pueda evitar — facilita tests (otra config) y deja explícitas las dependencias.
Una dependencia es código de terceros que tu proyecto usa: npm install, pip install, go get las traen. Cada una es deuda — ahora funciona, pero hay que mantenerla, actualizarla y confiar en ella.
- Nombres estables entre entornos:
DATABASE_URLesDATABASE_URLen dev y en prod — lo que cambia es el valor. - Loguea la config no secreta al arranque (puerto, entorno, orígenes) — “¿en qué entorno está corriendo esto?” nunca debe ser una adivinanza. Los secretos, jamás (cap. 23).
33.9 Ejercicio
- ¿Por qué
JWT_SECRETno puede tener valor por defecto aunquePORTsí? - Tu app arrancó en prod y funciona… hasta el primer login, donde explota porque
JWT_SECRETestá vacío. ¿Qué práctica de este capítulo lo hubiera evitado y en qué momento? - Escribe el
.env.examplecompleto de Taskflow (todas las llaves que el proyecto usa hasta ahora).
La autenticación responde “¿quién eres?” — login, contraseña, token. Se confunde con autorización (“¿qué puedes hacer?”), que es la pregunta siguiente. Primero te identificas, luego te dejan o no pasar.
33.10 Mini reto
Quitar todos los valores hardcodeados de Taskflow y arrancar en un entorno limpio.
Ejercicio.
PORTcon default es una conveniencia inofensiva: si falta, 3000 sirve.JWT_SECRETcon default es un secreto conocido — cualquiera que lea tu repo firma tokens válidos. Los secretos no tienen fallback: o hay secreto real, o la app no arranca.- Fail fast al boot: el
Env.parse/Settings()/LoadConfig()habría reventado en el deploy con “JWT_SECRET is required” — descubierto al deployar (rollback inmediato), no en el primer login de un usuario real. DATABASE_URL=postgres://user:pass@host:5432/dbname JWT_SECRET=change-me-32-chars-minimum-xxxx PORT=3000 CORS_ORIGIN=http://localhost:5173 SLACK_WEBHOOK_URL=https://hooks.slack.com/services/... WEBHOOK_SECRET= LOG_LEVEL=info NODE_ENV=development
Mini reto. grep por localhost, 5432, secret, http:// en el código → todo valor que cambia entre entornos pasa a env var → config validado → arrancar con env -i (entorno vacío) debe fallar con mensajes claros nombrando cada variable faltante — esa es la prueba real de que nada quedó hardcodeado.
33.11 Vocabulario técnico del capítulo
Una función es una receta reutilizable: recibe ingredientes (parámetros), hace pasos y devuelve un plato (return). La escribes una vez y la llamas mil veces: add(2, 3) → 5.
Un objeto es una colección de datos con nombre: {name: "Ana", age: 30} — cada dato es una propiedad (clave → valor). Python los llama dict, Go los arma con struct, TS con objetos/interface.
Una clase es el molde de un objeto: define qué datos y qué métodos tiene. class Task es el molde; new Task() es una instancia concreta. Go no tiene clases — usa struct + métodos sueltos.
Un string es texto entre comillas: "hola". El nombre viene de “cadena de caracteres” — una secuencia de letras. Todo lo que llega de un formulario o una URL llega como string, aunque parezca número.
null (TS), None (Py), nil (Go) = “aquí no hay valor”. Es la respuesta a “¿qué devuelvo cuando no hay nada?” — y la fuente del bug más famoso de la historia (su inventor lo llamó “el error del billón de dólares”). Por eso el código revisa if x is not None.
return hace dos cosas a la vez: devuelve el resultado Y termina la función — lo que esté debajo nunca corre. return task = “aquí está el plato, salgo de la cocina”.
Una firma (HMAC) prueba que un mensaje es auténtico y no fue tocado: se calcula con un secreto sobre el contenido. Los webhooks la usan para que verifiques “esto realmente vino de Stripe”.
Un log es el diario del programa: qué pasó, cuándo, con qué request. Estructurado = en JSON con campos (request_id, user_id), para filtrar por máquina y no con los ojos.
Un commit es una foto guardada del código con un mensaje que dice por qué cambió. La historia del proyecto es una cadena de commits: puedes volver a cualquiera, ver qué cambió y quién lo hizo.
Un proceso es un programa en ejecución: el sistema operativo le da memoria propia y un lugar en la fila del CPU. Tu API es un proceso; Postgres es otro; el navegador es varios. Que “se caiga el proceso” = el programa murió.
Un token es una credencial portable: una cadena que dice quién eres y hasta cuándo. El servidor la emite tras el login; el cliente la presenta en cada request en vez de la contraseña.