← ./articles

When Feature Flags Do Not Control Build Tool Config, Use Overlays

Feature flags often control application code. They do not automatically control every tool that participates in a build.

That difference matters when a compiler, bundler, packager, linker, or deploy tool reads its own configuration file. A feature can be disabled in code while the build tool still acts on configuration for that feature.

When that happens, split configuration into a base file and feature-specific overlays.

The symptom: the feature is off but the build tool still acts on it

The failure usually sounds contradictory:

  • the optional feature is disabled
  • the code path is behind a compile-time gate
  • the build still asks for a secret, signing key, endpoint, asset, or plugin setting
  • the error appears after compilation, during packaging or deployment

A common shape is:

feature OFF
compiled code path removed
build tool config still present
build tool performs feature-specific work

This is not a feature flag bug. It is a boundary bug.

Separate compile-time gates from tool-level config

Compile-time gates control source code.

Examples:

  • Rust #[cfg(feature = "updater")]
  • C or C++ preprocessor flags
  • TypeScript constants replaced by a bundler
  • package-level optional imports

Tool-level config controls the build tool.

Examples:

  • bundle targets
  • signing settings
  • updater artifact settings
  • deploy destinations
  • plugin configuration
  • linker scripts
  • generated asset options

The build tool does not necessarily know what your code feature flag means. If its config file says "sign this artifact" or "enable this plugin," it may try to do that work regardless of which code path compiled.

Use base config plus feature overlays

Keep the base config limited to settings shared by every variant:

{
  "name": "my-app",
  "build": {
    "outDir": "dist"
  },
  "bundle": {
    "targets": ["installer"]
  }
}

Put optional settings in a separate overlay:

{
  "bundle": {
    "createUpdateArtifacts": true
  },
  "release": {
    "signing": {
      "required": true
    }
  }
}

Then make build commands explicit:

# Base build
build-tool --config config.json

# Feature build
build-tool --config config.json --config config.updater.json

Some tools merge multiple config files directly. Others require you to generate a combined config first. The principle is the same: base settings are always loaded; feature settings are loaded only for that variant.

Know your merge semantics before relying on overlays

Do not assume every tool merges JSON the same way.

JSON Merge Patch, defined by RFC 7386, treats objects as mergeable, null as removal, and arrays as replaceable values rather than appendable lists.

That means this base:

{
  "bundle": {
    "targets": ["msi", "nsis"],
    "active": true
  }
}

and this overlay:

{
  "bundle": {
    "targets": ["nsis"]
  }
}

can produce:

{
  "bundle": {
    "targets": ["nsis"],
    "active": true
  }
}

The array was replaced, not appended.

Before using overlays in a release process, answer these questions:

  • Are objects merged recursively?
  • Are arrays replaced or appended?
  • Does null delete a key?
  • Does the later config override the earlier config?
  • Does the tool validate unknown keys?

Write overlays as small files that only contain keys they must change.

Verify variant-specific artifacts after the build

Build success is not enough. A missing overlay can produce a valid artifact for the wrong variant.

Use artifact checks:

# Base build should produce the app but not the optional artifact.
test -f dist/my-app.exe
test ! -f dist/my-app.exe.sig

# Updater build should produce both.
test -f dist/my-app.exe
test -f dist/my-app.exe.sig

Adapt the paths to your tool. The verification should express the variant contract, not just "the command exited zero."

Good checks are specific:

  • default build has no updater signature
  • updater build has an updater signature
  • free build has no paid-only assets
  • production build uses the production endpoint
  • staging build does not use production credentials

The point is to catch wrong-variant success.

Keep secrets out of base config

Config overlays are also a safety boundary.

Base config should not mention optional release secrets. It can reference public settings that every build needs, but it should not force local developers to provide production signing keys or deploy credentials.

Prefer this:

config.json
config.release.json

over this:

config.json with release signing enabled by default

The second form makes the release path easier to trigger accidentally and the development path harder to run cleanly.

When overlays are overkill

Do not split config for every small option.

An overlay is worth it when:

  • the build has named variants
  • variants produce different artifacts
  • variants require different secrets
  • the feature is optional for local development
  • a wrong variant can still exit successfully

If the setting is always required in every environment, keep it in the base config. Too many overlays can make the build harder to reason about.

References

Summary

Feature flags control code. Build tools control artifacts. When those systems do not share the same gate, use a base config plus feature overlays, understand the merge rules, and verify the artifacts that define each variant.