~/definitionOfDone.dev/blog/deployment-config-is-code.md

7 min read

Stop Hand-Writing Deployment Configuration

Every infrastructure tool eventually asks you for a flat configuration file. Environment name, then a list of key-value pairs. Do that for three environments and a hundred paramet…

TL;DR

  • Flat deployment config files are fine until they develop structure. Then you get copy-paste as the only composition mechanism, unchecked key names, no notion of a default, and no way to tell a deliberate value from a forgotten one.
  • Generate the file from a small typed model instead. Composition becomes spread syntax, key names become compile errors, and each service owns its own config next to its own template.
  • Emit exactly the format the tool already reads. No wrapper, no new abstraction — so you can adopt it on one service and abandon it without telling anyone.
  • Commit the generated file only if you add a CI check that regenerating produces no diff. Without the check, ignore it instead.
  • Don't oversell it: types check shape, not values. A typo'd stack name still compiles.
  • The threshold is behavioural, not numerical — when you're copying a block of config and changing three lines.

Every infrastructure tool eventually asks you for a flat configuration file. Environment name, then a list of key-value pairs. Do that for three environments and a hundred parameters and you get five hundred lines of near-identical text, where the only thing distinguishing one block from the next is which values changed.

Nobody chooses this. It's the shape the tool asks for.

And for a long while it's fine. The problems only start when the file develops structure — and by the time you notice, the file is the thing everyone's afraid to touch.

The failure modes, precisely

"It's too long" isn't a problem. Long files are fine. These are the actual problems:

Copy-paste is the only composition mechanism. Adding a parameter means adding it three times. Nothing enforces that you did. A parameter present in dev and staging and missing from production is a completely valid file, and you'll find out when the production deploy fails — or worse, when it succeeds because the parameter had a default and the default was wrong.

Nothing checks key names. A typo is a valid key. It deploys. The stack comes up with a parameter at its default value and no error anywhere.

There's no notion of a default. Every environment repeats the artefact bucket, the region, the capabilities flags. Change one, change it three times.

You can't tell deliberate from inherited. Looking at a value in the staging block, is it different from production because someone decided that, or because someone forgot to update it? The file cannot express the difference, so the answer lives in someone's memory or in a commit from two years ago.

It drifts from the templates it configures. A template grows a required parameter; the config doesn't. Nothing connects them.

That last one is the expensive one, because it fails at deploy time, which for CloudFormation means it fails during a rollback.

The move: stop treating it as data

Configuration files are usually treated as data, and most of the time that's right. Data is inspectable, diffable, and needs no toolchain.

But once you're copying blocks and changing three lines, you've written a program in a data format — and data formats are bad at exactly the two things programs are good at: composition and checking.

So generate the file. Keep a small typed model as the source of truth, and compile it into whatever flat format the tool wants:

tssnippet
interface DeployParams {
  stack_name?: string;
  s3_bucket?: string;
  region?: string;
  capabilities?: string;
  parameter_overrides?: string[];
}

interface EnvironmentConfig {
  name: 'dev' | 'staging' | 'prod';   // closed set — 'prd' won't compile
  deploy: DeployParams;
}

const shared: DeployParams = {
  s3_bucket: ARTEFACT_BUCKET,
  region: 'eu-west-1',
  capabilities: 'CAPABILITY_IAM CAPABILITY_AUTO_EXPAND'
};

const dev: EnvironmentConfig = {
  name: 'dev',
  deploy: {
    ...shared,
    stack_name: 'dev-orders',
    parameter_overrides: [...baseOverrides, 'Environment=dev', 'LogLevel=debug']
  }
};

compile('./services/orders', { version: 0.1, configurations: [dev, staging, prod] });

Four things changed, and only two of them are about types.

Composition became a language feature. ...shared is spread syntax. Shared settings are inherited; overrides are local and visible on the line where they happen. The question "is this deliberate or inherited?" is answered by looking at it.

Key names became checkable. stack_nmae is now a compile error rather than a key that silently does nothing.

Ownership moved. Each service owns its own configuration module next to its own template. The five-hundred-line shared file doesn't get split — it stops having a reason to exist.

The environment name became a closed set, which kills a small but genuinely annoying class of mistake.

The restraint is what makes it work

The important design decision here is what this doesn't do.

It doesn't wrap the deployment CLI. It doesn't introduce a new abstraction over your infrastructure tool. It doesn't require anyone downstream to learn anything. It emits exactly the file the tool already reads, and the tool never knows the difference.

That means you can introduce it to one service tomorrow, leave every other service on hand-written files, and nothing else in the estate notices. And if it turns out to be a bad idea, you delete the generator and keep the generated file.

The best infrastructure abstractions are the ones you can stop using without telling anyone. Every approach that replaces the deployment tool fails this test — the migration is all-or-nothing and the rollback is a rewrite.

Where the generated file should live

This is the genuinely contested decision and it deserves more than a footnote, because both answers are defensible and one of them will bite you.

Commit the generated file, and check it in CI. You get a reviewable diff — a pull request shows exactly how the deployed parameters change, which is real value during review. The risk: sooner or later someone hand-edits it. They'll have a good reason. The deploy failed, the error message named the TOML file, they fixed the value in the TOML file, it worked. The next generation silently reverts it.

If you commit it, you must add the CI check: regenerate, and fail if the result differs from what's committed. Without that check, committing is worse than not committing.

Ignore it and generate on every deploy. No drift, no hand-editing, one source of truth. You lose the reviewable diff, and you have to trust a build step you can't see the output of.

I'd commit it with the check, because seeing parameter changes in review has caught real mistakes for me and the check costs four lines of CI. But the failure mode of committing without the check is quiet and confusing, so if you're not going to add the check, ignore the file instead.

Either way, put a header comment in the generated file saying it's generated and naming the source. It's the cheapest thing on this list and it's what actually stops the hand-edit.

Don't oversell the types

Typed configuration is the kind of idea that gets carried too far, so let me be clear about what it does and doesn't buy.

The types check shape, not values. stack_name: 'dev-orders' type-checks. So does stack_name: 'dev-ordrs'. So does a region that doesn't exist, a bucket in the wrong account, and a subnet ID from a VPC you decommissioned. The interesting deployment failures are value failures, and a string is a string.

You can push further — branded types for ARNs, a union of valid regions, validation inside the compile step. Occasionally worth it. Mostly the point of diminishing returns arrives quickly, and a configuration DSL nobody wants to touch is worse than the TOML file you started with.

It adds a build step to something that was previously just a file. Someone who wants to know what production gets now has to run a program or read one. Against a flat file, that's a real loss of directness, and it's felt most by the people least familiar with the codebase.

Which is why the threshold matters. At eight parameters and one stack, this is overengineering and I'd push back on anyone proposing it. At a hundred parameters across a dozen services and three environments, the flat file is already costing more than the generator would.

Two things worth doing regardless

Whether or not you generate the file, these are cheap and pay for themselves:

Validate config against templates in CI. Parse both. Check that every template parameter without a default has a value supplied, and that no supplied parameter is unused. This catches the drift failure — the one that otherwise surfaces mid-rollback — and it's a short script.

Keep secrets out entirely. Deployment configuration is for non-secret values. Secrets belong in Secrets Manager or Parameter Store, referenced by name. A parameter file in source control that contains a credential is a problem that gets worse every time someone clones the repository, and generating the file doesn't fix it — it just makes the credential harder to spot.

The tell

You've crossed the threshold when you find yourself copying a block of configuration and changing three lines.

That's it. That's the signal. At that moment your configuration wants to be a function call, and every day you leave it as text is a day the copies drift further apart.

newer Build the Bridgehead Before You Migrate Anything older The Deploy-Time Bug You Can Catch at Pull-Request Time