
How to Manage Environment Variables Across Dev, Staging & Production
Keep environment variables in sync across development, staging, and production. Covers per-environment .env files, secrets vs config, CI/CD pipelines, team workflows, and avoiding config drift.
Managing Environment Variables Across Environments
Setting an environment variable is the easy part. The hard part starts when the same variable needs three different values — one for your laptop, one for staging, one for production — and ten developers all need to stay in sync. That's where config drift creeps in: a deploy fails because production is missing a key that's been on everyone's laptop for weeks, or staging quietly points at the production database because someone copied the wrong .env file.
This guide is about managing environment variables across environments, not the per-platform syntax for setting them. If you need the raw export / set / $env: commands for a specific OS or shell, start with How to Set Environment Variables — that's the cross-platform reference. Here we focus on the lifecycle: separating config by environment, keeping secrets out of Git, wiring variables into CI/CD, and giving a whole team a single source of truth.
Separate Config by Environment, Not by Machine
The core principle behind twelve-factor config is that anything that varies between deploys — database URLs, API keys, feature flags — lives in the environment, not in code. The same build artifact ships to dev, staging, and production; only the environment changes.
In practice that means one set of variable names and three sets of values:
| Variable | Development | Staging | Production |
|---|---|---|---|
DATABASE_URL | postgres://localhost:5432/app_dev | postgres://staging-db/app | postgres://prod-db/app |
LOG_LEVEL | debug | info | warn |
STRIPE_API_KEY | sk_test_… | sk_test_… | sk_live_… |
ENABLE_BETA | true | true | false |
The names stay identical so your code reads process.env.DATABASE_URL everywhere without branching on the environment. Resist the temptation to invent DATABASE_URL_PROD and DATABASE_URL_DEV — that pushes environment logic into your code, which is exactly what env vars are supposed to remove.
Manage your environment variables the secure way
Stop sharing .env files over Slack. EnvManager stores, versions, and syncs your secrets — encrypted, with role-based access.
Start free — no credit cardPer-Environment .env Files
The most common way to hold these value sets is a layered set of .env files. Most frameworks (Next.js, Vite, Rails, Laravel, Spring Boot) support a load order that goes from general to specific:
.env # shared defaults, committed (no secrets)
.env.local # local overrides, gitignored
.env.development # dev-only values
.env.staging # staging-only values
.env.production # production-only values
Later, more specific files override earlier ones. A typical setup:
# .env (committed — safe defaults and non-secret config)
LOG_LEVEL=info
ENABLE_BETA=false
APP_NAME="My Application"
# .env.local (gitignored — your personal machine)
DATABASE_URL=postgres://localhost:5432/app_dev
STRIPE_API_KEY=sk_test_local_key
LOG_LEVEL=debug
Docker Compose applies the same idea by merging files, with later entries winning:
services:
web:
image: myapp
env_file:
- .env # base config
- .env.production # environment-specific overrides
The pattern works, but it has a failure mode: the files only exist on machines where someone put them. There's no central record of which variables should exist, so a missing key surfaces as a runtime crash rather than a clear error — more on that below.
Secrets vs Configuration
Not every environment variable deserves the same treatment. It helps to split them into two buckets:
- Configuration — non-sensitive values like
LOG_LEVEL,ENABLE_BETA,PORT,APP_NAME. Safe to commit in a base.env, safe to read in logs. - Secrets — credentials that grant access:
DATABASE_URL,STRIPE_API_KEY,JWT_SECRET, OAuth tokens. These must never be committed, never logged, and should be rotatable without a code change.
Treating both buckets identically is how secrets end up in Git history. Keep non-secret config in a committed .env (or a .env.example), and route secrets through a secret store or a dedicated manager. For the full case on why scattered .env files become a liability, see Why .env Files Are a Security Nightmare.
Wiring Variables into CI/CD
CI/CD is where environment separation gets enforced automatically — the pipeline injects the right values for the target environment so a human never copies a production secret by hand.
GitHub Actions scopes secrets per environment and injects them at the step level:
jobs:
deploy:
runs-on: ubuntu-latest
environment: production # pulls this environment's secrets
env:
LOG_LEVEL: warn
steps:
- name: Deploy
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
STRIPE_API_KEY: ${{ secrets.STRIPE_API_KEY }}
run: npm run deploy
GitHub Environments let you attach a distinct set of secrets to staging and production, plus protection rules (required reviewers, wait timers) before a production deploy runs.
GitLab CI does the same with protected and environment-scoped variables:
deploy:
stage: deploy
environment: production
script:
- npm run deploy
only:
- main
Mark production variables as protected so they're only exposed on protected branches, and masked so they don't appear in job logs. For deeper CI patterns and Git-specific variables, see the Git Environment Variables guide.
The rule that matters: production credentials live in the CI system's secret store (or a connected secret manager), never in the repository or the pipeline YAML itself.
Common Cross-Environment Mistakes
Staging pointing at production. The classic. A developer copies .env.production to test something against staging and forgets to swap DATABASE_URL. Now staging writes to the production database. Guard against it by never copying production files by hand — pull each environment's config from a single source.
Missing variables surface as crashes, not errors. When a deploy is missing JWT_SECRET, the app usually crashes deep in a request rather than refusing to boot. Validate required variables at startup so a missing key fails loudly and immediately:
const required = ['DATABASE_URL', 'JWT_SECRET', 'STRIPE_API_KEY']
const missing = required.filter((key) => !process.env[key])
if (missing.length) {
throw new Error(`Missing required env vars: ${missing.join(', ')}`)
}
Treating every value as a string. Environment variables are always strings, so ENABLE_BETA=false is the truthy string "false". Parse explicitly:
ENABLE_BETA = os.getenv('ENABLE_BETA', 'false').lower() == 'true'
Stale .env.example. The committed example file drifts from reality the moment someone adds a variable and forgets to update it. New developers then boot with a half-configured app. A single source of truth that everyone pulls from removes the example-file problem entirely.
A Single Source of Truth for Your Team
The traditional workflow — .env.example in the repo, real values passed through Slack, 1Password, or a shared Google Doc — breaks down predictably as a team grows:
- New developers hunt down
.envfiles in DMs to onboard - Nobody knows which variables exist across dev, staging, and production
- "Works on my machine" bugs trace back to drifted local config
- Secrets get shared through channels that were never meant to hold them
- A rotated key has to be re-distributed to everyone, by hand
For teams managing variables across multiple projects and environments, EnvManager (14-day free trial) centralizes this. Define each variable once, organize values by environment, and have everyone pull from one source instead of passing .env files around. Secrets are encrypted, changes propagate immediately, new developers onboard with a single command, and an audit log records who changed what and when. Setting a variable stays platform-specific — but managing the full set across dev, staging, and production becomes one consistent workflow instead of a scavenger hunt.
Further Reading
- How to Set Environment Variables — The cross-platform reference for the actual syntax on Linux, macOS, and Windows
- Why .env Files Are a Security Nightmare — The security risks of scattered
.envfiles and how to fix them - Environment Variables in Python —
os.environ, python-dotenv, and structured config with Pydantic - Environment Variables in Java —
System.getenv()and Spring Boot externalized configuration - Git Environment Variables — Identity, SSH, and CI/CD usage
Manage your environment variables the secure way
Juggling different values for dev, staging, and production across a whole team? EnvManager keeps every environment's secrets encrypted, versioned, and one command away — no more drifted .env files or "what's the staging DATABASE_URL?" in Slack.