If your team schedules a meeting to decide when code goes to production, the meeting is a symptom, not a process. The fix is to separate two things most teams accidentally weld together: deploying code (shipping the binary) and releasing a feature (turning it on for users). Trunk-based development makes deploys boring and constant; feature flags move the release decision out of the deploy pipeline and into a runtime toggle. Do both and the coordination call disappears, because there is nothing left to coordinate.
I moved a team from a weekly "release train" to this model over a couple of quarters. What follows is the mechanics, the failure modes that actually bit us, and how to tell whether you're ready.
Why does the deploy meeting exist in the first place?
The meeting exists because deploys are risky and infrequent, so a batch of two weeks of changes go out at once. When something breaks, nobody knows which of the forty merged branches did it, so you need everyone in a room to bisect blame in real time. The meeting is a manual rollback-coordination protocol.
Long-lived feature branches make this worse. A branch that lives two weeks diverges from main, so the merge itself is a risky event — you're integrating a fortnight of other people's changes at the worst possible moment, right before release. The "merge day" pain and the "release day" pain are the same pain, and both come from batching.
The takeaway: infrequent deploys don't reduce risk, they concentrate it into a single scary event that requires human coordination.
What is trunk-based development, concretely?
Trunk-based development means everyone commits to one shared branch (main) at least once a day, and branches — if you use them at all — live for hours, not weeks. Every commit to main is a candidate for production. There is no develop, no release/1.4, no long-running integration branch.
The rule that makes this safe is that main is always releasable. That is not a slogan; it's enforced by the pipeline. A merge to main runs the full test suite, and if it's green, it deploys. Here's the shape of it in GitHub Actions:
name: deploy
on:
push:
branches: [main]
jobs:
ship:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npm test
- run: npm run deploy # only reached if tests pass
The catch every team hits: if main deploys on every merge, how do you land a half-finished feature without shipping a broken UI? You don't hold it on a branch. You merge it dark, behind a flag. That's the other half of the workflow, and it's the half people skip.
The takeaway: trunk-based development only works if incomplete code can live safely in production while switched off.
How do feature flags separate deploy from release?
A feature flag is a runtime conditional that decides whether a code path is active, evaluated per request instead of per deploy. The new checkout flow ships to production on Tuesday, disabled. On Friday, when QA and the product owner are happy, someone flips a toggle in a dashboard and it's live — no deploy, no pipeline, no meeting.
The simplest version is a boolean you can change without redeploying:
# Evaluated at request time, not build time.
if flags.is_enabled("new_checkout", user=current_user):
return render_new_checkout()
return render_legacy_checkout()
The important word is user. A flag isn't only on or off globally — it's the mechanism for a gradual rollout. Ship to internal staff first, then 1% of traffic, then 10%, watching your error rate at each step. If the graph turns red at 10%, you set the flag back to 1% instantly. That's a rollback measured in seconds, and it doesn't require redeploying the previous version.
If you want the managed version of this rather than a config file, LaunchDarkly is the tool that handles per-user targeting, percentage rollouts, and audit logs without you building the evaluation service yourself. If you'd rather keep it in your own stack, Unleash is the one that offers a self-hostable open-source server with the same targeting model. And if you're on a small team that just needs a boolean per environment, a row in Postgres read through a short-TTL cache is a completely legitimate starting point — don't reach for a platform before you feel the pain a platform solves.
The takeaway: a feature flag turns a release from a deployment event into a configuration change you can reverse in seconds.
Deploy strategies compared
The workflow choice interacts with your branching model. Here's how the common options actually differ in day-to-day cost:
| Approach | Integration frequency | Rollback speed | Meeting required? | Main risk |
|---|---|---|---|---|
| Release train (biweekly) | Every 2 weeks | Redeploy previous build (minutes–hours) | Yes | Batched changes, hard to bisect |
| GitFlow + release branches | Per release | Redeploy or hotfix branch | Usually | Merge conflicts, branch drift |
| Trunk-based, no flags | Daily | Redeploy (minutes) | Sometimes | Can't merge unfinished work |
| Trunk-based + feature flags | Continuous | Flag toggle (seconds) | No | Flag debt if not cleaned up |
The bottom row is the only one where releasing a feature and rolling it back are both sub-minute operations that any single engineer can perform alone. That is what "zero-meeting" actually means: no operation in the release path requires more than one person.
The takeaway: only trunk-based-plus-flags makes both release and rollback single-person, sub-minute operations.
What breaks when you actually do this?
Three things bit us, and none of them are in the tidy version of this story.
Flag debt. A flag is temporary scaffolding, but nothing forces you to remove it. Six months later you have two hundred flags, half permanently true, and every code path has a dead else branch nobody dares delete. The fix is process, not tooling: every flag gets a ticket to remove it at creation time, and you audit for flags older than 90 days. Treat a stale flag as a bug.
Testing the off state is not enough. If main always deploys, your test suite has to cover both sides of every flag — the new path and the old one — because both are in production simultaneously for different users. A test that only exercises the default state will happily let a broken enabled-path ship. In my experience this is the single biggest source of "but the tests passed" incidents on this workflow.
Incomplete work at the seams. Merging dark is easy for a self-contained feature and genuinely hard when it requires a database migration. You cannot flag a schema change the same way you flag a UI. The discipline that solves it is the expand-contract pattern: deploy the additive migration first (new nullable column), deploy code that writes to both old and new, backfill, then flip the flag to read from new, and only much later drop the old column. Each step is independently safe and reversible.
-- Step 1 (deploy A): additive only, nothing reads it yet.
ALTER TABLE orders ADD COLUMN total_cents BIGINT;
-- Step 2 (deploy B): app writes both columns, still reads old.
-- Step 3: backfill total_cents from the legacy float column.
-- Step 4 (flag flip): app reads total_cents.
-- Step 5 (weeks later): ALTER TABLE orders DROP COLUMN total_dollars;
The takeaway: the workflow's real cost is discipline around flag cleanup, dual-path testing, and schema changes — not the tooling.
When is this the wrong choice?
Trunk-based development assumes a strong automated test suite, because you've deleted the human gate that a release meeting provided. If your test coverage is thin and your deploys are currently safe only because a senior engineer eyeballs every release, going trunk-based without first building that suite just removes the one thing catching bugs. Fix the tests first.
It's also a poor fit where you genuinely can't deploy continuously: firmware, mobile apps gated behind app-store review, or regulated environments that require a signed release artifact per change. Feature flags still help there — you can ship dark and flip server-side — but the "deploy 20 times a day" half doesn't apply.
The takeaway: this workflow trades a human review gate for an automated one, so it's only as safe as the test suite underneath it.
FAQ
Do feature flags replace the need for staging environments?
No, but they change what staging is for. Staging still validates that the build works and integrations connect, but the risky "does this feature behave under real traffic" question moves to a percentage rollout in production, which is more honest than any staging environment. Many teams keep a lightweight staging and rely on gradual production rollouts for the real signal.
How do you roll back a bad deploy in trunk-based development?
For a feature guarded by a flag, you toggle the flag off, which takes seconds and needs no pipeline run. For an unflagged change that broke, you redeploy the previous green commit from main, which is fast because deploys are small and frequent. The small batch size is what makes the rollback quick — you're reverting one change, not two weeks of them.
Won't committing to main every day cause constant merge conflicts?
It causes the opposite. Conflicts scale with how long branches diverge, so integrating small changes daily produces tiny, trivial conflicts instead of the giant merge you get from a two-week branch. Frequent integration is the conflict-avoidance strategy, not the cause.
Bottom line
If you have a solid automated test suite, trunk-based development plus feature flags removes the release meeting by making both "ship it" and "turn it on" single-person, reversible actions. Start with the flags — even a boolean in your database — because they let you merge unfinished work safely, which is the prerequisite for short-lived branches. If your tests are weak, build those first; this workflow removes the human gate, so the automated gate has to be real. And whatever flag system you adopt, put a removal ticket on every flag the day you create it, because the failure mode of this workflow is not a bad deploy — it's a slow drowning in flags nobody cleaned up.
Top comments (0)