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.

← Back to Feature-Flag-Driven Release Management