Decoupling Deploy from Release with Feature Flags
You want to merge and deploy risky changes continuously without exposing them to users until you choose to — the exact promise of feature-flag-driven release management, where the deploy and the release become two independent, reversible events.
When to use this pattern
- You deploy to production frequently and want to separate “code is live” from “feature is on.”
- You need a per-user, sub-second kill switch that does not require a redeploy.
- You want to ramp a change to a growing cohort while comparing its metrics to a control group.
Prerequisites
Complete working example
// src/flags.ts — one place to evaluate flags, always safe by default
import { OpenFeature, type EvaluationContext } from "@openfeature/server-sdk";
import { UnleashProvider } from "@openfeature/unleash-provider";
OpenFeature.setProvider(
new UnleashProvider({
url: process.env.FLAG_URL!,
appName: "web",
// Resilience: serve the last-known ruleset if the service is unreachable,
// and if there is nothing cached, evaluation falls back to the code default.
refreshInterval: 15_000,
disableMetrics: false,
})
);
const client = OpenFeature.getClient();
export function flagEnabled(key: string, ctx: EvaluationContext): Promise<boolean> {
// The `false` default is the stable path — shipping this NEVER releases a feature.
return client.getBooleanValue(key, false, ctx);
}// src/routes/checkout.ts — the new path is gated; deploy != release
import { flagEnabled } from "../flags";
export async function checkout(req: Request, user: User) {
const on = await flagEnabled("new-checkout-flow", {
targetingKey: user.id, // stable bucket key — a user stays in one cohort
plan: user.plan,
});
return on ? newCheckout(req, user) : legacyCheckout(req, user);
}# .github/workflows/deploy.yml — deploys the binary; does NOT touch the flag
name: Deploy
on:
push: { branches: [main] }
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Assert new flags ship OFF in production
run: |
# Guard: any newly-introduced flag must default to false in the committed config,
# so merging the code cannot accidentally release the feature.
node scripts/assert-flags-default-off.mjs
- name: Build and deploy
run: ./scripts/deploy.sh # your existing deploy — blue-green, canary, etc.
# No "enable flag" step. Release is a separate, deliberate flag change.Step-by-step walkthrough
src/flags.ts — the single evaluation point. Centralizing evaluation means every call shares the same provider config and the same safe default. The Unleash provider caches rules and refreshes every 15 seconds; if the service is down, it serves the last-known ruleset, and with nothing cached, getBooleanValue’s false default takes over. Evaluation never throws on the request path.
checkout.ts — the gated path. The new flow runs only when the flag resolves true for that user. targetingKey: user.id buckets each user stably, so a user in the ramped cohort stays in it across requests and sessions — the same affinity principle a canary needs.
The deploy workflow — no release step. The pipeline builds and deploys the binary using whatever deployment strategy you already run. Crucially, it contains no “turn the flag on” step. The assert-flags-default-off guard fails the build if a new flag is committed defaulting to on, so merging code can never accidentally release a feature.
Releasing, later and separately. With the code live everywhere but dark, you release by editing the flag rule in the flag service — internal users, then 1%, 10%, 50%, 100% — watching each cohort’s metrics. If anything regresses, you flip the flag off: a sub-second, redeploy-free rollback.
Verification
# 1. Confirm the deploy released nothing: with the flag off, all traffic is legacy.
curl -s https://app.example.com/api/checkout-variant -H 'x-user: u_123'
# → {"variant":"legacy"}
# 2. Enable for an internal user in the flag service, then re-check.
curl -s https://app.example.com/api/checkout-variant -H 'x-user: staff_1'
# → {"variant":"new"}
# 3. Kill switch: flip the flag off and confirm sub-second revert to legacy.Expected: step 1 shows the deploy is dark, step 2 shows targeted release working, step 3 shows instant rollback — all with the same deployed binary.
Common pitfalls
- A pipeline step that enables the flag. If deploy turns the flag on, you have re-coupled deploy and release and lost the whole benefit. Keep flag changes out of the deploy pipeline entirely.
- Unstable bucket key. Bucketing on a rotating session ID moves users between cohorts each request, scrambling metrics and user experience. Use a persistent
targetingKey. - No flag TTL. A “temporary” flag that never gets removed becomes permanent branching debt. Enforce a flag-age check as covered in the feature-flag release guide, and treat removal as the rollout’s definition of done.
What decoupling actually changes
The phrase “decouple deploy from release” is easy to agree with and easy to under-implement. Concretely, it means three properties hold.
Code can reach production without being exposed. A merged, deployed change that is off for everyone is the normal state, not an exception. This alone removes the pressure to hold long-lived branches, which is the source of most painful merges.
Exposure changes without a deploy. Turning a feature on is a configuration change taking seconds, not a pipeline run taking minutes. That is what makes the decision reversible enough to be made by someone other than an engineer.
Exposure is reversible faster than it was made. Turning a feature off must be at least as fast as turning it on, and must not depend on the pipeline being healthy. A rollout that can be widened instantly but only narrowed by a deploy is not decoupled; it is a one-way door with extra steps.
Where the check belongs
The most consequential implementation decision is where in the code the flag is evaluated. One check at a boundary — selecting between two implementations of a behaviour — keeps the number of code paths at two and makes the eventual removal a deletion. Checks scattered through the call stack multiply paths combinatorially, cannot be reasoned about, and turn removal into a redesign that nobody schedules.
The scattered shape is how flags become permanent. Once removing a flag is genuinely risky, it stays, and the codebase accumulates conditionals guarding behaviour that has been fully rolled out for a year.
Deciding what deserves a flag
Not every change needs one. A flag has a real cost: an extra code path, a configuration entry, a cleanup obligation, and a small amount of runtime evaluation. Changes that are trivially reversible by a deploy, or that carry no user-visible risk, are better shipped without one.
The changes that earn a flag are those where the exposure decision is separate from the code decision: a redesign that needs to go to a cohort first, a migration between two implementations where both must run for a while, a feature whose launch date is a business decision, and anything where being able to switch it off without a deploy is genuinely valuable at three in the morning.
Related
- Feature-Flag-Driven Release Management — the parent guide covering lifecycle, kill switches, and flag debt.
- Deployment Strategies & Progressive Delivery — how release control composes with blue-green and canary.
- Canary Releases & Progressive Rollouts — the per-request sibling a flag ramp runs inside.
- Synchronizing Environment Variables Across Stages — keeping flag config consistent across environments.
← Back to Feature-Flag-Driven Release Management