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.
Deploy on Tuesday, Release on Thursday Code is deployed behind a disabled flag on Tuesday and sits in production unexposed. On Thursday the flag is enabled for a small share of users and then widened. Disabling the flag reverses the release in seconds without any deploy. deployed, flag off Tuesday — no user impact flag on for 5% Thursday morning widened to 100% Thursday afternoon off again seconds, no deploy THE POINT: THE LAST BOX COSTS SECONDS, NOT A PIPELINE RUN Deploy risk and release risk stop being the same event, and each can be managed on its own schedule.

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.

Targeting Rules Are Evaluated Top Down An explicit user override is checked first, then a segment rule such as internal staff, then a percentage rollout keyed on a stable user identifier, and finally the default. The first matching rule wins, which is what lets staff test a flag that is off for everyone else. 1 · explicit user override used for debugging a specific report — beats everything below 2 · segment rule internal staff, beta cohort, a specific tenant 3 · percentage rollout hashed on a stable user id, so a user does not flip between values Hashing on a session id instead of a user id makes the rollout re-roll on every visit — a common and confusing bug.

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.

One Check at the Boundary, Not Twelve in the Middle A single flag check at the entry point selects between two implementations and is trivially removable later. Checks scattered through the call stack multiply the number of paths, cannot be reasoned about, and make the eventual cleanup a redesign rather than a deletion. one check at the boundary two implementations, one selector cleanup is deleting one branch checks scattered through the stack twelve conditionals, 2¹² notional paths cleanup is a redesign nobody schedules The right-hand shape is how flags become permanent: removing them is genuinely risky, so nobody does.

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.

← Back to Feature-Flag-Driven Release Management