Guía pilar · 10 min de lectura

Env vars en monorepo: el patrón que de verdad escala en pnpm, Turborepo y Nx

Tienes tres paquetes. Cada uno necesita sus propias env vars. Alguien añadió un cuarto el sprint pasado y el .env.example no se actualiza desde entonces. Una persona nueva entró el lunes y todavía no puede levantar el web app porque nadie recuerda qué keys van dónde — la respuesta siempre es "pregúntale a Ana por Slack."

Al final de esta página tendrás un patrón concreto para env vars en un monorepo: un config en la raíz del repo, alcance por paquete, overrides locales que no pelean con los de tu compañero, e integración con task-runner para pnpm workspaces, Turborepo y Nx. También vas a saber dónde sigue rompiéndose cada pieza del patrón — un patrón que pretende resolverlo todo es un patrón del que no puedes fiarte.

Por qué .env-por-paquete deja de funcionar en un monorepo

Tres modos de falla, en el orden en que le pegan a un equipo que crece.

1. Drift por duplicación

El mismo DATABASE_URL termina en apps/api/.env, apps/worker/.env y apps/admin/.env. Alguien rota la contraseña en dos de ellos. El tercer servicio se cae en staging a la mañana siguiente porque nadie se acordó de que también necesitaba la nueva credencial. La duplicación no es el bug — el bug es que no hay un lugar único donde una rotación no se pueda olvidar.

2. Impuesto de onboarding

Una persona nueva clona el repo y no puede levantar el frontend porque .env.local está en el gitignore y el template está desactualizado. Pregunta por Slack. Alguien pega un JWT en un DM. Para cuando por fin produce, el secret ya está pegado en dos canales de Slack y un screenshot. Ese es tu modelo de seguridad.

3. Cache envenenado

Turborepo y Nx cachean las salidas de tareas haciendo hash de las entradas, incluidas las env vars — pero solo las que declaras en el campo env de turbo.json o en los inputs de Nx. Cualquier var que la tarea lea y que no esté declarada es invisible para el cache. La tarea corre una vez sin STRIPE_SECRET_KEY, el test pasa porque entró un mock, Turbo guarda el éxito en cache, y cada ejecución de CI después repite ese estado roto — hasta que alguien limpia el cache y descubre que el bug tiene dos sprints.

Los tres patrones a los que recurren los equipos, y por qué cada uno se rompe

Antes de nombrar un patrón que funciona, un repaso rápido por los que no. Quien ya probó uno de ellos necesita entender por qué falló antes de que un camino nuevo le cuaje.

PatrónCómo se ve en la prácticaPor qué se rompe

Un .env en la raíz

Un único archivo en la raíz del repo. Cada paquete lo lee con dotenv-cli o algún wrapper.

Filtra secrets que no corresponden a todos los workspaces — ahora tu bundle de frontend tiene la contraseña del DB en el alcance. La CI no puede imponer least privilege cuando el archivo entero siempre está montado.

Symlinks a la raíz

apps/api/.env → ../../.env commiteado en el repo para que cada paquete vea el mismo archivo.

Funciona en macOS y Linux. Se rompe con devs en Windows, en checkouts nuevos de CI que no preservan symlinks y en contextos de build de Docker que copian apps/api/ aislado. Te enteras durante el onboarding de un proveedor, en el peor momento posible.

Wrappers env-cmd / dotenv-cli

Cada script de package.json va prefijado con env-cmd -f ../../.env.local o algo similar.

Sigue siendo un .env por paquete — solo moviste la ruta del archivo. Nada se comparte, nada tiene alcance, los scripts se ensucian, y el wrapper suma un nuevo modo de falla cuando el archivo no está donde él espera.

.env.example + script de copia

Template commiteado, archivo real en el gitignore, el doc de onboarding dice "cp .env.example .env y rellena los valores".

El template queda desfasado el día que entra una nueva var. Tres sprints después faltan cuatro keys y nadie sabe cuáles. La persona nueva copia un template viejo y pasa el segundo día debuggeando undefined is not a function.

La forma de la falla siempre es la misma: un patrón que aguanta 3 paquetes y 5 env vars se desmorona con 8 paquetes y 30 env vars, y nadie puede señalar el momento en que dejó de funcionar.

Cómo se ve la respuesta correcta (independiente de herramienta)

Antes de nombrar un producto, aquí está la forma de una solución que escala. Si no tienes algo así en tu monorepo, va a dejar de funcionar por los mismos motivos que los patrones de arriba — da igual si lo construyes tú o si adoptas una herramienta.

1. Una fuente única de verdad en la raíz del repo.

Un archivo único declara qué paquetes existen, qué env vars necesita cada uno y a qué ambiente (development, staging, production) apuntan por defecto. Rotar un secret es una actualización; se propaga a cada paquete que declaró la key.

2. Alcance por paquete.

El paquete A recibe sus keys. El paquete B no las ve. El bundle de frontend no incluye el secret de Stripe por accidente porque el workspace de frontend nunca lo trae.

3. Overrides locales por usuario.

Tu .env.local y el .env.local de tu compañero quedan independientes. Puedes apuntar tu API local a una URL de staging sin commitar esa decisión en el config compartido y romperle el build a la otra persona.

4. Integración con task-runner.

Lo que orqueste tus builds (Turborepo, Nx o un Makefile) sabe qué env vars lee cada tarea, así que las claves de cache las incluyen. Una env var no declarada es un error de build, no un cache hit silencioso.

Una respuesta que te da tres de estos cuatro deja un vector de drift en pie. Quieres los cuatro.

Cómo Envshed implementa ese patrón

Envshed es un gestor de secrets hospedado con un CLI que lee un único archivo de config en la raíz de tu repo. El config mapea workspaces a projects en tu organización Envshed. envshed pull recorre el config, trae las keys que cada workspace declaró y las escribe en disco donde tu código ya espera encontrarlas.

Este es el schema real de .envshed.json (paquete → { project, defaultEnv?, file?, org? }):

{
  "org": "acme",
  "project": "platform",
  "defaultEnv": "development",
  "workspaces": {
    "apps/api":      { "project": "platform-api" },
    "apps/web":      { "project": "platform-web", "defaultEnv": "development" },
    "packages/auth": { "project": "platform-auth", "file": ".env.local" }
  }
}

Cada entrada de workspace apunta a su propio project Envshed. Un envshed pull en la raíz va a cada workspace en paralelo. envshed pull --workspace apps/api acota el pull a un paquete — útil en jobs de CI que tocan un solo servicio.

El campo file sobrescribe la ruta de salida por defecto (./.env relativo al workspace). Usa .env.local en los workspaces donde Next.js o Vite esperan ese archivo.

Los valores de los secrets se almacenan cifrados en reposo con AES-256-GCM del lado de Envshed; envshed pull los descifra en tránsito y los escribe en las rutas de arriba. Nada se loguea del lado del servidor, y nada de tu config sale del repo.

Walkthrough: pnpm workspaces

El pnpm-workspace.yaml de pnpm declara los globs de paquetes. envshed init lee ese archivo directamente cuando lo ejecutas en la raíz del repo.

Paso 1 — Detecta y elige los paquetes.

envshed init lee pnpm-workspace.yaml y muestra cada paquete que encuentra bajo los globs configurados. Elige los que quieras trackear — por defecto, todos.

Paso 2 — Mapea cada paquete a un project Envshed.

Para cada paquete elegido, elige o crea un project Envshed y un ambiente. El CLI escribe el mapeo en .envshed.json en la raíz del repo.

Paso 3 — Commitea el config.

.envshed.json no tiene valores de secret — solo slugs y rutas de archivo — así que es seguro commitearlo. Ese commit también es tu doc de onboarding: una persona nueva clona el repo, corre envshed pull, y todo el monorepo levanta.

De ahí en adelante, traer secrets es un comando:

envshed pull                       # pulls every workspace in parallel
envshed pull --workspace apps/api  # scopes the pull to one package
envshed pull --env staging         # same config, staging values

Conéctalo a tu script dev para que cada arranque local tome los valores más recientes:

{
  "scripts": {
    "dev": "envshed pull && turbo run dev"
  }
}

El pull tarda unos cientos de milisegundos en frío, así que el pnpm dev de nadie se siente más lento. Referencia del CLI: envshed init, envshed workspace, envshed pull.

Walkthrough: Turborepo

El superpoder de Turborepo es el cache de tareas. Su punto ciego — el que manda env vars rotas a prod — es que solo hashea las env vars declaradas en turbo.json. Este es el cableado que hace que Envshed y Turbo se pongan de acuerdo.

Paso 1 — Haz que el pull sea dependencia de la tarea.

Declara una tarea envshed:pull y arrástrala vía dependsOn. Pon cache: false tanto en el pull como en cualquier tarea persistent de larga duración — el pull tiene que correr cada vez para tomar valores rotados, y las tareas persistent no son cacheables igualmente.

Paso 2 — Declara cada env var en el env de Turbo.

Cualquier cosa que tu build lea vía process.env y no esté listada aquí es invisible para el hash de cache de Turbo. Las env vars no declaradas son la causa #1 de "funciona en mi máquina, cacheado-roto en CI". Mantén honesta esta lista.

{
  "tasks": {
    "envshed:pull": {
      "cache": false
    },
    "dev": {
      "dependsOn": ["envshed:pull"],
      "persistent": true,
      "cache": false
    },
    "build": {
      "dependsOn": ["envshed:pull", "^build"],
      "env": ["DATABASE_URL", "STRIPE_SECRET_KEY", "NEXTAUTH_SECRET"],
      "outputs": [".next/**", "!.next/cache/**"]
    }
  }
}

Paso 3 — Conéctalo a CI con un service token.

En CI, emite un service token Envshed con alcance por ambiente, fíjalo como secret de CI y llama a envshed pull al inicio del job. El token es el único valor específico de Envshed que tu config de CI necesita — todo lo demás rota en Envshed.

- run: envshed pull
  env:
    ENVSHED_TOKEN: ${{ secrets.ENVSHED_CI_TOKEN }}

Walkthrough: Nx

Nx modela cada app y librería como un project aparte con su propio project.json. El mapa workspaces de .envshed.json calza uno a uno: una entrada de workspace Envshed por project Nx.

Paso 1 — Deja que cada project lea su propio .env.

Nx toma automáticamente apps/<nombre>/.env cuando ejecuta un target en el cwd de ese project, así que basta con que envshed pull escriba en esa ruta.

Paso 2 — Haz el pull dependencia de tarea en Nx.

Agrega un target run-commands local por project y encadénalo con dependsOn en los targets que necesitan secrets:

{
  "name": "api",
  "targets": {
    "envshed": {
      "executor": "nx:run-commands",
      "options": {
        "command": "envshed pull --workspace apps/api",
        "cwd": "{workspaceRoot}"
      }
    },
    "serve": {
      "dependsOn": ["envshed"],
      "executor": "@nx/node:node"
    }
  }
}

Paso 3 — Declara env vars en namedInputs.

Nx solo hashea las env vars que declares — misma regla que Turbo. Usa namedInputs para que ese conjunto sea reutilizable entre targets:

{
  "namedInputs": {
    "runtime": [
      { "env": "DATABASE_URL" },
      { "env": "REDIS_URL" }
    ]
  },
  "targetDefaults": {
    "build": { "inputs": ["default", "runtime"] }
  }
}

Lo que no pretendemos resolver

Porque lo vas a notar si no lo decimos.

No corremos sidecar ni proxy.

Envshed escribe valores en archivos .env en disco. Una vez escrito el archivo, tu código lo lee de la forma normal — lo que significa que cualquiera en la máquina puede leerlo. Si necesitas un límite de secret solo en memoria del proceso, Envshed no es la respuesta para ese requisito específico.

No reemplazamos los secret stores de CI para deploys de producción.

Vercel, AWS Parameter Store, 1Password Secrets Automation, GitHub Actions Environments — en la mayoría de los equipos esas herramientas son dueñas de los deploys de producción, y así debe ser. Envshed es la experiencia de dev del día a día: el pull local, el bootstrap de onboarding, el ambiente de staging que un product manager necesita en su laptop. Se integra con secret stores de producción — mira la guía de GitHub Actions — no los reemplaza.

No resolvemos drift de schema a nivel de lenguaje.

Si tu código lee process.env.DATABSE_URL con un typo, Envshed no lo atrapa. Usa @t3-oss/env-core, Zod o validators hechos a mano en el entrypoint de cada paquete. Envshed te dice qué keys existen; tu runtime te dice si son las keys que esperabas.

Estos límites sostienen el resto. Un gestor de secrets que dice resolverlo todo es, o bien un proxy que tienes que operar, o bien un lock-in del que no puedes salir. Envshed no es ninguno.

FAQ

¿Los archivos .env deberían commitearse en un monorepo?

No, y la respuesta no cambia entre single-repo y monorepo. Los archivos .env contienen valores de secret de un ambiente específico; su lugar es el .gitignore. Lo que commiteas es .envshed.json — solo tiene slugs y rutas de archivo, ningún valor. Si hoy usas el patrón .env.example, mantenlo como referencia de las keys obligatorias; Envshed no reemplaza ese archivo.

¿Cómo comparto una sola env var tipo DATABASE_URL entre paquetes sin duplicarla?

Apunta ambos workspaces al mismo project Envshed. apps/api y apps/worker declaran { "project": "platform-api" } en el mapa de workspaces de .envshed.json. Un DATABASE_URL vive en un project Envshed y se trae a ambos workspaces en envshed pull. Rota una vez; ambos paquetes lo toman en el próximo pull.

¿Y eso de que Next.js toma .env.local automáticamente?

Soportado. Pon "file": ".env.local" en el config de ese workspace y envshed pull escribe en la ruta que Next.js espera. Los overrides por usuario que quedan fuera del config compartido pueden vivir al lado como un segundo archivo local — Next.js los junta con sus reglas de precedencia documentadas.

¿Cómo corro el repo entero con env de staging en un solo comando?

envshed pull --env staging && pnpm dev. La flag --env sobrescribe el ambiente por defecto de cada workspace de una. Útil para product reviews, reproducción de bugs, y esa investigación ocasional tipo "cómo se ven los datos de prod contra esta feature branch".

¿Es compatible con Docker Compose?

Sí. Corre envshed pull antes de docker compose up — Compose lee los .env del contexto de build como cualquier otra cosa. La alternativa en tiempo de CI es un service token Envshed en el runtime de Compose; la referencia del CLI cubre el flujo del token.

¿En qué se diferencia esto de Doppler, 1Password Secrets Automation o Vault?

Esos son gestores de secrets de propósito general; Envshed es nativo de monorepo. El patrón config-en-la-raíz, el pull por workspace y el alcance vía --workspace son comportamientos de primera clase, no workflows que armas alrededor de una herramienta de propósito general. En escala de 2–50 devs, la UX del día a día es el producto entero. Si necesitas policy-as-code y HSMs multirregionales, ya te quedó chico Envshed.

Mira el patrón corriendo

En vez de copiar snippets de esta página, clona el monorepo de demo. Son tres paquetes — una API Fastify, un web app Next.js 15 y una librería de auth compartida — cableados con pnpm-workspace.yaml, turbo.json y un .envshed.json usando el schema real de esta guía. De git clone a un servidor de dev corriendo en menos de cinco minutos.

github.com/envshed/monorepo-secrets-example

Público, licencia MIT. Fastify + Next.js 15 + pnpm workspaces + Turborepo, con turbo.json configurado para que los cambios en archivo .env realmente invaliden el cache.

Lectura relacionada

Deja el malabarismo de .env-por-paquete

Un comando en la raíz. Cada workspace trae exactamente lo que necesita. USD 5 por usuario, plano.

Empezar gratis

Una guía pilar de Envshed.