August 2026 · 14 min read · .NET · Azure · DevOps

I Ship to Production Every Day.
Feature Flags Are Why That's Safe.

How I built a code-first feature flag system for trunk-based development — with per-environment state, client-safe filtering, expiry enforcement, and live toggling without a single redeployment.

DevOps .NET Azure App Config Trunk-Based Dev
In this article
01Why trunk-based development breaks without flags 02The code-first catalog — one source of truth 03Per-environment state without JSON hell 04Client-safe filtering — don't leak server flags to the browser 05Live toggling — 30 seconds, no redeploy 06Zombie flags: the silent killer 07What I learned

My team ships to production every weekday. Not every sprint — every day. We merge to main, CI runs, and if it's green, it goes out.

This sounds risky. It isn't — because every in-progress feature sits behind a flag. You can merge half-built work, merge it early, merge it often. As long as the flag is off, users never see it. When it's ready, flip the flag. No big-bang release. No long-lived branches that rot.

But here's what nobody tells you: feature flags only work if the system managing them is solid. A poorly designed flag system creates more problems than it solves — scattered JSON files, flags nobody can find, dev flags leaking to production, flags that live forever because nobody dares touch them.

This is what I built to solve all of that.

Why trunk-based development breaks without flags

Trunk-based development (TBD) means everyone works on one branch — main. No long-lived feature branches. You commit small, you commit often, and you merge before the day is out.

The problem is obvious: what do you do when a feature takes two weeks to build? You can't merge broken half-built code to main.

The answer is feature flags. Wrap the unfinished feature in a flag check. Merge freely. The flag keeps it hidden until it's ready. Simple in theory — chaotic in practice without a proper system.

The core problem

Without a structured system, flags scatter across config files, environment variables, and hardcoded values. Nobody knows what's on, what's off, what's safe to delete, or what breaks if you touch it.

The code-first catalog — one source of truth

The first thing I built was a flag catalog. Every flag in the system must be declared in code — not in a config file, not in a portal, not in an environment variable. In code, with an attribute.

C# — Flag declaration
public static class TaskFlags
{
    // A release flag — ships a finished feature
    [FlagMeta(
        team: "backend",
        type: FlagType.Release,
        environments: AppEnv.Dev | AppEnv.Staging,
        exposedToClient: false,
        expiresOn: "2026-09-01"
    )]
    public const string BulkTaskImport = "task.bulk-import";

    // An experiment flag — A/B test
    [FlagMeta(
        team: "product",
        type: FlagType.Experiment,
        environments: AppEnv.Staging | AppEnv.Production,
        exposedToClient: true,
        expiresOn: "2026-10-15"
    )]
    public const string NewDashboardLayout = "task.new-dashboard";

Every flag has an owning team, a type, which environments it's active in by default, whether the browser is allowed to know about it, and an expiry date. That's it. Declaring the flag IS the source of truth.

A CLI tool scans these declarations at build time and generates the configuration JSON for each environment. Nobody hand-edits JSON. Nobody forgets a property. The code is always right because the code drives everything else.

A feature team never touches a platform file to ship a flag. They declare it, gate their code, and the system does the rest.

Using a flag in server code

C# — Gating server-side logic
public class TaskService(IFeatureFlagGate flags)
{
    public async Task<ImportResult> ImportBulkAsync(ImportRequest req)
    {
        if (!await flags.IsEnabledAsync(TaskFlags.BulkTaskImport))
            throw new FeatureNotAvailableException();

        // actual import logic
        return await _importer.RunAsync(req);
    }
}

Per-environment state without JSON hell

The classic mess: appsettings.Development.json, appsettings.Staging.json, appsettings.Production.json — each one a slightly different copy, all drifting out of sync over time. Someone enables a flag in dev, forgets to update staging, it ships to production in the wrong state.

My solution: the flag declaration itself carries its default state per environment via the environments field. The CLI emitter reads the catalog and generates correct JSON for each environment. The only way a flag is enabled in production by default is if the developer explicitly says Env.Production in the attribute.

Shell — Generate per-env config
dotnet run --project TaskManager.Flags.Cli -- emit --target production
# Outputs: feature_management JSON for Azure App Configuration
# No manual editing. Catalog is always right.

This JSON is then imported to Azure App Configuration in the CD pipeline. Live state lives there — not in the repo. The repo just defines the defaults. Operations can flip a flag in the portal without redeploying. The repo catches up on the next deploy.

How a flag travels from code to production
Declare
Developer adds [FeatureFlag] attribute in code. Owner, env defaults, expiry all set here.
Generate
CLI tool scans catalog, emits per-env JSON. Runs in CI on every merge to main.
Upload
CD pipeline imports JSON to Azure App Configuration with label=app. Overwrites previous state.
Toggle
Operations flips a flag in the App Config portal — no redeploy needed. App picks it up within 30 seconds.

Client-safe filtering — don't leak server flags to the browser

This one catches people off guard. If you expose your full flag list to the frontend via an API, you're leaking your entire feature roadmap to anyone who opens DevTools. Upcoming features, experiments, kill switches — all visible.

The fix is exposedToClient: true/false on each flag. A flag marked exposedToClient: false never appears in the API response the browser polls. It simply doesn't exist as far as the client is concerned.

C# — API endpoint (server-side)
[HttpGet("/api/flags/browser")]
public async Task<IActionResult> GetBrowserFlags()
{
    // Only client-safe flags evaluated and returned
    var browserFlags = _catalog.GetBrowserVisibleIds();
    var result = new Dictionary<string, bool>();

    foreach (var id in browserFlags)
        result[id] = await _flags.CheckAsync(id);

    return Ok(result);
}
// Non-browser flags never evaluated here — invisible to the client

The Angular frontend polls this endpoint every 30 seconds. It only ever knows about flags that were explicitly marked safe. Route guards and UI elements check against this list. Everything else is invisible by design.

CWE-200 — Information Exposure

Leaking internal flag names to the browser is a real information disclosure risk. Flag names often reveal feature names, codenames, or architecture details. Client-safe filtering is not optional.

Live toggling — 30 seconds, no redeploy

The whole point of feature flags is the ability to turn things on and off fast. If toggling a flag requires a deployment, you've added risk — the thing you were trying to avoid in the first place.

Azure App Configuration supports refresh intervals. The backend polls for changes every 30 seconds. The frontend polls the API every 30 seconds. End-to-end, a flag flip propagates in under 60 seconds with zero downtime.

C# — App Configuration setup
builder.Configuration
    .AddAzureAppConfiguration(opts =>
    {
        opts.Connect(endpoint, credential)
            .UseFeatureFlags(ff =>
            {
                ff.SetRefreshInterval(TimeSpan.FromSeconds(30));
                ff.Label = "app";
            });
    });

There's also an in-memory fallback for local development. If no App Configuration endpoint is configured, the app regenerates flag state directly from the catalog — dev environment flags on, everything else off. Developers get a working setup without any cloud dependency.

Zombie flags: the silent killer

This is the problem nobody talks about until it bites them. A flag ships a feature. The feature is stable. The flag should be deleted — but nobody does it because "it's working, don't touch it." Six months later you have 80 flags, nobody knows which ones are safe to remove, and the codebase is littered with dead branches.

Every flag in my system has a mandatory expiry date. Before that date, an automated bot files a work item: "This flag is expiring — remove it or extend the date." Nobody forgets. Ignoring it means your sprint board is noisy until you deal with it.

Flag kind Typical lifetime What happens at expiry
Release Until feature is stable (weeks) Remove flag, make behaviour permanent
Experiment Until A/B test concludes Keep winner, delete loser and flag
KillSwitch Indefinite Review annually, extend or remove
Permission Indefinite Migrate to a proper permissions system

The expiry date isn't enforced at runtime — the flag doesn't auto-disable. It's a governance mechanism. The bot creates noise until you deal with it. That friction is intentional.

What I learned

The flag system is infrastructure. Treat it that way from the start. A couple of booleans in appsettings.json works for one flag. It collapses under ten.

Code is the source of truth — not the portal. Portals are for emergency toggles. If the portal is how flags normally get configured, you'll have drift, undocumented state, and flags nobody owns.

Client-safe filtering is not optional. Your feature roadmap is not public information. A 30-line API filter keeps it that way.

Expiry dates feel bureaucratic until month six. Then they feel like the most important thing you shipped.

Trunk-based development without flags is chaos. With flags, it's calm. The system isn't about moving fast — it's about moving safely. Every daily deploy is boring. That's the goal.

M
Manjit Sithuraj
Software Engineer · .NET · AI Systems
Read on Medium LinkedIn ← More articles