A Sane Workflow for Env Vars Across Environments
Blog

A Sane Workflow for Env Vars Across Environments

A practical workflow for managing environment variables across local, staging, production, and CI without drift, leaks, or 2am surprises.

READ MORE

TL;DR: Treat environment variables as a managed, versioned resource per environment, not files passed around in Slack. Define one schema, scope secrets by environment, inject them at runtime, and keep CI in sync with a single source of truth.

A sane workflow for environment variables has four rules: one schema shared by every environment, secrets scoped per environment, values injected at runtime instead of committed, and CI reading from the same source as your laptop. Get those four right and the usual failures mostly disappear: “works on my machine”, a missing STRIPE_KEY in production, a leaked .env in a Git history.

Below is a workflow you can adopt incrementally, tool-agnostic first, with notes on where a managed secrets manager helps.

The problem with the default .env workflow

Most teams start the same way: a .env file per developer, copied from someone else over Slack, plus a separate set of values pasted into the CI provider’s UI and the production host’s dashboard. Three or four copies of the truth, none of them authoritative.

That setup breaks in predictable ways:

  • Drift. A new variable lands in staging but nobody updates production until it 500s.
  • Onboarding friction. A new hire spends an afternoon collecting values from five people.
  • Leaks. A .env gets committed, or pasted into a ticket, and now a credential lives in Git history forever.
  • No audit trail. Someone rotated the database password. Who, when, and what was it before? Nobody knows.

The fix is not “be more careful”. It is to treat configuration as a managed resource with one source of truth.

Rule 1: one schema, all environments

Define the shape of your configuration once, independent of values. Every environment must satisfy the same schema; only the values differ. This catches the most common production incident before deploy instead of after: a variable that exists locally but was never set in prod.

A lightweight version in TypeScript:

// env.ts
import { z } from 'zod'

const schema = z.object({
    NODE_ENV: z.enum(['development', 'staging', 'production']),
    DATABASE_URL: z.string().url(),
    STRIPE_SECRET_KEY: z.string().startsWith('sk_'),
    REDIS_URL: z.string().url()
})

export const env = schema.parse(process.env)

Now a missing or malformed variable fails fast at boot with a clear message, in every environment, instead of surfacing as a null deref three requests later. The schema is your contract; the rest of the workflow is about supplying values to it safely.

Rule 2: scope secrets per environment

Local, staging, production, and CI are different trust boundaries. Your laptop should never hold production database credentials. Treat each environment as its own scoped set:

  • local. Dummy or sandbox values; a local Postgres, a Stripe test key.
  • staging. Real infrastructure, isolated data, test-mode third-party keys.
  • production. The real credentials, accessible to the fewest people possible.
  • CI. Only what tests and builds genuinely need, usually closest to staging.

The point is blast radius. A leaked local value should be worthless. A production value should be reachable by as few humans and systems as possible, ideally injected directly into the runtime and never written to disk.

This is where role-based access matters. Engineers might read and write local and staging but only senior on-call has production. If your current setup can’t express that, it is a sign the values are living in too many uncontrolled places.

Rule 3: inject at runtime, never commit

Values should arrive at the process at the moment it starts, not from a file tracked in source control. The mechanics differ by tool, but the shape is the same: pull the right set for the environment, hand it to the process as real environment variables, exit.

# instead of: source .env && npm start
envless run -- npm start

A few principles regardless of tooling:

  • .env* belongs in .gitignore, always. Add a pre-commit hook or a secret scanner so it can’t slip in.
  • Keep an .env.example with keys only, no values so the schema is discoverable.
  • Prefer wrapping the process (run -- <cmd>) over writing a file to disk, so secrets never persist on the filesystem.

If you are still curious why files are the weak link, we wrote a longer piece on the problems with .env files. The short version: files are easy to copy, hard to revoke, and impossible to audit.

Rule 4: CI reads from the same source

CI is where the “many copies” problem gets expensive, because pipeline failures block everyone. The fix is to make CI a consumer of the same source of truth as your laptop, not a separate copy pasted into the provider’s settings.

# .github/workflows/test.yml (illustrative)
steps:
    - uses: actions/checkout@v4
    - run: npm i -g @goenvless/cli@latest
    - run: envless run --env ci -- npm test
      env:
          ENVLESS_TOKEN: ${{ secrets.ENVLESS_TOKEN }}
          ENVLESS_KEY: ${{ secrets.ENVLESS_KEY }}
          ENVLESS_PROJECT: my-app

Now CI holds exactly two long-lived secrets: the machine token that authorizes the fetch, and the key that decrypts what comes back (the server only ever returns ciphertext, so the token alone is useless). Every other value comes from the central store and is updated in one place. When you rotate a key, CI, staging, and production all pick it up on their next run, without a hunt through three dashboards.

Where a secrets manager fits

You can implement most of this with discipline and your platform’s native secret store. A dedicated secrets manager earns its place when you need the parts that are tedious to build yourself:

  • Versioning and change history. Every change to every variable, attributed to who made it and when, so a bad rotation is one revert away.
  • One source of truth. Change a staging value once and every teammate and CI runner picks it up on their next sync or run, instead of re-sharing files.
  • Granular access control. Roles with fine-grained permissions, plus private scopes that stay visible only to the people you name.
  • Encryption you can reason about. Values encrypted on the client before they ever reach a server, so the store holds ciphertext only.

Envless is built around exactly this workflow: a CLI for run and sync, a typed SDK for Node, Bun, and Deno, plus a REST API for every other language, and a REST API for everything else. Values are encrypted client-side before upload, sign-in is passwordless so there is no reusable password to leak (and account access on its own reveals no value without the workspace passphrase), and every change is versioned with an attributed history on every variable. The free tier covers one project and one environment; pricing scales up to more environments and team access. The encryption design is covered in more depth on the security page if you want to evaluate the threat model before trusting it.

A checklist to adopt this week

  1. Write a schema and validate it at boot in every environment.
  2. Move all .env* files into .gitignore and add a secret scanner.
  3. Commit an .env.example with keys only.
  4. Pick a single source of truth: a managed store or your platform’s secret manager.
  5. Switch CI to pull from that source with one scoped token.
  6. Lock production behind explicit roles.

None of these steps require a rewrite. Do them in order and the “missing variable in prod” pages stop, onboarding drops to a single command, and you finally have an answer to “who changed this and when”.