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.
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