33  24. Necesitamos la misma app en tres lugares distintos

Tu máquina, CI y prod tienen BD, secretos y orígenes distintos

NotaEn una frase

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:

  1. 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: .env está en .gitignore, .env.example documenta las llaves sin valores.
  2. Fail fast al arranque — al boot, la app parsea y valida toda su config: si falta DATABASE_URL o JWT_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 · Py pydantic-settings · Go os.Getenv + validación manual
  • U .env, .env.example, y por qué .env jamá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 opens
from 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 starts
type 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 ListenAndServe
Centraliza 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.example actualizado 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_URL es DATABASE_URL en 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

  1. ¿Por qué JWT_SECRET no puede tener valor por defecto aunque PORT sí?
  2. Tu app arrancó en prod y funciona… hasta el primer login, donde explota porque JWT_SECRET está vacío. ¿Qué práctica de este capítulo lo hubiera evitado y en qué momento?
  3. Escribe el .env.example completo 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.

  1. PORT con default es una conveniencia inofensiva: si falta, 3000 sirve. JWT_SECRET con 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.
  2. 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.
  3. 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.

33.12 Lo que deberías saber hacer ahora