What belongs in environment config and what doesn't

Environment config covers everything that changes from one deploy to the next while the code stays the same. The Twelve-Factor App puts three kinds of value in this group: handles to backing services such as the database or cache, credentials for external services, and per-deploy values such as the canonical hostname. Internal wiring is different. Route tables and the way modules are composed are identical in every deploy, so they belong in code, where they get reviewed and versioned along with the logic they shape.

Belongs in environment config Stays in code
Database and cache connection URLs Route definitions
API keys and service credentials Dependency injection and module wiring
Public hostname, ports, log level per deploy Business rules and default algorithms
Feature limits that differ between staging and production Validation logic and data schemas

To check whether you've drawn the line correctly, ask whether you could open-source the repository right now without leaking a credential. If you couldn't, some config is still hard-coded or committed.

This guide is for backend and platform engineers who run the same service on a laptop, in CI pipelines and in production. In that setup, most "works on my machine" failures aren't code bugs. They happen because one environment is missing a value, holds a stale one, or holds a value of the wrong type. Treat config as an explicit contract: a known set of names, types and required values that every environment must satisfy. Then a mismatch shows up as an error when the service starts, or as a failed CI check. Without that contract, it shows up as strange behaviour after deploy.

Define config once as a typed, validated schema

Declare every variable in one module of application code. Each entry needs a name, a type (string, integer, boolean, URL, duration), a required flag, an optional default, and a secret flag. This module becomes the single inventory of what the service reads. Nothing else in the codebase should call the raw environment lookup directly. Code that bypasses the schema can't be validated or audited.

Validate the whole schema once at process startup, before the service opens a port or a connection. Collect every failure before exiting. An error that lists all five missing or malformed variables takes one fix-and-redeploy cycle. Reporting them one at a time takes five.

Encoding structure in flat strings

Environment variables are strings with no nesting. Pick one encoding per shape and apply it everywhere:

  • Comma-separated values for simple lists.
  • JSON for anything nested.
  • A shared prefix such as CACHE_ to group related scalars.

Parse these in the schema layer. Business logic should receive a typed list or object and never split strings itself, so the encoding rules live in one place.

Defaults versus required

Give defaults only to values that are safe in every environment, such as a timeout or a log level. Anything environment-specific should be required with no default: database URLs, hostnames, credentials. A default production URL that silently applies on a developer laptop is worse than a startup error.

Fail-fast has a cost: a typo in a production variable stops the deploy. Keep that cost small with two things. First, error messages should name the variable and the expected type, and should never print the rejected value of a secret. Second, rollback to the last known-good config should be a single, rehearsed step.

import json
import os
import sys
from dataclasses import dataclass
from typing import Any, Callable, Optional

@dataclass(frozen=True)
class Var:
    name: str
    kind: str                      # human-readable type for error messages
    parse: Callable[[str], Any]    # flat string -> typed value
    default: Optional[str] = None  # only for values safe in every environment
    secret: bool = False           # never echo the raw value if True

def csv_list(raw: str) -> list:
    return [item.strip() for item in raw.split(",") if item.strip()]

# The single inventory of everything this service reads from the environment.
SCHEMA = [
    Var("DATABASE_URL", "url", str, secret=True),           # env-specific: required, no default
    Var("ALLOWED_ORIGINS", "comma-separated list", csv_list),
    Var("FEATURE_FLAGS", "JSON object", json.loads, default="{}"),
    Var("HTTP_TIMEOUT_SECONDS", "integer", int, default="10"),
]

def load_config(env=os.environ) -> dict:
    config, errors = {}, []
    for var in SCHEMA:  # check every variable; do not stop at the first failure
        raw = env.get(var.name, var.default)
        if raw is None or raw == "":
            errors.append(f"{var.name}: missing, expected {var.kind}")
            continue
        try:
            config[var.name] = var.parse(raw)
        except ValueError:  # json.JSONDecodeError is a ValueError subclass
            shown = "<redacted>" if var.secret else repr(raw)
            errors.append(f"{var.name}: invalid value {shown}, expected {var.kind}")
    if errors:  # exit before opening ports or connections
        sys.exit("Invalid configuration:\n  " + "\n  ".join(errors))
    return config

A Python config schema that declares each variable's type, default and secret flag, parses lists and JSON in one place, and reports every missing or invalid variable at startup without printing secret values.

How values flow from local env files, CI variables and production

The schema is the same in every environment. Only the source of the values changes, and each source fails in its own way.

Local development

Commit a .env.example that lists every key the schema expects. Give each key a placeholder value, and add a short comment where the format isn't obvious. Add the real .env to .gitignore. When someone adds a key to the schema, they update the example file in the same change, so reviewers see the new key and new engineers have a complete checklist.

Only dev tooling should load .env, such as the task runner, the test harness or a local start script. Application startup code should never load it. If production code reads .env, a stray file left on a host or copied into an image can silently override values the platform injected. Nobody can then tell which source won.

CI

Put non-secret values, such as test hostnames or feature flags, in the pipeline config so they go through code review. Put secrets in the CI system's protected or masked store. Scope each secret to the jobs and branches that need it. A lint job has no reason to hold a deploy key. Never expose secrets to jobs triggered by pull requests from forks, because those jobs run code nobody has reviewed yet.

Production

Inject values at deploy or start time from the platform's config or secret mechanism. Never bake them into images. If you need a rebuild to change a value, config has leaked into the build, and you can no longer promote the same artifact unchanged from staging to production.

#!/usr/bin/env bash
# scripts/dev.sh: local development only. Deploys never run this script.
set -euo pipefail

if [[ ! -f .env ]]; then
  echo "No .env found. Copy .env.example to .env and fill in real values." >&2
  exit 1
fi

# Fail early if .env is missing any key that .env.example (the committed checklist) lists.
missing=0
while IFS= read -r line || [[ -n "$line" ]]; do
  # Skip blank lines and comments in the example file.
  [[ -z "$line" || "$line" == \#* ]] && continue
  key="${line%%=*}"
  if ! grep -q "^${key}=" .env; then
    echo "Missing in .env: ${key}" >&2
    missing=1
  fi
done < .env.example
(( missing == 0 )) || exit 1

# Export every assignment in .env into this process's environment.
# set -a marks each sourced variable for export to child processes.
set -a
source .env
set +a

# The app only reads plain environment variables and has no .env loader of its own.
# In CI and production, the same variables come from the pipeline or the platform instead.
exec ./bin/server

A local-only start script checks .env against the committed .env.example, exports its values, and starts the app, so the application itself never loads .env and reads only the process environment in every setting.

Prefer granular variables over named environment bundles

A common way config goes wrong is grouping it into named environments. The application reads APP_ENV, and code branches on it: if APP_ENV == "staging" turns off email delivery, if APP_ENV != "production" enables verbose SQL logging, and if APP_ENV in ("dev", "qa") points payments at a sandbox. Each new deploy needs a new name. The Twelve-Factor App warns that this does not scale. Staging and QA come first, then per-developer copies like joes-staging. Every name multiplies the branches you have to reason about, and that leads to a combinatorial explosion of config.

The fix is to replace each branch with a variable that names the behavior, not the deploy. EMAIL_DELIVERY_ENABLED=false, SQL_LOG_LEVEL=debug and PAYMENTS_API_BASE_URL each control one thing, and none depends on the others. A new deploy then needs no code changes, only a set of values. You can also reproduce a production bug locally by copying the specific settings involved, without pretending to be "production" everywhere.

Most teams will still have dev, staging and prod pipelines, and that is fine. Pipeline names are a useful way to organize deployment tooling, such as which CI job runs, which value set is loaded and who approves the release. The rule is to keep those names out of application logic. APP_ENV can stay as a label for logs and metrics, as long as no code path branches on it.

import os

# BEFORE: behavior is inferred from the deploy name.
# Every new environment (qa, joes-staging, ...) means touching these branches.
#
# app_env = os.environ.get("APP_ENV", "dev")
# send_email = app_env != "staging"
# sql_log_level = "INFO" if app_env == "production" else "DEBUG"
# payments_url = (""
#                 if app_env in ("dev", "qa")
#                 else "")


def env_bool(name: str, default: bool) -> bool:
    """Parse a true/false environment variable, falling back to a default."""
    raw = os.environ.get(name)
    if raw is None:
        return default
    return raw.strip().lower() in ("1", "true", "yes", "on")


# AFTER: each variable controls one behavior and is set per deploy.
# Defaults are the safe choice, so a missing value never emails real users.
EMAIL_DELIVERY_ENABLED = env_bool("EMAIL_DELIVERY_ENABLED", default=False)
SQL_LOG_LEVEL = os.environ.get("SQL_LOG_LEVEL", "INFO").upper()

# No safe default exists for an external endpoint, so fail fast if it is unset.
PAYMENTS_API_BASE_URL = os.environ["PAYMENTS_API_BASE_URL"]

# APP_ENV is kept only as a label for logs and metrics. No code branches on it.
DEPLOY_LABEL = os.environ.get("APP_ENV", "unknown")

The same settings shown two ways: first as branches on APP_ENV, then as separate variables that each control one behavior, with APP_ENV kept only as a label for logs and metrics.

Detecting config drift in CI

Drift starts when the three places that describe config stop agreeing: the schema the application validates at startup, the example env file developers copy, and the variable list each environment declares in CI and in the deploy platform. A CI job can enforce agreement by comparing key names only. It extracts the key set from each source, computes the differences, and fails if a key appears in one set but not another. The failure output should name each missing or unexpected key and where it was found, so the fix is obvious from the log. Optional keys with defaults still belong in the schema and the example file, but marked optional so the check doesn't require them in every environment.

Pair the check with a review rule: any change that adds, renames or removes a variable must update the schema, the example file and the environment declarations in the same change as the code that reads it. When these land separately, a deploy can ship code that expects a key nobody has set yet.

This check proves that a variable exists. It does not prove that the value is right. A staging database URL pasted into production passes every key comparison. Protecting values requires scoped access controls on who can edit production variables, plus human review of those edits.

The comparison is fast because it only reads names. Most of the cost is elsewhere. Listing deployed variables needs read access to each environment, so use credentials that can list names without reading values where your platform supports that. The extraction scripts also need upkeep whenever the schema format or an environment's storage changes.

#!/usr/bin/env python3
# Usage: check_env_drift.py config.schema.json .env.example envs/staging.keys envs/production.keys
# Compares key NAMES only. Values are never read, so a wrong value still passes.
import json, sys, pathlib

def schema_keys(path):
    s = json.loads(pathlib.Path(path).read_text())
    # All declared keys, plus the subset every environment must set.
    return set(s.get("properties", {})), set(s.get("required", []))

def example_keys(path):
    keys = set()
    for line in pathlib.Path(path).read_text().splitlines():
        line = line.strip()
        if line and not line.startswith("#") and "=" in line:
            keys.add(line.split("=", 1)[0].removeprefix("export ").strip())
    return keys

def list_keys(path):
    # One name per line, produced by an earlier CI step using list-only credentials.
    return {l.strip() for l in pathlib.Path(path).read_text().splitlines() if l.strip()}

def main(schema, example, *env_lists):
    all_keys, required = schema_keys(schema)
    ex = example_keys(example)
    errors = [f"{example}: missing {k} (declared in {schema})" for k in sorted(all_keys - ex)]
    errors += [f"{example}: unexpected {k} (not in {schema})" for k in sorted(ex - all_keys)]
    for path in env_lists:
        declared = list_keys(path)
        # Optional keys have defaults, so only required keys must be set everywhere.
        errors += [f"{path}: missing required {k}" for k in sorted(required - declared)]
        errors += [f"{path}: unexpected {k} (not in {schema})" for k in sorted(declared - all_keys)]
    for e in errors:
        print(e)
    sys.exit(1 if errors else 0)

if __name__ == "__main__":
    main(*sys.argv[1:])

A standard-library Python check compares key names across a JSON schema, the example env file and per-environment key lists. It names each missing or unexpected key and where it was found, and exits non-zero on any mismatch.

Secrets, rotation and safe logging

Environment variables work well for most config but are a weak container for high-value credentials. Any library loaded into the process can read the whole environment. Child processes inherit it by default. On Linux, the same user or root can read it from /proc/<pid>/environ. Values also end up in crash dumps, error-tracker payloads and debug pages that print the environment.

For credentials whose leak would be serious, such as database admin passwords, signing keys and payment API keys, fetch them at runtime from a dedicated secret manager. Keep only a reference and the means to authenticate in the environment. The trade-off is a new startup dependency. If the secret manager is unreachable, the service cannot boot, so decide in advance whether to fail fast or retry with a bounded backoff.

Approach Behaviour Rotation cost Main risk
Read once at startup Value fixed for process lifetime Requires restart or redeploy Stale credential until restart
Reload on change Re-fetched on signal or interval No restart Partial reloads, harder to reason about

Zero-downtime rotation follows the same steps with either approach:

  1. Issue the new credential.
  2. Configure the consumer to accept both old and new values.
  3. Switch all producers to the new value.
  4. Confirm nothing still uses the old value.
  5. Revoke the old credential.

At startup, log the resolved config, but show secrets only as present or absent, never as values or partial values. Scrub environment data from crash reports and debug endpoints. When you spawn subprocesses, pass them an explicit, minimal environment instead of letting them inherit everything.

When to avoid it, alternatives, and impact on cost and engineering time

The full schema-plus-drift-check setup pays off when several people deploy one service to several places. You can skip it for a single-environment internal tool or a script that will be deleted next week. A short README note and a fail-fast check at startup cover most of the risk there.

Environment variables are also the wrong vehicle in two cases. The first is large structured config, such as routing tables or nested feature-flag rules. In an env var it becomes an escaped JSON string that nobody can review in a diff. The second is highly sensitive secrets. Child processes inherit the environment by default, and it can surface in process inspection and crash dumps.

Alternative Good fit Trade-off
Config files mounted at runtime Structured, non-secret config Needs its own loader and validation, and adds another format
Dedicated secret manager Credentials, rotation, audit trails The app depends on it being reachable at startup, and you maintain access policies
Centralized config service Many services, changes without redeploy Another system to run; changes skip code review unless you add a review step

Your committed example file is public documentation, so watch what it reveals. Internal hostnames, vendor names and service names give attackers a map of your system. Use generic placeholders.

Illustrative estimate (assumed numbers, not a benchmark). Payback in quarters = setup hours ÷ (incidents per quarter × hours per incident + onboardings per quarter × hours saved per onboarding).

Assume:

  • 16 hours of setup
  • 2 missing-variable incidents per quarter at 3 hours each
  • 2 onboardings per quarter, each 2 hours faster

That gives 16 ÷ (6 + 4) = 1.6 quarters. The formula leaves out the image rebuilds you avoid by not baking config into images, which makes the real payback faster.

How OneEnv can help

Disclosure: OneEnv is the product behind this blog.

OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.

Applied to manage environment variables, this is where it helps:

Saves time

  • Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
  • One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
  • Changing a shared variable is one edit and one review instead of editing every service's .env by hand.

Saves cost

  • Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
  • Less duplicated configuration to maintain: one shared definition inherited by many services.
  • A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
  • OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.

These are qualitative benefits, not measured results — how much you save depends on how many services share the value.

Want to try it? Start at oneenv.io.