Skip to content
Danube Labs

Your Config File Is an API, So Treat It Like One

A config file has callers, a contract and a failure mode. Validate it before the write, replace it atomically, control concurrent writers, keep history and explain every key.

By Alexandru Secureanu9 min read
A configuration file with schema, lock and history around it

A configuration file looks like a text file, so it gets edited like one. Someone opens it, changes a value, saves, restarts the service, and finds out whether the change was right. We would not accept that from any other interface. An endpoint that half the team calls with unchecked input, that can be rewritten while it is being read, and that nobody documented would be called a liability. A config file sits in exactly that position, usually with fewer safeguards.

The file has callers, inputs and a failure mode

Every key in a config file is a field in an interface. It has a type, allowed values, a default and an effect someone depends on. Its callers are the people and programs that edit it: a deploy script, an admin panel, a colleague at eleven at night, an agent. The failure mode differs from an API's, which is why the discipline slips. A bad API request returns 400 and the caller fixes it. A bad config key is accepted, the process starts, and the problem surfaces later as a wrong URL, a queue that never drains, or a service that cannot reach its database.

The comparison tells you what to build: validation, a versioned contract, atomic updates, concurrency control, history and documentation. Those are ordinary requirements for an endpoint. They are rare in a config file because the file is editable by hand and nothing forces the discipline.

Validate against a schema before the write

Most teams validate configuration at startup, if at all. The process reads the file, checks a few required keys and exits with an error when something is missing. That is better than nothing, but it puts the check at the worst moment: after the change is saved, during a restart, with the old process already gone.

Validation belongs at the point of the write. The editor, the CLI or the admin form should check the whole document against the same schema the application uses, and refuse to save one that fails. A JSON Schema is one way to express that: types, required fields, enumerations and constraints in a file both sides can read. The format matters less than the arrangement. There should be one canonical definition, not a validation function in the app and a different set of rules in the form.

Three decisions make schema validation useful in practice.

  • Unknown keys. Reject them or warn loudly. Silently ignoring a typo such as max_retires is how a setting ends up with no effect and no explanation.
  • Defaults. Keep them in the schema, not scattered through the code that reads each value.
  • Version. The schema carries a version, and the application refuses a config written for a newer one than it understands.

Validating at write time delivers the error to the person who made the change, while they still remember it. A startup check delivers it to whoever restarts the service, often someone else, often hours later.

Write atomically: temporary file, then rename

A write is not one operation. The editor truncates the file, writes the new bytes and closes it. If the process dies between truncate and close, or the disk fills, or the machine loses power, the file on disk is neither the old version nor the new one. The next start reads a truncated JSON document or half a YAML file, fails, and the working config is gone.

The fix is old and portable: write the complete new content to a temporary file in the same directory, flush it, then rename it over the original. On Unix-like systems rename(2) replaces the destination atomically, so another process sees either the old file or the new one and never a partial one. Windows has an equivalent in ReplaceFile, which also preserves the original file's attributes and access control lists. The temporary file must live on the same filesystem as the target, because a rename cannot cross volumes.

Two details are easy to miss. Flush and sync before the rename if the machine can lose power, since the rename is atomic with respect to other processes but not necessarily durable against a power cut. And preserve the original permissions. A temporary file created with the defaults inherits the writer's umask, so a config that was readable only by the service account can end up readable by everyone. Libraries handle both, for example npm's write-file-atomic, which writes under a temporary name, applies the existing owner and mode, renames, and serialises concurrent writes inside one process.

Decide who is allowed to write

Atomic replacement protects against a crash. It does not protect against two writers. If an admin panel and a CLI both edit the file, the second save silently discards the first. This is the lost update problem, which HTTP already solved for resources.

If-Match can be used to prevent the lost update problem.

That sentence comes from MDN's description of the header. When config is exposed over HTTP, send the version you read and require the server to reject the write if the file has changed since. For a local file, the equivalent is a lock. flock(2) is advisory on Unix: a process that does not ask for the lock can still write, so every writer has to cooperate. Windows behaves differently, enforcing sharing rules on an open handle, which brings its own failure modes when a reader holds the file.

The simpler rule is one writer. If a UI and a daemon both need to change settings, let the daemon own the file and have the UI call it. A lock is what you add when several writers are unavoidable, not a substitute for deciding who owns the file.

Keep history you can read and revert

Every incident review eventually asks what changed. Without history the answer comes from memory, and memory is wrong. With history the answer is a diff, and the fix is a revert.

The diff has to be structural, not textual. A line diff of a JSON file reports that a closing brace moved and three lines changed when one value did. A structural diff reports that queue.retries went from 3 to 0 and queue.backoff was removed. The second one is readable at two in the morning and can be reverted key by key. Store the parsed document for each version, not the rendered text, and keep enough context to answer who changed it, when, and from which tool.

If the file lives in a Git repository, you already have most of this. Each save is a commit, each commit has an author and a message, and reverting is a Git operation. The same reasoning is why we keep site content as JSON files in Git rather than in a database, as we described in the article about a CMS without a database. Config that lives outside a repository, in an application data folder or a mounted volume, needs the same history built into whatever edits it, or it will not have any.

Document the key, not just its name

An undocumented key is a key nobody will set correctly. The name suggests one thing, the code does another, and the only reliable source is whoever wrote it. In practice people copy a value from a blog post or from another project and never learn what it does.

For each key, a useful reference answers five questions.

  • What it does, in one sentence a non-author can understand.
  • Its type and accepted values, including what happens at the boundaries.
  • Its default, and the value currently in effect.
  • Where the effective value came from: the file, an environment variable, a command-line flag or the default.
  • What changing it affects, and whether it needs a restart.

The fourth item is the one most references leave out. The difference between "the file says 3" and "the process is using 5 because an environment variable overrides it" is often the entire bug. The twelve-factor essay defines config as everything likely to vary between deploys, and argues for environment variables because they are unlikely to be committed and do not depend on a language. The advice is sound, but it sharpens the precedence question. Once a value can come from a file, an environment, a flag and a default, a person reading any one of them cannot tell what the process will use.

Drift is the gap between what you think and what runs

Config drift is what happens when staging and production start from the same file and diverge through small manual edits. One environment has a key the other never got. A default changed in a new release. Someone set an override in a dashboard and forgot it. None of this is visible in the repository, because the repository holds the file, not the running state. Kubernetes makes the same point when it describes a ConfigMap as decoupling environment-specific configuration from the container image, so the same image runs in both places.

"It works on staging" is usually the sentence that precedes finding one unset key. Staging has the SMTP host in its environment, production does not, and the code falls back to a default of localhost. The application starts, the health check passes, and the first password reset fails. Nothing in the diff shows it, because the difference is not in the diff.

The fix is to compare effective configuration, not files. Resolve the values in each environment, after defaults and overrides, then compare key by key. Run the comparison as part of a release rather than as an investigation. The output should name the key, its value in each environment, and where each value came from. A missing key and a key explicitly set to its default are different facts, and both matter.

Two rules keep drift small. Keep one source of truth per key, so a value is not set in a file and overridden in three other places. And treat a config change like a release: validated, reviewed, recorded and reversible.

A checklist for config you can trust

  • One schema, used by the application and by every editor.
  • Validation on write, with unknown keys rejected or reported.
  • Atomic replacement through a temporary file and a rename, preserving mode and owner.
  • One owner for the file, or a lock or version check when there are several writers.
  • Version history with structural diffs and a one-step revert.
  • Documentation for every key, including its default and its effective value.
  • A comparison of effective values between environments before each release.

Where a tool helps

None of this is hard, but all of it is easy to skip when the config file belongs to a side project. We built GSD Config Manager around this exact set of problems for GSD's own config.json, a file with more than two hundred nested keys. It explains each key in plain language, shows the effective value next to the default, validates against the schema before saving, writes atomically with a file lock, and keeps snapshots with structural diffs and one-click restore. It runs with one command, has no server component and is MIT licensed.

Danube Labs is a founder-led software and AI studio in Bucharest. The same discipline applies to internal tools, admin panels and deployment scripts; see custom software, browse the portfolio, or book a free intro call.

Frequently asked questions

What does it mean to treat a config file as an API?
It means every key is a field with a type, allowed values, a default and an effect someone depends on, and the people who edit the file are its callers. That brings the same requirements as an endpoint: validation against a contract, safe writes, concurrency control, history and documentation.
Why should a config file be validated before it is saved?
Validation at startup only runs after the change is saved and the service restarts, so the error reaches whoever restarts it, often later and often someone else. Checking the document against the schema at write time refuses a broken file while the person who made the change is still there and can fix it.
Why is writing a config file not atomic by default?
A normal save truncates the file and then writes the new bytes, so a crash, a full disk or a power cut between those steps leaves a partial file. Writing the whole document to a temporary file and renaming it over the original avoids that, because the rename replaces the destination in one step.
How do two processes avoid overwriting each other's config changes?
The simplest answer is to allow one writer and have every other tool ask it to make the change. When that is impossible, use a lock file that every writer respects, or a version check such as an ETag with If-Match over HTTP, which rejects a write based on a stale copy with 412 Precondition Failed.
What is config drift and how do you detect it?
Config drift is the gap between the configuration you believe an environment runs and the one it actually runs, built up from manual edits, overrides and changed defaults. Detect it by resolving the effective value of every key in each environment, including defaults and overrides, and comparing those values rather than the files.
What should the documentation for a config key contain?
What the key does, its type and accepted values, its default, its current effective value, where that value comes from and whether a change needs a restart. The source of the effective value matters most, because a setting overridden by an environment variable explains many failures that the file alone cannot.
Share

Related articles