What configuration drift is and the three types you need to control
Configuration drift happens when the configuration a service actually runs with stops matching what you meant it to run with. That mismatch can be between environments, between services, or between what is declared and what is live. It is easier to control once you split it into three types, because each one fails differently and needs a different fix.
| Drift type | What it looks like | Control that addresses it |
|---|---|---|
| Shape drift | A key exists in staging but not production, or one service reads DB_URL while another reads DATABASE_URL |
A typed schema that every environment and service is validated against |
| Value drift | The same key has different values in different places, and nobody intended the difference | One source of truth, with base values plus explicit per-environment overrides |
| Runtime drift | The running process doesn't match the declared config because of a manual edit, a stale deploy or a missed restart | Runtime checks that compare live state with the declared state |
Scope matters here. The Twelve-Factor definition treats config as values likely to vary between deploys: backing-service handles, credentials and per-deploy hostnames. Internal wiring, such as routes or how modules connect, doesn't vary between deploys and belongs in code. Keeping it there shrinks the drift surface, so fewer keys need to be managed and checked.
Drift is a structural problem, not a discipline problem. Each environment or service you add multiplies the places a key can be missing or wrong. Careful engineers still miss a key when nothing checks the whole set. The people who benefit most are backend and platform engineers running several services across several environments, where those combinations grow fastest.
How a single source of truth with a typed schema works
The fix is to make the full set of keys an artifact you can read and check. Each service keeps one version-controlled declaration of every key it reads. Each entry records the key's type, whether it is required, a safe default where one exists, and which environments are allowed to override it. A reviewer can then see the service's whole configuration surface in one file, and a tool can compare it against what each environment actually supplies.
Secrets as references, not values
Secrets go in the declaration only as references, such as a path or name in a secret store. The process resolves them at startup. This keeps the repository consistent with the Twelve-Factor litmus test: you could open-source the codebase without leaking any credentials. It also means a secret rotation never shows up as a config diff. Only the reference is versioned.
The flow
- Declaration. The schema lists every key. A key that is not declared does not exist, so stray variables are rejected instead of silently ignored.
- Per-environment values. Each environment supplies only the values it is permitted to override. Everything else comes from the declared default.
- Startup validation. Before serving traffic, the process checks the resolved set against the schema for missing required keys, type mismatches and undeclared keys. If any check fails, startup fails.
- Config hash. The running process computes a hash over the resolved non-secret values and exposes it, for example on a health or metadata endpoint. Two instances that should match can be compared by hash without dumping their contents. A mismatch is the first signal of drift.
+---------------------------+ +---------------------------+
| config.schema (in VCS) | | Per-environment values |
| key, type, required, | | dev / staging / prod |
| default, overridable_in | | (only permitted keys) |
| secrets: ref only | +-------------+-------------+
+-------------+-------------+ |
| |
+----------------+-----------------+
|
v
+---------------------------+ +--------------+
| Resolve: default+override |<-----| Secret store |
| secret refs -> values | | (by ref) |
+-------------+-------------+ +--------------+
|
v
+---------------------------+
| Startup validation |----> missing / wrong type
| required? type? declared? | / undeclared key
+-------------+-------------+ => startup FAILS
| pass
v
+---------------------------+
| Running process |
| GET /health -> cfg hash |
| (non-secret values only) |
+-------------+-------------+
|
v
hash(A) == hash(B) ? mismatch => drift
A version-controlled schema and per-environment overrides are merged, then secret references are resolved from the secret store and the result is validated at startup; the running process exposes a hash of its non-secret config so instances can be compared for drift.
Implementing it: schema, base-plus-overrides layout, and fail-fast startup
The schema file
Start with one declaration file that lists every key the service reads. For each key it records the type, whether the key is required, any allowed range or enum, and whether it is a secret. Every other artifact gets checked against this file.
Base plus overrides
Keep a base file that holds values common to all deploys. Then add one small override file per deploy that sets only the values that differ. Overrides can change values but never shape. They cannot add keys the schema lacks or change a key's type. This applies the Twelve-Factor point that config values should be granular and orthogonal rather than bundled into named environment groups. Groups like staging or qa multiply as deploys grow, and each new copy becomes another place where drift can start. Per-key overrides keep each deploy's difference visible in a few lines.
CI and startup checks
In CI, merge the base with each override and validate the result against the schema. Fail the build if a required key is missing, if a key is not declared in the schema, or if a value has the wrong type. Extra keys matter because they usually mean a typo or a stale setting that nothing reads.
At startup, run the same validation before the service opens any listeners. On failure, exit non-zero with a message that names the key and the deploy, for example "PAYMENTS_TIMEOUT_MS missing in prod-eu."
Required vs optional
Give a key a default only when that default is correct in every deploy. A permissive default such as a localhost URL lets production boot on the wrong value and hides the drift. When in doubt, make the key required.
# validate_config.py - run in CI per deploy, and at startup before opening listeners.
# Usage: python validate_config.py prod-eu config/base.json config/prod-eu.json
import json
import sys
# Single source of truth: every key the service reads.
# Defaults only where the value is correct in EVERY deploy.
SCHEMA = {
"PAYMENTS_TIMEOUT_MS": {"type": int, "required": True},
"PAYMENTS_API_URL": {"type": str, "required": True, "secret": False},
"PAYMENTS_API_KEY": {"type": str, "required": True, "secret": True},
"LOG_LEVEL": {"type": str, "required": False, "default": "info",
"enum": ["debug", "info", "warn", "error"]},
}
def load(path):
with open(path) as f:
return json.load(f)
def validate(deploy, base_path, override_path):
# Overrides change values only; shape is enforced by the checks below.
merged = {**load(base_path), **load(override_path)}
errors = [f"{k} not declared in schema (typo or stale key?)"
for k in sorted(merged.keys() - SCHEMA.keys())]
for key, rule in SCHEMA.items():
if key not in merged:
if rule["required"]:
errors.append(f"{key} missing")
else:
merged[key] = rule["default"]
continue
value = merged[key]
# bool is a subclass of int in Python, so reject it explicitly.
if isinstance(value, bool) or not isinstance(value, rule["type"]):
errors.append(f"{key} must be {rule['type'].__name__}")
elif "enum" in rule and value not in rule["enum"]:
errors.append(f"{key} must be one of {rule['enum']}")
for e in errors:
print(f"config error in {deploy}: {e}", file=sys.stderr)
if errors:
sys.exit(1) # fail fast: non-zero exit breaks the build or the boot
return merged
if __name__ == "__main__":
validate(*sys.argv[1:4])
A standard-library Python validator merges a base config with one deploy's override file and checks the result against a schema. It exits non-zero and names the deploy and the offending key when a required key is missing, a key is not declared in the schema, or a value has the wrong type.
Detecting runtime drift and handling values shared across services
Detecting drift at runtime
Fail-fast validation proves only that the config was valid when the process booted. To see what is actually running, have each service compute a hash of its effective config after all layers and overrides are merged. Sort the keys so ordering doesn't change the hash, and keep secrets out of the input or hash them separately so the digest can't leak them. Expose the hash on an info or health endpoint. Then run a scheduled job that computes the expected hash from the declared config for each environment and alerts on any mismatch.
Hot-reloaded config makes this harder, because a hash taken at startup records what was loaded then, not what is live now. Recompute the hash on every reload and report it with the reload timestamp. If you don't, the detector raises false alarms after legitimate reloads and misses reloads that pulled in a bad value.
If the alert fires because someone changed production by hand, revert the change or commit it back to the source of truth. Don't leave it in place. A fix that exists only in a running process disappears on the next deploy, and nobody will know why the bug came back.
Values shared across services
Queue names, internal API base URLs and feature flags often have to match across services. Declare each one once and have consumers reference it, not copy the literal. The cost is coupling: one edit now reaches every consumer, so a mistake has a larger blast radius. Share only values that must be identical. Where you can, roll changes out in stages: run the old and new values side by side, such as a new queue next to the old one, and switch consumers over one at a time.
import hashlib
import json
from datetime import datetime, timezone
# Top-level keys that hold secrets. Leave them out of the digest so the hash can't leak them.
SECRET_KEYS = {"db_password", "api_token"}
def config_hash(effective: dict) -> str:
public = {k: v for k, v in effective.items() if k not in SECRET_KEYS}
# sort_keys sorts nested dicts too, so key order never changes the hash.
canonical = json.dumps(public, sort_keys=True, separators=(",", ":"))
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
class LiveConfig:
"""Keeps the hash in step with the config that is live right now, not just the one loaded at boot."""
def __init__(self, effective: dict):
self.apply(effective)
def apply(self, effective: dict) -> None:
# Call this at startup and again on every hot reload.
self.values = effective
self.hash = config_hash(effective)
self.loaded_at = datetime.now(timezone.utc).isoformat()
def info(self) -> dict:
# Serve this from the service's /info or health endpoint.
return {"config_hash": self.hash, "config_loaded_at": self.loaded_at}
def check_drift(service: str, declared: dict, reported: dict) -> bool:
# A scheduled job runs this for each service and environment, using the declared config.
expected = config_hash(declared)
if reported["config_hash"] != expected:
print(f"DRIFT {service}: expected {expected[:12]}, got "
f"{reported['config_hash'][:12]} (loaded {reported['config_loaded_at']})")
return True
return False
live = LiveConfig({"orders_queue": "orders-v2", "billing_url": "", "db_password": "placeholder"})
check_drift("orders", {"orders_queue": "orders-v2", "billing_url": ""}, live.info())
A service hashes its merged config with sorted keys and no secrets, recomputes the hash on every reload, and reports it with a timestamp, so a scheduled job can compare it against the hash of the declared config.
Putting config changes through code review and staged rollout
Treat a config change as a deploy. Every change goes through a pull request, and CI renders the effective config for each environment, meaning base plus overrides fully resolved. Reviewers should see the diff of the final values, not just the override files. A one-line change to a shared base file can alter five environments, and that is only obvious once the output is resolved.
The rendered diff also exposes asymmetric edits. In this illustrative example, someone raised a timeout during an incident and only touched production:
| Key | staging | production | Status |
|---|---|---|---|
payments.timeout_ms |
2000 | 5000 | flagged: differs, no label |
api.replica_count |
2 | 12 | expected: scale |
tracing.sample_rate |
1.0 | 0.05 | expected: cost |
payments.endpoint |
sandbox URL | live URL | expected: external-env |
Mark intentional differences next to the override, with a short reason. Instance sizes, replica counts, sampling rates, and sandbox versus live endpoints are typical cases. The drift check skips labeled keys and fails on everything else. Without labels, alerts fire on every expected difference, people start ignoring them, and real drift gets lost among them. Adding a label is itself a reviewable change, so "we meant this" is written down.
Each merge produces a versioned config artifact, such as a commit hash. Apply it to staging first, then a canary, then the rest of production. Rolling back means redeploying the previous version, not reconstructing old values by hand.
The review path has to work under pressure. Keep a fast lane for incidents, such as a single approver or an expedited pipeline that still records the change. If the official path takes an hour, engineers will edit live systems directly, and the source of truth will no longer match what is running.
#!/usr/bin/env python3
# Drift check run in CI on every config PR. Inputs are the *effective* configs
# (base + overrides already resolved) rendered for each environment.
# Usage: drift_check.py rendered/staging.json rendered/production.json expected_diffs.json
import json
import sys
def flatten(d, prefix=""):
# Nested config -> dotted keys, e.g. {"payments": {"timeout_ms": 2000}} -> payments.timeout_ms
out = {}
for k, v in d.items():
if isinstance(v, dict):
out.update(flatten(v, f"{prefix}{k}."))
else:
out[f"{prefix}{k}"] = v
return out
def load(path):
with open(path) as f:
return json.load(f)
staging = flatten(load(sys.argv[1]))
production = flatten(load(sys.argv[2]))
# Labeled intentional differences live in the repo, so adding one is a reviewed change:
# {"api.replica_count": "scale", "tracing.sample_rate": "cost", "payments.endpoint": "external-env"}
expected = load(sys.argv[3])
drift = []
for key in sorted(staging.keys() | production.keys()):
s, p = staging.get(key, "<missing>"), production.get(key, "<missing>")
if s == p:
continue
if key in expected:
print(f"expected {key}: {s!r} -> {p!r} ({expected[key]})")
else:
drift.append(key)
print(f"FLAGGED {key}: {s!r} -> {p!r} (no label)")
# Non-zero exit fails the PR: either align the value or add a labeled exception.
sys.exit(1 if drift else 0)
A minimal CI drift check that diffs the resolved staging and production configs, skips keys labeled as intentional differences, and fails the pull request on any unlabeled mismatch such as a production-only timeout change.
When to use this approach, when to avoid it, and the alternatives
The full approach pays off when several services read overlapping values, you run at least two long-lived environments, and config changes ship weekly or more often. In that situation configuration drift comes from too many copies of the same values, so a single source of truth removes the problem at its root. For a single service with one environment, or a prototype whose keys change daily, the schema and review gates cost more than they save. Teams in the middle of a migration should phase it in. Start by declaring a schema for the keys that have already caused incidents, then move the rest over gradually.
| Approach | Strength | Weakness |
|---|---|---|
| Hardcoded constants | Simple, versioned with code | Requires redeploying to change; secrets end up in the repo |
| Unversioned config files | Kept out of code | Easy to commit by accident; scattered across formats and locations (12factor) |
| Plain environment variables | Language-agnostic, hard to commit by accident | Untyped and flat; visible in process listings, crash dumps and child processes |
| Schema-backed source of truth | Typed, reviewable, diffable across environments | Upfront setup and ongoing ownership |
Two trade-offs need deliberate handling. First, a strict schema can block an urgent deploy because of a malformed non-critical key. To avoid this, mark keys as required or optional, and let optional keys fall back to a default with a logged warning. Second, perfect environment parity turns into noise if you chase it everywhere. Staging should differ from production in replica counts and endpoints. Declare those differences as expected so that drift alerts only fire on divergence nobody intended.
Impact on development cost and engineering time
The upfront work is real. You have to write a schema for each service, migrate services one at a time, build the diff and hash tooling, and keep committing manual changes back to the repository. That last cost never goes away, because a single hand-edited value that never makes it back to the repository brings drift straight back.
The savings fall into four kinds. A bad value becomes a failed deploy instead of a production incident. When environments disagree, a diff tells you where, so debugging is shorter. Reviewers see config changes the same way they see code. Rollback becomes a revert.
To estimate the break-even point, use this formula:
Break-even (months) = upfront hours ÷ (incident hours avoided + debugging hours saved − maintenance hours per month)
Illustrative estimate (assumed numbers, not a benchmark):
- Upfront cost: 3 services × 16 hours of schema and migration work, plus 24 hours of tooling, totals 72 hours.
- Incidents: one config-caused incident per month at 6 engineer-hours. Caught as a failed deploy, it costs 1 hour, which saves 5.
- Debugging: three environment-mismatch investigations per month, each 2 hours shorter, saves 6.
- Maintenance: 4 hours per month.
- Result: 72 ÷ (5 + 6 − 4) ≈ 10 months.
The result depends heavily on how often config causes incidents. If that rate doubles in the example, break-even drops to about 5 months. If config rarely breaks anything for you, it may never pay back.
Migrate in an order that pays off early:
- Fail-fast validation on the service with the most config incidents. It costs the least and starts paying off immediately.
- Base-plus-overrides layout for that service, then the next most incident-prone services.
- CI diffs between environments, once a few services share the layout.
- Runtime hash checks last. They only pay off after the source of truth is reliable.
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 configuration drift, 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
.envby 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.