Back to Blog
Tutorial9 min read2026-06-28

How to Manage Environment-Specific Configuration

Dev, staging, and prod need different config without different code. Here's how to manage environment-specific configuration with env vars, the 12-factor way, plus secrets handling.

Ajay Kumar
Ajay Kumar
Founder & DevOps, PandaStack

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 config

Local 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.example

Commit 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=false

In 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:

  1. 1Create a service per environment (or per branch), e.g. myapp-staging and myapp-prod.
  2. 2Set the same keys in each with environment-appropriate values.
  3. 3Attach the right managed database to each — DATABASE_URL is injected per environment automatically, so staging points at the staging DB and prod at the prod DB.
  4. 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.
  • .env gitignored; .env.example committed.
  • 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

Ready to deploy?

Start free on PandaStack.

Start free on PandaStack

More in Tutorial

Browse all Tutorial articles →

See also