Build Stack Review

Trunk-Based Development With Feature Flag Gating

Feature flags let teams ship incomplete code safely by decoupling deployment from release.

Editor at Large · · 12 min read
Cover illustration for “Trunk-Based Development With Feature Flag Gating”
CI and Production Environment Consistency · September 17, 2026 · 12 min read · 2,712 words

Trunk-based development asks every engineer to commit small changes to a single shared branch, usually main, at least once a day. That only works if unfinished code can sit on that branch without breaking things for users, and feature flags are the mechanism that makes it possible: they let a team ship incomplete work to production constantly while keeping it invisible until someone decides otherwise.

Gitflow and its relatives solved integration risk by giving every feature its own long-lived branch. That branch drifts. Two weeks in, it's built on an old version of main, and the eventual merge back is a slog of conflicts nobody can review properly or test with any confidence. Teams respond by scheduling integration phases and code freezes, which slows delivery down further, which is the opposite of what the branching strategy was supposed to buy them. Trunk-based development skips the problem instead of managing it: branches, when they exist at all, live for hours or a single day, so there's never enough drift to make merging dangerous.

The tradeoff is that merging daily means unfinished features land on the trunk, and with continuous delivery, they land in production. That's the part that makes a lot of engineering leads nervous, and reasonably so: a codebase with no way to hide half-built work is a codebase where every commit is a small bet on production stability. Research cited in Unleash's documentation on trunk-based development ties this to DORA's findings, that teams keeping three or fewer active branches and merging to trunk daily tend to ship faster and more reliably than teams running heavier branching models. Unleash's docs describe two legitimate ways to run trunk-based development: the smallest teams commit straight to trunk, relying on pair programming or synchronous review instead of pull requests to keep quality up, which maximizes how often work actually integrates. Most teams use short-lived branches instead, existing only long enough for code review and CI checks, merged within a day. The branch is there to let the checks run, not to let work accumulate. Either way, "done" for a merge means the same thing: trunk builds, tests pass, and the result could go out the door today.

What trunk-based development gives up, deliberately, is the option to hide unfinished work on a branch while it's not ready. That job has to live somewhere else. It lives in the flag.

What feature flags do in this context, and what they should not be asked to do

A feature flag is a runtime switch: a piece of config that controls whether a given code path is visible or active, without anyone deploying anything new. The code sits in production, inert, until someone flips the switch.

That separation, between deploying code and releasing a feature, is the whole point. Deployment turns into a routine, low-stakes event, because shipping unreleased code changes nothing for the user. Release becomes a deliberate act instead, one that can happen per environment, per user segment, and can be reversed in seconds. Martin Fowler's widely cited reference on feature toggles frames release toggles specifically as the mechanism that lets incomplete, even untested, code paths ship to production as latent code, present but not live.

Not all flags do the same job, and mixing them up is where teams get into trouble. Release flags decouple deployment from release, the kind used for phased rollouts and canary releases, generally owned by software and QA engineers, and meant to be short-lived by design. Experimentation flags run A/B tests and feature comparisons, owned more by product managers and data analysts than engineers. Operational flags control system behavior directly, things like logging verbosity or maintenance mode, and belong to DevOps and system administrators. Permission flags gate access by user segment, the mechanism behind early access programs, usually owned jointly by product and engineering.

Not everything needs a flag, and treating every change as flag-worthy is how flag bloat starts before a single feature ships. A small refactor or a bug fix that's complete within one merge goes straight to trunk. No flag, no ceremony. Reserving flags for changes that actually need runtime control is what keeps cleanup manageable months down the line.

Flags aren't the only technique available here, either; they extend two older ideas rather than replacing them. The keystone interface pattern, another Fowler reference, has teams build the backend of a feature first and add the user-facing UI last, so that most of the feature stays unreachable by users until the final piece lands. Branch by abstraction swaps implementations behind a stable interface. What flags add on top of both is runtime control: the ability to change behavior without touching the deployment pipeline at all. GitHub's own writeup on feature flags, referenced in Unleash's docs, makes the operational payoff concrete: disabling a broken change takes seconds through a flag, while a rollback deployment takes minutes. That gap, seconds versus minutes, is the reason the rest of this workflow exists.

How the workflow runs in practice, from the first commit to the full rollout

The flag gets created before any code for the feature gets written. It's registered in a tool like Unleash, ConfigCat, Flagsmith, or Harness Feature Flags, and it starts life turned off everywhere. At creation, the flag gets metadata attached: an owner, a linked ticket, and an expected expiry. That metadata is what makes the flag removable later instead of a mystery someone has to reverse-engineer. The base toggle configuration usually lives in a structured file checked into source control like anything else the team maintains.

From there, the feature gets built as a sequence of small merges, flag-guarded, landing on trunk daily rather than accumulating on a branch somewhere. Each slice integrates with everyone else's work immediately; the flag keeps the growing feature invisible in production the whole time. This is where the keystone interface pattern earns its keep: backend logic can merge across dozens of small commits over weeks, and the UI lands last, as the final piece that makes any of it reachable. Every one of those merges has to leave trunk in a buildable, tested, releasable state. The flag-off path is the production path, always, no exceptions.

Once the feature is complete behind the flag, validation happens in stages. It gets turned on for developers first, then QA, then an internal cohort, so the team can watch it behave in the real production environment before any actual user sees it. From there, canary releases turn it on for a small slice of real traffic, with monitoring in place before expanding further. The same mechanism supports A/B testing along the way: different segments can see different code paths simultaneously, with no separate infrastructure needed.

Full rollout happens by changing the flag's state, not by deploying anything. Once the rollout is stable, the flag comes out of the codebase entirely, so the conditional logic gets deleted, not just left permanently switched on. Unleash's own documented workflow lays this out as a sequence: create the release flag, merge incremental commits with it off, enable for internal users, roll out progressively, then retire the flag. And if something breaks after release, the fix is immediate: flip the flag off. No emergency branch, no hotfix deployment, no scrambling to revert a chain of commits under pressure.

What CI pipelines must do differently when feature flags are in play

A CI pipeline that only tests the default flag configuration is testing one version of what might ship, and ignoring the rest. It's testing one version of what might ship, and ignoring the rest. Guidance from trunkbaseddevelopment.com is direct that tests running after launch need to adapt to whatever's meaningful for the flag permutations in play, not just the happy path a team assumed would be the only one.

In practice, this means fanning out after the unit test stage, running the pipeline against each meaningful flag combination rather than just one. A blunt but workable version of this runs the entire CI suite in parallel for each permutation that matters, so a single commit to trunk can kick off multiple builds at once. This only holds up with elastic infrastructure behind it. Fixed-capacity CI runners can quickly become the bottleneck as the number of parallel builds per commit multiplies.

The reason this matters isn't abstract. A flag-off path that passes every check, paired with a flag-on path nobody has actually tested, is a latent failure sitting quietly in production, waiting for someone to flip the switch that reveals it. It's a latent failure sitting quietly in production, waiting for someone to flip the switch that reveals it. Teams don't test every theoretical combination, of course; that's usually neither practical nor necessary. They identify the ones that matter (flag-off as the current production baseline, flag-on as the next release candidate) and gate on those specifically.

None of this works if the environment underneath is inconsistent. A CI runner that resolves different dependency versions than a developer's laptop will produce results nobody can trust or reproduce. A SETUP.md file that quietly resolves to different Node.js minor versions or different OpenSSL patch levels across machines means the flag-permutation test results aren't actually comparable run to run, even if the test names match. Pinned, declarative environments, the same lockfile enforced locally and in CI, are what turn multi-permutation testing into a signal instead of noise.

Runtime-switchable flags add one more wrinkle: their state has to survive restarts and propagate consistently across every horizontally scaled node running the service. Holding that state in distributed config systems like Consul or etcd is described in trunkbaseddevelopment.com's guidance as the modern approach, as opposed to baking flag state into a config file that ships with each deploy.

Flag hygiene: the accumulated complexity that quietly undermines the whole system

Old flags pile up because nothing forces them out. Teams pivot to the next business priority, the flagged feature works fine sitting there fully rolled out, and removing the conditional logic never becomes urgent enough to schedule. Trunkbaseddevelopment.com puts a specific number on the fix worth aiming for: try to get the business to allow remediation of a flag, and the code it wraps, about a month after release. Framing it that way matters, because cleanup is a discipline engineers need to schedule deliberately, not something they get to when they have a free afternoon. It's a negotiation with whoever owns the roadmap, and it needs to be treated as one.

Every flag left in the codebase past its useful life adds a branch that anyone reading that code has to hold in their head. Two or three flags interacting in the same code path multiply that cognitive load rather than adding to it, since now the reader has to reason about combinations, not just individual switches.

What separates a flag that's easy to remove from one that turns into archaeology is almost entirely the metadata captured at creation. An owner, a linked ticket, an expiry date: those three things mean removal is a scheduled task, not a forensic investigation into who added this and why. Unleash's platform tracks flags against their expected lifetime for exactly this reason, externalizing the expiry contract so it doesn't live only in one engineer's memory, an engineer who may have left the team by the time the flag needs pulling. Trunkbaseddevelopment.com suggests a simpler, low-tech version of the same idea: list flags in the project's readme with a "review for delete" date attached.

Release flags, in particular, are supposed to be short-lived. Leaving one in place indefinitely defeats the reason it was created and quietly turns it into an operational flag nobody assigned ownership of, still gating a code path months or years after the rollout finished. Trunkbaseddevelopment.com's own phrasing for what happens when this goes unmanaged echoes an old warning from C programming: "#ifdef considered harmful," a nod to what conditional compilation does to a codebase once it's allowed to proliferate without anyone reining it in. Unit tests covering the flag-on path offer a partial safety net during the cleanup lag, at least, since a path that's technically off in production but still exercised in CI won't go completely dark while it waits its turn for removal.

What teams need to manage carefully when choosing and operating a flag system

A handful of platforms have built dedicated tooling and documentation around this exact workflow. Unleash publishes an explicit trunk-based development guide covering the full arc, from flag creation through progressive rollout to retirement, and it tracks flags against their expected lifetime as a core feature rather than an add-on. ConfigCat manages flags with CI/CD integration built in. Flagsmith offers an open-source option, though it doesn't have dedicated trunk-based development documentation the way Unleash does. Harness Feature Management and Experimentation publishes its own dedicated documentation on the workflow through developer.harness.io.

Regardless of which platform a team picks, a few operational details shape how auditable and maintainable the rollout ends up being, independent of the vendor choice itself. Flag configuration needs to live in source control, so every change is auditable and reviewable the same way a code change would be. Metadata fields, owner, ticket, expiry, need to be enforced at creation time, not left as optional fields someone might fill in eventually. The platform has to actually participate in CI's fan-out across flag permutations rather than sitting off to the side as a separate dashboard. And for flags that switch at runtime, state persistence and propagation across a cluster have to be solved at the infrastructure layer, not assumed away.

None of this holds up, though, if the environment underneath is inconsistent. Flag behavior is only trustworthy when the environment running the code, developer machine, CI runner, production server, is the same environment each time, not three approximations of it. A study of 5,298 Docker builds by Malka et al. found that only 6.4% of rebuilt images matched the original set of installed package versions exactly. That's the baseline most teams quietly assume is stable, and it isn't. Reproducible, declarative environments, meaning toolchains and dependencies pinned through lockfiles and enforced identically across local development and CI, are what make flag-gated test results something a team can actually act on rather than second-guess. Nix-based tooling, including Nix Flakes and higher-level wrappers like Devbox, can pin an entire environment down to system libraries, so the same flag permutation produces the same result whether it's run on a laptop or a CI runner three time zones away.

There's a supply chain angle here too, one that's easy to overlook. Flag configuration is itself a runtime dependency the same way a package version is. The source of truth for flag state needs to be version-controlled and auditable, not managed solely through a UI where changes leave no trail. A change to flag state that determines which code path runs in production is, functionally, a release. It deserves the same traceability a code deploy gets, not less.

The discipline that makes trunk-based development with feature flags work long-term

Neither practice covers for the other's weaknesses on its own. Trunk-based development without flags means incomplete code either gets exposed to users or blocks the trunk from being releasable at all, which defeats the purpose of merging daily in the first place. Flags without trunk-based development still deliver the deploy/release split, but long-lived branches keep generating the same merge pain they always did; a flag doesn't fix integration drift, it just gives a team something to hide behind once the drift becomes visible in the merge itself.

Run together, with both maintained deliberately, every commit integrates continuously, incomplete work stays invisible to users, and release turns into a controlled decision made at runtime rather than a deployment event with rollback risk attached.

Staying "continuously releasable" is ongoing upkeep, though. It's upkeep. The flag-off path has to remain the production-safe baseline at all times, tested in CI, never quietly broken by whatever's being built behind the flag-on path. The flag-on path needs testing against that same environment, not treated as a lower-priority afterthought because it isn't live yet. And every flag needs an owner and a linked work item from the moment it's created, because a flag without either of those is already, on day one, the piece of hidden complexity some future engineer will have to go excavate.

Sources

  1. Trunk Based Development
  2. Trunk-based development with feature flags | Unleash Documentation
  3. Use Feature Flags for Trunk-Based Development | Feature Flags | Harness
  4. Feature Toggles (aka Feature Flags)

More in CI and Production Environment Consistency