Guia pilar · 10 min de leitura

Env vars em monorepo: o padrão que realmente escala em pnpm, Turborepo e Nx

Você tem três pacotes. Cada um precisa das próprias env vars. Alguém adicionou um quarto na sprint passada e o .env.example não é atualizado desde então. Uma pessoa nova entrou segunda e até agora não sobe o web app porque ninguém lembra quais keys vão onde — a resposta é sempre "pergunta pra Ana no Slack."

Até o fim desta página você tem um padrão concreto pra env vars em monorepo: um config na raiz do repo, escopo por pacote, overrides locais que não brigam com os do colega, e integração com task-runner pra pnpm workspaces, Turborepo e Nx. Você também vai saber onde cada pedaço do padrão ainda quebra — um padrão que finge resolver tudo é um padrão no qual você não pode confiar.

Por que .env-por-pacote para de funcionar em monorepo

Três modos de falha, na ordem em que pegam um time que está crescendo.

1. Drift por duplicação

O mesmo DATABASE_URL aparece em apps/api/.env, apps/worker/.env e apps/admin/.env. Alguém rotaciona a senha em dois deles. O terceiro serviço quebra em staging na manhã seguinte porque ninguém lembrou que ele também precisava da nova credencial. A duplicação não é o bug — o bug é não existir um lugar único onde uma rotação não possa ser esquecida.

2. Imposto de onboarding

Uma pessoa nova clona o repo e não consegue subir o frontend porque .env.local está no gitignore e o template está desatualizado. Pergunta no Slack. Alguém cola um JWT numa DM. Quando finalmente começa a produzir, o secret já foi colado em dois canais do Slack e num screenshot. É esse o seu modelo de segurança.

3. Cache envenenado

Turborepo e Nx cacheiam saídas de tarefas com hash das entradas, incluindo env vars — mas só as que você declara no campo env do turbo.json ou nos inputs do Nx. Qualquer var que a tarefa lê e não foi declarada é invisível pro cache. A tarefa roda uma vez sem STRIPE_SECRET_KEY, o teste passa porque caiu num mock, o Turbo guarda o sucesso no cache, e toda execução de CI depois repete esse estado quebrado — até alguém limpar o cache e descobrir que o bug tem duas sprints.

Os três padrões que os times tentam, e por que cada um quebra

Antes de nomear um padrão que funciona, uma passada rápida pelos que não funcionam. Quem já tentou um deles precisa entender por que falhou antes que um novo caminho faça sentido.

PadrãoComo fica na práticaPor que quebra

Um .env na raiz

Um arquivo único na raiz do repo. Todo pacote lê dele via dotenv-cli ou algum wrapper.

Vaza secrets irrelevantes pra todos os workspaces — agora o seu bundle de frontend tem a senha do banco no escopo. A CI não consegue impor least privilege se o arquivo inteiro sempre é montado.

Symlinks pra raiz

apps/api/.env → ../../.env commitado no repo pra cada pacote ver o mesmo arquivo.

Funciona no macOS e no Linux. Quebra pra quem desenvolve no Windows, em checkouts novos de CI que não preservam symlinks e em contextos de build do Docker que copiam apps/api/ isolado. Você descobre durante o onboarding de um fornecedor, no pior momento possível.

Wrappers env-cmd / dotenv-cli

Todo script de package.json é prefixado com env-cmd -f ../../.env.local ou algo parecido.

Continua sendo um .env por pacote — você só mudou o caminho do arquivo de lugar. Nada é compartilhado, nada tem escopo, os scripts ficam mais barulhentos, e o wrapper adiciona um novo modo de falha quando o arquivo não está onde ele espera.

.env.example + script de cópia

Template commitado, arquivo real no gitignore, doc de onboarding manda "cp .env.example .env e preenche os valores".

O template defasa no dia em que uma nova var entra. Três sprints depois está faltando quatro keys e ninguém sabe quais. A pessoa nova copia um template velho e passa o segundo dia debugando undefined is not a function.

O formato da falha é sempre o mesmo: um padrão que aguenta 3 pacotes e 5 env vars desmorona em 8 pacotes e 30 env vars, e ninguém consegue apontar o momento em que parou de funcionar.

Como é a resposta certa (independente de ferramenta)

Antes de nomear um produto, aqui está o formato de uma solução que escala. Se você não tem algo assim no seu monorepo, ele vai parar de funcionar pelos mesmos motivos dos padrões acima — não importa se você constrói na mão ou adota uma ferramenta.

1. Uma fonte única de verdade na raiz do repo.

Um arquivo único declara quais pacotes existem, quais env vars cada um precisa e qual ambiente (development, staging, production) é o padrão. Rotacionar um secret é uma atualização só; ela se propaga pra todo pacote que declarou a key.

2. Escopo por pacote.

O pacote A recebe as keys dele. O pacote B não enxerga as do A. O bundle de frontend não inclui o secret do Stripe por acidente porque o workspace de frontend nunca puxa ele.

3. Overrides locais por usuário.

O seu .env.local e o .env.local do colega ficam independentes. Você aponta a sua API local pra uma URL de staging sem commitar essa escolha no config compartilhado e quebrar o build da outra pessoa.

4. Integração com task-runner.

Seja quem orquestra os builds (Turborepo, Nx ou um Makefile), ele sabe quais env vars cada tarefa lê, então as chaves de cache incluem essas vars. Uma env var não declarada vira erro de build, não cache hit silencioso.

Uma resposta que te dá três desses quatro deixa um vetor de drift em pé. Você quer os quatro.

Como o Envshed implementa esse padrão

O Envshed é um gerenciador de secrets hospedado com uma CLI que lê um único arquivo de config na raiz do repo. O config mapeia workspaces pra projects na sua organização Envshed. envshed pull percorre o config, busca as keys que cada workspace declarou e escreve em disco onde o seu código já espera encontrar.

Este é o schema real do .envshed.json (pacote → { 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 aponta pro próprio project Envshed. Um envshed pull na raiz busca de todos os workspaces em paralelo. envshed pull --workspace apps/api limita o pull a um pacote só — útil em jobs de CI que tocam num serviço só.

O campo file sobrescreve o caminho de saída padrão (./.env relativo ao workspace). Use .env.local nos workspaces onde o Next.js ou o Vite espera esse arquivo.

Os valores dos secrets ficam criptografados em repouso com AES-256-GCM no lado do Envshed; envshed pull descriptografa em trânsito e escreve nos caminhos acima. Nada é logado no servidor, e nada do seu config sai do repo.

Passo a passo: pnpm workspaces

O pnpm-workspace.yaml do pnpm declara os globs dos pacotes. envshed init lê esse arquivo direto quando você roda na raiz do repo.

Passo 1 — Detecte e escolha os pacotes.

envshed init lê o pnpm-workspace.yaml e mostra todo pacote que encontra nos globs configurados. Escolha os que você quer rastrear — por padrão, todos.

Passo 2 — Mapeie cada pacote num project Envshed.

Pra cada pacote escolhido, selecione ou crie um project Envshed e um ambiente. A CLI escreve o mapeamento no .envshed.json na raiz do repo.

Passo 3 — Commite o config.

O .envshed.json não tem valores de secret — só slugs e caminhos de arquivo — então é seguro commitar. Esse commit também vira o seu doc de onboarding: a pessoa nova clona o repo, roda envshed pull e o monorepo inteiro sobe.

A partir daí, puxar secrets é um comando só:

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

Pluga no seu script dev pra todo start local pegar os valores mais recentes:

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

O pull leva algumas centenas de milissegundos a frio, então o pnpm dev de ninguém fica mais lento. Referência da CLI: envshed init, envshed workspace, envshed pull.

Passo a passo: Turborepo

O superpoder do Turborepo é o cache de tarefas. O ponto cego — aquele que manda env vars quebradas pra prod — é que ele só faz hash das env vars declaradas no turbo.json. Este é o arranjo que faz Envshed e Turbo concordarem.

Passo 1 — Torne o pull uma dependência da tarefa.

Declare uma tarefa envshed:pull e puxe via dependsOn. Defina cache: false tanto no pull quanto em qualquer tarefa persistent de longa duração — o pull precisa rodar toda vez pra pegar valores rotacionados, e tarefas persistent não são cacheáveis mesmo.

Passo 2 — Declare toda env var no env do Turbo.

Qualquer coisa que o seu build lê via process.env e não está listada aqui é invisível pro hash de cache do Turbo. Env vars não declaradas são o motivo #1 de "funciona na minha máquina, cacheado-quebrado na CI". Mantenha essa lista honesta.

{
  "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/**"]
    }
  }
}

Passo 3 — Pluga na CI com um service token.

Na CI, emita um service token Envshed com escopo por ambiente, defina como secret da CI e chame envshed pull no início do job. O token é o único valor específico do Envshed que o seu config de CI precisa — todo o resto rotaciona no Envshed.

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

Passo a passo: Nx

O Nx modela todo app e biblioteca como um project separado, cada um com o próprio project.json. O mapa workspaces do .envshed.json alinha um a um: uma entrada de workspace Envshed por project Nx.

Passo 1 — Deixe cada project ler o próprio .env.

O Nx pega automaticamente apps/<nome>/.env quando executa um target no cwd desse project, então basta envshed pull escrever nesse caminho.

Passo 2 — Torne o pull uma dependência de tarefa do Nx.

Adicione um target run-commands local por project e encadeie com dependsOn nos targets que precisam de 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"
    }
  }
}

Passo 3 — Declare env vars em namedInputs.

O Nx só faz hash das env vars que você declara — mesma regra do Turbo. Use namedInputs pra esse conjunto ser reutilizável entre targets:

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

O que não fingimos resolver

Porque você vai notar se não falarmos disso.

Não rodamos sidecar nem proxy.

O Envshed escreve valores em arquivos .env no disco. Depois que o arquivo está lá, o seu código lê da forma normal — o que quer dizer que qualquer pessoa na máquina consegue ler. Se você precisa de um limite de secret só em memória de processo, o Envshed não é a resposta pra esse requisito específico.

Não substituímos os secret stores de CI para deploys de produção.

Vercel, AWS Parameter Store, 1Password Secrets Automation, GitHub Actions Environments — na maioria dos times essas ferramentas cuidam dos deploys de produção, e assim deve ser. O Envshed é a experiência de dev do dia a dia: o pull local, o bootstrap de onboarding, o ambiente de staging que o product manager precisa no laptop. Integra com secret stores de produção — veja o guia do GitHub Actions — não substitui eles.

Não resolvemos drift de schema no nível da linguagem.

Se o seu código lê process.env.DATABSE_URL com um typo, o Envshed não pega. Use @t3-oss/env-core, Zod ou validators feitos à mão no entrypoint de cada pacote. O Envshed diz quais keys existem; o seu runtime diz se são as keys que você esperava.

Esses limites seguram o resto. Um gerenciador de secrets que diz resolver tudo é ou um proxy que você tem que operar ou um lock-in do qual você não consegue sair. O Envshed não é nenhum dos dois.

FAQ

Arquivos .env devem ser commitados num monorepo?

Não, e a resposta não muda entre single-repo e monorepo. Arquivos .env contêm valores de secret de um ambiente específico; o lugar deles é no .gitignore. O que você commita é o .envshed.json — ele só tem slugs e caminhos de arquivo, nenhum valor. Se você usa o padrão .env.example hoje, mantenha como referência das keys obrigatórias; o Envshed não substitui esse arquivo.

Como compartilhar uma env var, tipo DATABASE_URL, entre pacotes sem duplicar?

Aponte os dois workspaces pro mesmo project Envshed. apps/api e apps/worker declaram { "project": "platform-api" } no mapa de workspaces do .envshed.json. Um DATABASE_URL vive num project Envshed só e é puxado pros dois workspaces no envshed pull. Rotaciona uma vez; os dois pacotes pegam no próximo pull.

E o Next.js pegando o .env.local automaticamente?

Suportado. Defina "file": ".env.local" no config desse workspace e o envshed pull escreve no caminho que o Next.js espera. Overrides por usuário que ficam fora do config compartilhado podem viver ao lado como um segundo arquivo local — o Next.js junta os dois com as regras de precedência documentadas.

Como rodar o repo inteiro com env de staging num comando só?

envshed pull --env staging && pnpm dev. A flag --env sobrescreve o ambiente padrão de todos os workspaces de uma vez. Útil pra product reviews, reprodução de bug, e aquela investigação ocasional tipo "como ficam os dados de prod nessa feature branch".

Funciona com Docker Compose?

Funciona. Rode envshed pull antes do docker compose up — o Compose lê .env do contexto de build como qualquer outra coisa. A alternativa em tempo de CI é um service token Envshed no runtime do Compose; a referência da CLI cobre o fluxo do token.

Em que isso difere de Doppler, 1Password Secrets Automation ou Vault?

Aqueles são gerenciadores de secrets de uso geral; o Envshed é nativo de monorepo. O padrão config-na-raiz, o pull por workspace e o escopo via --workspace são comportamentos de primeira classe, não workflows que você monta em volta de uma ferramenta de uso geral. Na escala de 2–50 devs, a UX do dia a dia é o produto inteiro. Se você precisa de policy-as-code e HSM multirregional, já passou do tamanho do Envshed.

Veja o padrão rodando

Em vez de copiar snippets desta página, clone o monorepo de demo. São três pacotes — uma API Fastify, um web app Next.js 15 e uma biblioteca de auth compartilhada — montados com pnpm-workspace.yaml, turbo.json e um .envshed.json usando o schema real deste guia. Do git clone até um servidor de dev rodando em menos de cinco minutos.

github.com/envshed/monorepo-secrets-example

Público, licença MIT. Fastify + Next.js 15 + pnpm workspaces + Turborepo, com turbo.json configurado pra mudanças em arquivo .env realmente invalidarem o cache.

Leitura relacionada

Pare o malabarismo com .env-por-pacote

Um comando na raiz. Cada workspace puxa exatamente o que precisa. US$ 5 por usuário, sem pegadinha.

Começar grátis

Um guia pilar do Envshed.