What counts as configuration in a microservice

The Twelve-Factor App gives a definition that holds up well: configuration is everything likely to vary between deploys. In practice that means three kinds of values:

  • Backing-service handles: database URLs, cache endpoints, queue addresses.
  • Credentials: API keys, tokens, and passwords for anything outside the process.
  • Per-deploy values: the canonical hostname, region, or replica-specific settings.

Some things look like config but aren't. Route tables, dependency-injection wiring, and how modules connect to each other stay the same in staging and production. They belong in code, where they are reviewed, versioned, and tested with the logic they shape. Moving them into external config adds knobs nobody should turn per environment, and it adds another place to look when behavior changes.

A quick audit: could this repository be made open source right now without leaking anything? If the answer is no because a connection string or token is committed, then deploy-varying values have leaked into code. Run the check on each service. With dozens of repos, at least one usually fails.

Defaults are harder to judge. A timeout or pool size hard-coded with a sensible default shrinks the config surface, so each deploy has fewer values to set and fewer to get wrong. The cost is visibility. An operator reading the deploy's settings can't see that the service is using a 30-second timeout, because that value exists only in source.

This guide is for backend and platform engineers running more than a handful of services. At that scale, these boundaries start to break down without anyone noticing.

Who should use it

A managed configuration layer built on schemas, layered resolution, and drift checks is aimed at specific roles and specific conditions.

Roles that benefit directly:

  • Platform engineers who own shared values such as broker endpoints, log sinks, and cluster domains. They need to know which services consume a key before changing it.
  • Service teams who own their own keys and want a bad value caught at startup, not on the first request that exercises a rare code path.
  • On-call engineers who need to answer "what config is this instance actually running, and where did each value come from?" during an incident.
  • Security and compliance owners who need secrets kept out of repositories and an audit trail of who changed what and when.

Conditions that make it worth adopting:

  • Several services read the same backing-service handles or shared defaults.
  • The same services run in more than two environments, and those environments are meant to differ only in known ways.
  • More than one team can change config that another team's service depends on.
  • Config changes have caused incidents that were slow to diagnose because nobody could see the resolved values.

If you run one service, or a few owned by a single team in one or two environments, the lighter approach described later in "When to use it" is the better fit.

Why environment variables are the delivery mechanism, not the management layer

The Twelve-Factor App makes the case for storing config in the environment. Its reasons still hold. Environment variables are a language- and OS-agnostic standard. They change between deploys without touching code. Unlike config files, they rarely end up in the repository by accident. Every runtime can read them, and every orchestrator can set them. That makes them a good contract between the platform and the process.

A contract is not a system of record, though. An env var tells a process what DB_POOL_SIZE is. It says nothing about where that value lives, who approved the last change, which team owns it, or whether forty other services carry a stale copy. Across a fleet, those questions decide whether a config change is routine or an incident.

The format has its own costs. Every value is a flat string, so lists and maps get encoded ad hoc: comma-separated here, JSON there, a numeric suffix somewhere else. Each service writes its own parser. Most processes also read the environment once at startup, so changing a value means restarting the process. Treat that as a constraint you design around, not a bug.

The workable model separates the layers:

  • Versioned source of truth. Values live in a reviewed, versioned store.
  • Deploy-time resolution. The pipeline resolves the values for a specific service and environment.
  • Injection. The platform injects them as env vars, or as mounted files for secrets.
  • Validation. The process validates them before serving traffic.

The environment carries config from one layer to the next. It does not manage it.

+--------------------------------------------+   MANAGEMENT LAYER
| VERSIONED SOURCE OF TRUTH                  |   where values live,
| reviewed, versioned store                  |   who approved them,
| owners + audit history per value           |   who owns them
+--------------------------------------------+
                      |
                      | approved change
                      v
+--------------------------------------------+
| DEPLOY-TIME RESOLUTION (pipeline)          |
| values for one service + one environment   |
+--------------------------------------------+
                      |
- - - - - - - - - - - | - - - - - - - - - - - - - - - - - - - - - -
                      v                          DELIVERY MECHANISM
+--------------------------------------------+
| INJECTION (platform / orchestrator)        |   the platform-to-
| config  -> env vars (flat strings)         |   process contract
| secrets -> mounted files                   |
+--------------------------------------------+
                      |
                      | read once at startup
                      v
+--------------------------------------------+
| RUNNING PROCESS                            |
| parse + validate types -> serve traffic    |
| invalid config -> fail fast, no traffic    |
+--------------------------------------------+
  to change a value: edit source -> redeploy -> process restarts

Config is stored, reviewed, and resolved above the line; environment variables and mounted files only carry the resolved values to a process that validates them once at startup.

How it works end to end

The layers above describe where config lives. This is the path a single change takes through them:

  1. Author the change in the source of truth. An engineer edits a value in the versioned store: a service repo for service-local keys, a shared config repo for shared keys, an environment definition for platform values. Secrets are changed in the secrets manager, and the repo holds only a reference to them.
  2. Review and validate before merge. The change goes through the same review as code. CI checks it against the schema of every service that reads the key: types, ranges, and required keys. For shared keys, CI also lists the consuming services, so the reviewer can see the blast radius.
  3. Resolve per service and environment. At deploy time the pipeline merges the layers in their documented precedence order (code defaults, shared defaults, per-environment platform values, per-deploy overrides, secrets). It records which layer supplied each key, and it records the config version (for example, the commit) that was resolved.
  4. Inject. The platform delivers non-secret values as environment variables and secrets as mounted files with narrow permissions.
  5. Validate at startup. The process checks every key against its schema, reports all errors together, and exits before serving traffic if anything is invalid. The orchestrator sees the failure and halts the rollout, while the previous version keeps running.
  6. Log what was loaded. A valid process logs its resolved non-secret config once, with value sources and the config version, and redacts secrets.
  7. Check for drift continuously. A scheduled job and CI compare declared values with what instances report, and compare environments against an allowlist of intended differences. Anything else is flagged.

Rolling back a config change follows the same path: revert the commit and redeploy. Config does not need a separate rollback mechanism. The following sections cover each step in detail.

Classifying config by scope, ownership, and precedence

Before deciding where a value lives, decide who owns it and how many services read it. Most config falls into four classes, and each needs one source of truth that a single team can change:

Class Example Owner Source of truth
Service-local Worker pool size, feature flags Service team Service repo
Shared Message broker endpoint Platform team Shared config repo
Per-environment platform Region, log sink, cluster domain Platform team Environment definition
Secrets Database password, API keys Service team (grant), security (store) Secrets manager

Granular values, layered composition

The Twelve-Factor App warns against bundling config into named environments: once staging exists, qa and joes-staging follow, and every bundle drifts from the others. The fix is to keep each variable independent and build a deploy's config from layers, not to copy a bundle and edit it. A documented precedence order, from lowest to highest, could be: code defaults, shared defaults, per-environment platform values, per-deploy overrides, then secrets. Each key resolves from the highest layer that sets it. A new deploy then adds only its own override layer, and anyone can trace why a key has its value.

Trade-offs

Strict granularity means a fleet can carry hundreds of variables. Without tooling to show the resolved set for each deploy and diff it against another, nobody can review the result. Centralizing shared config also couples services to whoever owns it. A bad edit to the broker endpoint breaks every consumer at once, and the shared store becomes a dependency your deploys need to be available.

How to implement it: typed schemas, fail-fast validation, and safe secrets handling

Each service should own a schema that lists every key it reads: its name, type, whether it is required, a default if not, and allowed values or ranges. A pool size of 0 or a timeout of 30s where the code expects milliseconds should stop the process before it serves traffic. Failing at startup is cheaper than failing on the first request that hits the bad path, because the orchestrator sees the crash, halts the rollout, and keeps the old version running. Collect all validation errors and report them together, so one deploy attempt surfaces every problem instead of one per restart.

After validation, log the resolved non-secret config once, including where each value came from (default, file, or env). That single line is what you compare when two replicas behave differently.

Keep required keys to the minimum. Every key marked required becomes an outage the first time a new environment forgets it. Give optional settings safe defaults instead.

Secrets are a separate class

Secrets stay out of the repo and are injected at deploy time or runtime, so they can be rotated without a code change. Mark them in the schema as secret, and redact them in the startup log. Remember that an env var is readable by anything that can inspect the process environment. Common leak paths include crash dumps, debug endpoints that print config, child processes that inherit the environment, and error handlers that log the full environment. Mounting secrets as files with narrow permissions avoids the inheritance problem.

import json, os, sys

SCHEMA = {
    "DB_HOST":         (str, True,  None, None,          False),
    "DB_POOL_SIZE":    (int, False, 10,   (1, 200),      False),
    "HTTP_TIMEOUT_MS": (int, False, 5000, (100, 60000),  False),  # "30s" fails int()
    "DB_PASSWORD":     (str, True,  None, None,          True),
}

def load_config():
    cfg, src, errors = {}, {}, []
    for name, (typ, required, default, rng, secret) in SCHEMA.items():
        raw, origin = os.environ.get(name), "env"
        path = os.environ.get(name + "_FILE")  # e.g. /run/secrets/db_password
        if secret and path:  # prefer a mounted file: child processes don't inherit it
            try:
                with open(path) as f:
                    raw, origin = f.read().strip(), "file"
            except OSError as e:
                errors.append(f"{name}: cannot read {path}: {e.strerror}"); continue
        if raw is None:
            if required:
                errors.append(f"{name}: required but not set"); continue
            cfg[name], src[name] = default, "default"; continue
        try:
            val = typ(raw)
        except ValueError:  # never echo a secret's value in an error
            errors.append(f"{name}: expected {typ.__name__}, got " + ("<redacted>" if secret else repr(raw))); continue
        if rng and not rng[0] <= val <= rng[1]:
            errors.append(f"{name}: {val} outside {rng[0]}..{rng[1]}"); continue
        cfg[name], src[name] = val, origin
    if errors:  # report every problem at once, then crash before serving traffic
        print("config invalid:\n  " + "\n  ".join(errors), file=sys.stderr)
        sys.exit(1)
    shown = {k: ("<redacted>" if SCHEMA[k][4] else v, src[k]) for k, v in cfg.items()}
    print("config resolved: " + json.dumps(shown), file=sys.stderr)  # one line to diff across replicas
    return cfg

CONFIG = load_config()

A stdlib-only Python config loader that validates every key against a typed schema with ranges, collects all errors and exits before serving traffic, reads secrets from mounted files, and logs the resolved config with value sources and secrets redacted.

Detecting config drift and shipping config changes as deploys

Config drift has two forms. The first is a running value that differs from the declared source, usually because someone edited a deployment by hand or a secret rotated in one place only. The second is the same logical setting differing across environments without anyone intending it, such as a 2-second timeout in staging and 30 seconds in production. Both are dangerous because tests pass against a configuration that production does not run.

Detecting drift

Run three checks on a schedule and in CI:

  • Declared vs. deployed. Diff each service's resolved source against what instances actually report. This catches manual edits.
  • Environment vs. environment. For the same service, diff staging against production. Expected differences go on an explicit allowlist, so everything else stands out.
  • Missing keys. Flag keys present in one place but absent in another. A key missing in production often means the service silently fell back to a code default.

Config changes are deploys

Keep the source under version control and review changes the way you review code. Have each instance expose the config version it loaded, so a diff can name the commit that caused a regression.

Decide per key whether a change requires a restart or can be reloaded safely. Reload is faster, but instances pick up new values at different moments. For a short window, the fleet runs mixed config. That is acceptable for log levels, but not for keys that two services must agree on.

To rename or retire a shared key, run a deprecation window: read both names, warn when the old one is used, then remove it. A single flag-day change breaks every consumer that has not been redeployed yet.

Production hotfixes

Banning out-of-band edits conflicts with incident response, because a responder may need to change a value immediately. Allow the emergency change, but make it loud. Drift detection should flag the change, and the next normal deploy should either commit it to the source or revert it.

ALLOWED_ENV_DIFFS = {"db.host", "replicas"}  # intended staging/prod differences

def diff(a, b, a_name, b_name, ignore=frozenset()):
    findings = []
    for key in sorted(a.keys() | b.keys()):
        if key in ignore:
            continue
        if key not in b:
            findings.append(f"MISSING in {b_name}: {key}")  # may fall back to a code default
        elif key not in a:
            findings.append(f"MISSING in {a_name}: {key}")
        elif a[key] != b[key]:
            findings.append(f"DIFF {key}: {a_name}={a[key]!r} {b_name}={b[key]!r}")
    return findings

declared_prod = {"http.timeout_s": 30, "db.host": "db.prod.internal", "log.level": "info"}
deployed_prod = {"http.timeout_s": 30, "db.host": "db.prod.internal", "log.level": "debug",
                 "config_version": "a1b2c3d"}
declared_staging = {"http.timeout_s": 2, "db.host": "db.staging.internal", "log.level": "info",
                    "cache.ttl_s": 60}

version = deployed_prod.pop("config_version")
findings = diff(declared_prod, deployed_prod, "declared", "deployed")  # manual edits
findings += diff(declared_staging, declared_prod, "staging", "prod",
                 ignore=ALLOWED_ENV_DIFFS)  # unintended env differences

for f in findings:
    print(f"[config {version}] {f}")

raise SystemExit(1 if findings else 0)

A minimal drift check that diffs declared vs. deployed values and staging vs. production, skips allowlisted differences, flags missing keys, and tags findings with the loaded config version.

Worked example: resolving and drift-checking a small fleet

The following setup is assumed for illustration, not drawn from a real system. Three services, orders, payments and notifications, share a message broker endpoint and a default request timeout. Each also has values of its own. Resolution runs through three layers, and later layers win:

  1. Shared defaults. BROKER_URL points at the staging broker and REQUEST_TIMEOUT_MS=2000.
  2. Environment overrides. Production replaces BROKER_URL with the production broker and raises REQUEST_TIMEOUT_MS to 3000.
  3. Per-deploy overrides. payments in production sets REQUEST_TIMEOUT_MS=5000 because its upstream processor is slower.

The resolver writes out the final value for every key and records which layer supplied it. That record makes a drift report readable. When a value looks wrong, an engineer can see whether a default, an environment override or a deploy override produced it, and fix the right layer instead of patching the symptom.

Now assume two problems. During an incident, someone set orders production REQUEST_TIMEOUT_MS to 1500 by hand. Separately, staging gained DLQ_TOPIC for notifications before production did.

Service Key Declared (layer) Live staging Live production Drift result
orders REQUEST_TIMEOUT_MS 3000 (env override) 2000 1500 Value mismatch in production
payments REQUEST_TIMEOUT_MS 5000 (deploy override) 2000 5000 Clean
notifications DLQ_TOPIC not declared for production set absent Key missing in production

The check compares the live values against the declared values, not staging against production. The two environments are supposed to differ in places, so a straight comparison between them would bury the real problems under expected differences.

The orders timeout mismatch is exactly the emergency edit described above. The next deploy needs to make a decision about it: commit the change to the environment override, or let the deploy revert it.

The missing key needs a different decision. Someone has to say whether notifications in production should get a dead-letter topic before staging's behavior becomes production's by accident.

DEFAULTS = {"BROKER_URL": "amqp://broker.staging.internal:5672", "REQUEST_TIMEOUT_MS": "2000"}
ENV_OVERRIDES = {"production": {"BROKER_URL": "amqp://broker.prod.internal:5672", "REQUEST_TIMEOUT_MS": "3000"}}
DEPLOY_OVERRIDES = {("payments", "production"): {"REQUEST_TIMEOUT_MS": "5000"}}

def resolve(service, env):
    # Later layers win; remember which layer supplied each value.
    resolved = {}
    layers = [("default", DEFAULTS), ("env override", ENV_OVERRIDES.get(env, {})),
              ("deploy override", DEPLOY_OVERRIDES.get((service, env), {}))]
    for layer_name, values in layers:
        for key, value in values.items():
            resolved[key] = (value, layer_name)
    return resolved

def drift(service, env, live):
    # Compare live values against the declared values, never staging against production.
    declared = resolve(service, env)
    for key, (value, layer) in declared.items():
        if key not in live:
            print(f"{service}/{env}: {key} declared ({layer}) but missing live")
        elif live[key] != value:
            print(f"{service}/{env}: {key} live={live[key]} declared={value} ({layer})")
    for key in live.keys() - declared.keys():
        print(f"{service}/{env}: {key} set live but not declared")

drift("orders", "production", {"BROKER_URL": "amqp://broker.prod.internal:5672", "REQUEST_TIMEOUT_MS": "1500"})
drift("payments", "production", {"BROKER_URL": "amqp://broker.prod.internal:5672", "REQUEST_TIMEOUT_MS": "5000"})

staging_keys = {"BROKER_URL", "REQUEST_TIMEOUT_MS", "DLQ_TOPIC"}
prod_keys = {"BROKER_URL", "REQUEST_TIMEOUT_MS"}
for key in sorted(staging_keys - prod_keys):
    print(f"notifications: {key} present in staging, missing in production")

For the assumed fleet, this resolver applies layered precedence and records which layer supplied each key, then a drift check flags the hand-edited orders timeout in production and the DLQ_TOPIC key that exists in staging but not in production.

When to use it, when to avoid it, alternatives, and cost

That decision is the reason to adopt this approach at all. It earns its cost when many services share backing services, keys must stay consistent across several environments, or an auditor asks who changed a value and when. In those conditions, misconfiguration usually comes from changes nobody coordinated, and a typed, versioned layer makes those changes visible before they ship.

Defer it if you run one service or a few owned by one team. A plain env file plus sensible code defaults, as Twelve-Factor describes, is enough. Schemas and drift checks would be ceremony with little to protect.

Alternative Strength Trade-off
Config baked into images Immutable and reproducible Any change requires a rebuild, and the image may end up holding secrets
Mounted config files Change without rebuild Files scatter across formats, and a mounted file can differ from what the process actually loaded
Central runtime service with dynamic reload Changes take effect without restarts A new runtime dependency, and changes bypass deploy review and rollback
Per-repo env files Simple and local Shared keys drift silently between repos

The cost is mostly upfront: writing schemas, a resolver, and CI checks. In return, you spend less time chasing typos and missing keys. Incidents are faster to diagnose because each instance logs its resolved config and version. Shared keys are safer to change because the blast radius is computed before deploy instead of discovered after it.

How it saves engineering time

The time savings come from moving config failures earlier, to points where they are cheaper to find and fix, and from removing the manual work of reconstructing what a process is running.

  • Errors surface at review or startup, not in production traffic. Schema checks in CI reject a malformed value before merge. Fail-fast validation stops a rollout before any request reaches the bad config. Both replace an investigation with a readable error message.
  • One deploy attempt shows every problem. Because the loader collects all validation errors, a new environment that is missing four keys fails once with four messages. Without that, it fails four times with one message each, and every round trip costs a deploy.
  • Diagnosis starts from facts. The startup log line gives the resolved values, their sources, and the config version. Engineers compare two replicas or two releases directly, without reading deployment manifests, shell history, and override files to work out the effective value.
  • Drift is found by a job, not by an outage. Scheduled drift checks catch hand edits and missing keys while they are still harmless, so nobody has to rediscover them during an unrelated incident.
  • Shared changes need less coordination. When CI lists every consumer of a shared key, the platform team does not have to poll service teams about whether they read it. A deprecation window replaces a coordinated flag-day deploy.
  • Rollback is a revert. Config changes are commits, so undoing one uses the same deploy path the team already knows.

Illustrative estimate. The inputs below are assumed for the sake of the example and are not measured. Use your own incident history in place of them. Assume a fleet has 2 config-related incidents a month, and each takes 2 engineers 3 hours to diagnose because the effective config has to be reconstructed by hand. That is 12 engineer-hours a month. Assume the resolved-config log and drift reports cut diagnosis to 1 hour per incident for the same 2 engineers. That is 4 engineer-hours, which saves 8 engineer-hours a month. Assume the schemas, resolver, and CI checks take 80 engineer-hours to build. On these assumed inputs, the diagnosis savings alone cover the build cost in about 10 months. That figure does not count incidents prevented outright by CI and startup validation, and it does not count the ongoing work of maintaining schemas. Both can move the result in either direction, so measure your own baseline before and after adoption instead of relying on this estimate.

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 microservices configuration management, 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.