Back to Blog
DevOps14 min readJun 2026

Environments & Config: Dev / Staging / Prod Done Right

Why do teams keep separate dev, staging, and prod environments, and how do you avoid the classic 'it worked in staging' disaster? This is the practical guide to environment parity, keeping config in the environment instead of in code (the 12-factor way), separating secrets from config, and promoting one identical artifact through every stage.

EnvironmentsConfig12-FactorDevOps
SB

Sri Balaji

Founder · TheSimplifiedTech

On this page

The four most expensive words in software

"But it worked in staging." Those four words have preceded more outages than almost anything else in this field. You test a change, it passes everywhere you can see, you ship it, and it falls over in production for reasons that seem to make no sense. Nearly always, the cause isn't the code. It's a difference between environments, or a piece of configuration that was baked into the wrong place.

Environments and configuration are unglamorous, but getting them right is what separates teams that ship calmly from teams that fear every deploy. The principles are simple and they pay off immediately. Let's build the model.

Who this is for

Junior engineers who can deploy an app but have hardcoded a database URL at least once and felt that something was off. No prior infra knowledge needed. The patterns here apply whether you deploy to a VM, a container platform, or a serverless runtime.

Why separate environments at all?

An environment is a complete, isolated copy of the place your software runs, its own servers, its own database, its own config, so you can test changes somewhere that isn't where your users live.

If you only had one environment, every experiment would happen on your users. A bad migration would corrupt real data; a half-finished feature would be visible to everyone. So teams keep a ladder of environments, each closer to production, so a change has to survive several gates before it touches a real customer.

PurposeWho uses itData
DevelopmentBuild & experiment, break things freelyEngineersFake / seed data
StagingFinal rehearsal, a production look-alikeQA, the teamRealistic, anonymized
ProductionThe real thing, real usersCustomersReal, sensitive data
The classic three-rung ladder. Some teams add more (QA, UAT), but these three are the backbone.

Think of it like a stage play. Dev is rehearsing lines in your living room. Staging is the full dress rehearsal on the real stage with costumes and lighting. Production is opening night with a paying audience. The whole point of dress rehearsal is that it matches opening night closely enough to catch problems before the audience does, which brings us to the most important concept here.

Environment parity: keep the rungs close

Environment parity means your environments are as similar as possible. The bigger the gap between staging and production, the more bugs slip through staging undetected, because you tested under conditions that don't match reality. "It worked in staging" almost always means "staging and prod weren't actually the same."

Classic parity gaps that bite teams: staging runs an older database version than prod; staging has one server while prod has ten behind a load balancer; staging uses SQLite while prod uses Postgres. Each difference is a place where a bug can hide. You'll never get perfect parity (prod has real traffic and real data scale), but every gap you close is a class of bug you eliminate.

Pro tip

Containers and infrastructure-as-code are the best parity tools there are. If staging and prod are built from the same Docker image and the same Terraform, the environments are identical by construction, not by someone remembering to keep them in sync. This is a major reason teams adopt them.

The core rule: config lives in the environment, not the code

Here's the principle that fixes most config pain, straight from the Twelve-Factor App methodology: anything that differs between environments must live in the environment, not in your code. Your database URL, API keys, feature flags, log levels, none of that belongs hardcoded in your source.

The litmus test is simple: could you make your codebase open-source right now without leaking any secrets or breaking any environment? If the answer is no because there's a production password or a staging URL sitting in a source file, your config is in the wrong place.

config.bad.ts
typescript
// ❌ BAD: config hardcoded in the code.
// Different per environment, secrets in source control,
// and you'd have to edit code to change environments.
export const config = {
  databaseUrl: "postgres://admin:hunter2@prod-db:5432/app",
  apiKey: "sk_live_abc123",
  logLevel: "debug",
};
config.good.ts
typescript
// ✅ GOOD: config read from environment variables.
// The SAME code runs in every environment; only the
// values injected from outside change.
function required(name: string): string {
  const value = process.env[name];
  if (!value) throw new Error(`Missing required env var: ${name}`);
  return value;
}

export const config = {
  databaseUrl: required("DATABASE_URL"),
  apiKey: required("API_KEY"),
  logLevel: process.env.LOG_LEVEL ?? "info",
};

The good version reads everything from environment variables and fails loudly at startup if a required one is missing, so a misconfiguration crashes immediately on boot, not silently at 2am when the first request hits the missing setting. Note the sensible default for LOG_LEVEL: non-secret config can have defaults; secrets never should.

Injecting config: env files and the real thing

Locally, a .env file (loaded by your framework or a library like dotenv) is the friendly way to set environment variables. Crucially, this file is never committed, you commit a .env.example template with the keys but no values, so teammates know what to set.

.env.example (committed, no real values)
bash
# Copy to .env and fill in. .env itself is gitignored.
DATABASE_URL=postgres://user:pass@localhost:5432/app_dev
API_KEY=your-dev-key-here
LOG_LEVEL=debug

In real environments you don't use .env files at all, the platform injects the variables. In a container platform or CI you set them as environment config; in Kubernetes they come from ConfigMaps (non-secret) and Secrets. The application code doesn't care where the values come from, it just reads process.env. That's the whole elegance of the pattern: one code path, many environments.

deploy.yml (excerpt, values come from the platform)
yaml
env:
  - name: DATABASE_URL
    valueFrom:
      secretKeyRef:       # a secret, stored encrypted
        name: app-secrets
        key: database-url
  - name: LOG_LEVEL
    value: "info"         # plain config, fine in the manifest

Secrets vs config: a critical distinction

Both come from the environment, but they are *not* the same and must be handled differently. Config is non-sensitive (log level, feature flags, a region name). Secrets are sensitive (passwords, API keys, tokens), if they leak, you have an incident.

ConfigSecrets
ExamplesLOG_LEVEL, REGION, feature flagsDB password, API keys, tokens
Sensitive?NoYes, leaking = incident
Can have a default?OftenNever
Stored inConfigMap / plain envSecret manager (encrypted)
Safe to log?UsuallyNever log them
Treat them differently even though both are injected as environment variables.

Never commit a secret

A secret pushed to Git is compromised the moment it's pushed, even if you delete it in the next commit, it lives in history forever, and bots scan public repos for keys within minutes. If it happens: rotate the secret immediately (assume it's burned), then clean history. Prevention beats cleanup: gitignore `.env`, and use a secret scanner in CI.

Promote one artifact, change only the config

Now the payoff that ties it all together. Because your code reads config from the environment, the exact same build artifact can run in dev, staging, and prod, you don't rebuild per environment. You build once, then promote that identical artifact up the ladder, injecting different config at each rung.

  1. 1

    Build once

    CI builds a single immutable artifact (e.g. a container image tagged with the commit SHA). No environment-specific code is baked in.

  2. 2

    Deploy to staging with staging config

    Run that exact image in staging, injecting STAGING database URL, keys, and flags from the environment. Verify it works.

  3. 3

    Promote the same image to prod

    Deploy the byte-for-byte identical image to production, injecting PROD config. Nothing about the code changed, only the values around it.

This is why "config in the environment" and "build once, deploy many" are two sides of the same coin. If config were baked into the code, you'd need a different build per environment, and you could never be sure the prod build matched the one you tested. Keep config outside, and the thing you tested in staging *is* the thing that ships.

Common mistakes that cost hours

  1. Hardcoding config in source. A database URL or key in code means editing and rebuilding to change environments, and leaking secrets into version control.
  2. Committing the `.env` file. Commit .env.example with empty values; gitignore the real .env. A committed .env is a leaked secret.
  3. Big parity gaps. If staging runs a different database, version, or scale than prod, staging stops predicting prod and "it worked in staging" becomes a recurring headline.
  4. Treating secrets like config. Secrets need encrypted storage, no defaults, and must never be logged. Don't drop a production password into a plain config file.
  5. Rebuilding per environment. Build one artifact and promote it. Rebuilding for prod means you shipped something you never actually tested.
  6. Silent missing config. Read required vars at startup and crash loudly if they're absent, far better than a vague failure deep in a request hours later.

Takeaways

The whole article in seven lines

  • Environments (dev/staging/prod) are isolated copies so you don't experiment on users.
  • Environment parity is the goal, the closer staging is to prod, the fewer surprises.
  • Keep config in the environment, not in the code (the 12-factor rule).
  • Use .env locally (never committed); let the platform inject config in real environments.
  • Config vs secrets are different: secrets get encrypted storage, no defaults, no logging.
  • Validate required config at startup and fail loud and early.
  • Build the artifact once and promote it up the ladder, changing only the config.

Where to go next

Config and environments are part of a larger discipline for building cloud-native apps. Go deeper on the methodology, wire config into your pipeline, and see the full DevOps track.

Want to go deeper?

This article covers concepts taught hands-on in the Cloud Engineer and DevOps career paths, with real terminal labs, production scenarios, and structured lessons.