# How to Manage Environment Variables Across Deployments
Environment variables are how you separate configuration from code — a core principle of the [Twelve-Factor App](https://12factor.net/config). They're also where a lot of production incidents start: a missing variable, a secret committed to Git, or staging quietly drifting from production. This tutorial covers doing it right.
Why environment variables at all?
The same container image should run in development, staging, and production without modification. What changes between those is *config*: database URLs, API keys, feature flags, log levels. Baking those into the image means rebuilding for every environment and risks leaking production secrets into your registry. Reading them from the environment keeps one artifact, many configurations.
Config vs. secrets — they're not the same
A distinction that matters more than people think:
| Type | Examples | Sensitivity | Where it lives |
|---|---|---|---|
| Config | LOG_LEVEL, PORT, FEATURE_X=true | Low | Plain env vars |
| Secrets | DB passwords, API keys, JWT signing keys | High | Encrypted secret store |
Both arrive in your app as environment variables, but they should be *managed* differently. Secrets need encryption at rest, restricted access, and audit trails. Config can be more freely shared and version-controlled (as templates).
Step 1: Use a .env file locally — and gitignore it
For local development, a .env file is fine. The critical rule: never commit it.
# .gitignore
.env
.env.local
.env.*.localCommit a template instead so teammates know what's needed:
# .env.example (safe to commit — no real values)
DATABASE_URL=
REDIS_URL=
STRIPE_SECRET_KEY=
LOG_LEVEL=infoIf you've ever committed a real secret, rotate it immediately — Git history is forever, and scrapers find committed keys within minutes.
Step 2: Read variables defensively
Fail loudly at startup if a required variable is missing, rather than crashing mysteriously mid-request.
function requireEnv(name) {
const val = process.env[name];
if (!val) {
throw new Error(`Missing required env var: ${name}`);
}
return val;
}
const config = {
databaseUrl: requireEnv('DATABASE_URL'),
logLevel: process.env.LOG_LEVEL || 'info', // optional with default
};Validating config at boot is one of the highest-ROI defensive patterns you can adopt. A pod that refuses to start with a clear error is far better than one that serves 500s.
Step 3: Keep environments organized
The goal is parity: staging should differ from production only in values, not in *which* variables exist. A common failure is adding a new variable to production but forgetting staging, so the next staging deploy breaks.
Strategies:
- Maintain one canonical list of variable *names* (your
.env.example). - Set values per environment in your platform's config UI/API.
- Review diffs between environments before major releases.
Step 4: Handle changes without downtime
Changing an environment variable usually requires restarting the app to pick it up — most processes read env vars once at startup. On a well-run platform, updating a variable triggers a rolling restart so there's no outage. Plan for this: a variable change is a deploy, not a free action.
For truly dynamic values you don't want to redeploy for, use a runtime config source (a settings table or feature-flag service) rather than environment variables.
Managing env vars on PandaStack
PandaStack lets you set environment variables per app through the dashboard, and they're delivered to your container's runtime environment at startup. Secrets are handled as part of the platform's secret management rather than ending up in your image or your Git history.
A couple of platform-specific notes worth knowing:
- When you attach a managed database, PandaStack injects
DATABASE_URLautomatically — you don't set it by hand, and you shouldn't override it. - Certain platform-reserved variables are protected so a stray value in your config can't break the runtime wiring.
- Updating a variable rolls out as a new deployment, so your change takes effect cleanly without you SSHing into anything.
This keeps the same mental model across environments: your code always reads process.env.SOMETHING, and the values come from the platform, not from a file you might accidentally commit.
Common mistakes
- ❌ Committing
.envwith real secrets - ❌ Different variable *sets* across environments (drift)
- ❌ Silent fallback to a default for a required secret
- ❌ Logging full config (which logs secrets) at startup
- ✅ Gitignore
.env, commit.env.example - ✅ Validate required vars at boot
- ✅ Keep variable names identical across environments
- ✅ Treat secrets as encrypted, access-controlled data
References
- [The Twelve-Factor App: Config](https://12factor.net/config)
- [OWASP: Secrets Management Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Secrets_Management_Cheat_Sheet.html)
- [Kubernetes: ConfigMaps](https://kubernetes.io/docs/concepts/configuration/configmap/)
- [dotenv documentation](https://github.com/motdotla/dotenv)
Set your environment variables once and deploy across environments cleanly on PandaStack — start free at [dashboard.pandastack.io](https://dashboard.pandastack.io).