One codebase, many environments
Your app runs in at least three places — your laptop, staging, production — and each needs different database URLs, API keys, log levels, and feature flags. The wrong way is branching on a hardcoded environment name and committing different configs. The right way is the 12-factor approach: config lives in the environment, not the code.
This guide covers structuring config, handling secrets, and wiring up per-environment values without forking your code.
The core principle
Same artifact, different config. The exact same build (Docker image, bundle) runs in every environment; only the environment variables differ. This means:
- No
if (env === 'prod')scattered through your code reading hardcoded values. - No secrets in the repo.
- A new environment is just a new set of env vars, not a code change.
Reading config from the environment
Centralize config reading into one module that validates at startup. Fail fast if something required is missing — a missing DATABASE_URL should crash on boot, not 500 on the first request.
// config.js
function required(name) {
const v = process.env[name];
if (v === undefined || v === "") {
throw new Error(`Missing required env var: ${name}`);
}
return v;
}
export const config = {
env: process.env.NODE_ENV ?? "development",
databaseUrl: required("DATABASE_URL"),
redisUrl: process.env.REDIS_URL ?? null,
logLevel: process.env.LOG_LEVEL ?? "info",
// typed/parsed, not raw strings everywhere
port: Number(process.env.PORT ?? 3000),
featureNewBilling: process.env.FEATURE_NEW_BILLING === "true",
};For stronger guarantees, validate the whole shape with a schema library (zod, env-schema) so typos and missing values are caught immediately:
import { z } from "zod";
const schema = z.object({
DATABASE_URL: z.string().url(),
PORT: z.coerce.number().default(3000),
LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"),
});
export const env = schema.parse(process.env); // throws on invalid configLocal development: .env files
For local dev, a .env file is convenient — but it must never be committed.
# .env (gitignored!)
DATABASE_URL=postgresql://localhost:5432/myapp_dev
LOG_LEVEL=debug
FEATURE_NEW_BILLING=true# .gitignore
.env
.env.*
!.env.exampleCommit a .env.example with the *keys* and dummy values so teammates know what's needed:
# .env.example (committed)
DATABASE_URL=
REDIS_URL=
LOG_LEVEL=info
FEATURE_NEW_BILLING=falseIn production, you don't use .env files — you set real environment variables in the platform.
Separating secrets from config
Not all config is equal:
- Non-secret config:
LOG_LEVEL,PORT, feature flags, public URLs. Fine to be visible. - Secrets:
DATABASE_URL, API keys,JWT_SECRET, Stripe keys. Must be stored securely and never logged.
Never log the full config object — you'll leak secrets into log aggregation. Redact:
console.log("config", { ...config, databaseUrl: "[redacted]" });Per-environment values on PandaStack
PandaStack lets you set environment variables per service. The pattern for dev/staging/prod:
- 1Create a service per environment (or per branch), e.g.
myapp-stagingandmyapp-prod. - 2Set the same keys in each with environment-appropriate values.
- 3Attach the right managed database to each —
DATABASE_URLis injected per environment automatically, so staging points at the staging DB and prod at the prod DB. - 4Mark secret values as secrets so they're stored encrypted and not shown in plaintext.
Because the database connection is injected per service, you never accidentally point staging at the production database — a genuinely dangerous and common mistake.
Feature flags as config
Simple on/off behavior differences belong in env-driven flags, letting you enable a feature in staging before prod:
if (config.featureNewBilling) {
useNewBillingFlow();
} else {
useLegacyBilling();
}For dynamic flags (toggle without redeploy), use a feature-flag service; for build-time/deploy-time differences, env vars are perfect.
Anti-patterns to avoid
- Committing secrets (even "temporarily" — they live forever in git history).
- Hardcoded environment branches reading literal values from code.
- Different code per environment — drift makes "works in staging, breaks in prod" inevitable.
- Logging full config — leaks secrets.
- One shared database across environments — staging tests should never touch prod data.
Checklist
- Config read from env, validated at startup, fail-fast on missing required values.
.envgitignored;.env.examplecommitted.- Secrets stored as platform secrets, never in the repo or logs.
- Same build artifact across all environments.
- Separate database per environment, injected automatically.
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
- zod schema validation: https://zod.dev/
- dotenv: https://github.com/motdotla/dotenv
- Feature flags overview (Martin Fowler): https://martinfowler.com/articles/feature-toggles.html
---
Want per-environment config and secrets with the database injected automatically per service? PandaStack handles env vars and DATABASE_URL injection per environment. Try it free at https://dashboard.pandastack.io