Branching & feature flags

Trunk-based dev; decouple deploy from release.

Intermediate30 min · lesson 5 of 15

A stage crew wheels an entire new set into place behind a closed curtain while the audience is still watching the current scene. The set arriving and the curtain going up are two separate events, run by different people at different moments, and if the new set looks wrong the curtain stays down. Software gets that same freedom once you pull the two apart. Deployment means the new code is sitting on production machines. Release means users can see it. Teams that treat them as one event can only ship when a feature is completely finished, so they ship rarely and they ship big. Teams that split them deploy constantly and pick the release moment on purpose.

Two habits make the split work. Trunk-based development keeps every developer merging into one shared mainline (the *trunk*, which is the main branch in Azure Repos). Feature flags, also called toggles, are named on/off switches your app reads while it is running to decide whether a chunk of code executes. On Azure the moving parts are Azure Repos for the trunk, Azure App Configuration for the flags, and App Service deployment slots (a second live copy of your app) for shipping code nobody can see yet.

Trunk-based development: stay close to main

Trunk-based development means branches that live a day or two and then merge into main, instead of feature branches that drift for weeks and come home as a huge, conflict-ridden merge. Small changes going in constantly keep conflicts trivial and keep the mainline current. The real payoff is that the codebase stays releasable at every moment. That property holds up this entire course, because you cannot automate deployments out of a branch that is broken half the time.

The obvious objection is work that takes three weeks. You still merge it in two-day slices. You merge it switched *off*. Half-finished code lands on main wrapped in a flag that defaults to disabled, so it compiles, passes CI (continuous integration, the automatic build and test run that fires on every change), and rides to production as inert weight until it is done. The branch policies from the previous lesson keep main protected, and --auto-complete merges each PR (pull request, the review-and-merge unit in Azure Repos) the moment those policies go green:

short-lived-branch.sh
# 1. Branch, commit small, push — branch lifetime measured in days
git switch -c feat/new-checkout
git commit -am "Checkout v2 skeleton behind 'new-checkout' flag"
git push -u origin feat/new-checkout
# 2. Open the PR; let branch policies drive the merge
az repos pr create \
--repository shop-web \
--source-branch feat/new-checkout --target-branch main \
--title "Checkout v2 (dark, behind new-checkout flag)" \
--auto-complete true --squash true --delete-source-branch true
{
"pullRequestId": 412,
"status": "active",
"mergeStatus": "queued",
"sourceRefName": "refs/heads/feat/new-checkout",
"targetRefName": "refs/heads/main",
"autoCompleteSetBy": { "uniqueName": "[email protected]" }
}
# 3. Watch the validation build the PR just triggered
az pipelines runs list --branch refs/pull/412/merge --top 1 \
--query "[0].{id:id, status:status, result:result}"
{
"id": 20817,
"status": "inProgress",
"result": null
}

mergeStatus: "queued" means Azure Repos is already test-merging your branch against main in the background. --auto-complete true then finishes the PR by itself once every policy passes: build validation, required reviewers, work-item link. Nobody sits watching the merge button.

Feature flags in Azure App Configuration

A feature flag can be an if wrapped around an environment variable, and on a hobby project that is fine. It stops being fine the first time you need to flip the switch *without redeploying*, turn something on for one user in ten, or answer "who turned this on, and when?". Azure App Configuration is the managed version. Each flag is stored as a key-value pair under the reserved prefix .appconfig.featureflag/, with a small JSON (JavaScript Object Notation, a plain-text data format) body holding its state and filters. Your app does not call the store on every request. The App Configuration provider that feeds the Microsoft.FeatureManagement libraries (with equivalents for Java, Python, and JavaScript) keeps a cached copy and re-polls for changes on a refresh interval, 30 seconds by default, so a flip reaches every running instance without a restart.

feature-flags.sh
# Create the flag — default state is OFF
az appconfig feature set --name shop-config --feature new-checkout \
--description "Checkout v2 rollout" --yes \
--query "{key:key, name:name, state:state, locked:locked}"
{
"key": ".appconfig.featureflag/new-checkout",
"name": "new-checkout",
"state": "off",
"locked": false
}
# Release to 10% of users — no deploy involved
az appconfig feature filter add --name shop-config --feature new-checkout \
--filter-name Microsoft.Targeting \
--filter-parameters Audience="{\"DefaultRolloutPercentage\":10}" --yes
az appconfig feature enable --name shop-config --feature new-checkout --yes
az appconfig feature show --name shop-config --feature new-checkout \
--query "{name:name, state:state}"
{
"name": "new-checkout",
"state": "conditional"
}
# Incident? Kill it. Live at the next client refresh (~30 s), no rollback build
az appconfig feature disable --name shop-config --feature new-checkout --yes

state: "conditional" means the flag is on but gated behind the Microsoft.Targeting filter. That filter hashes each user ID against the rollout percentage, so one user gets the same answer request after request instead of the feature flickering on and off underneath them. That is a canary release (letting a small slice of real traffic meet the change first) built entirely out of configuration, and the *Progressive delivery* lesson stacks rings and traffic splitting on this same primitive. The kill switch is the identical mechanism run backwards. feature disable takes hold at the next refresh, with no build, no pipeline, no rollback deployment.

Deploy dark: slots, swaps, and sticky settings

Flags separate release from deployment. Deployment slots take the risk out of the deployment itself. A slot is a parallel, live copy of your App Service with its own hostname and its own configuration. The routine goes like this: deploy every build to a staging slot, let it warm up, then swap. Azure repoints the routing so staging becomes production with no cold start, and the old production build stays parked in the staging slot, still warm, as an instant rollback.

Configuration is the part that bites everyone. During a swap, ordinary app settings travel *with the app*, so whatever you configured on staging arrives in production. Settings marked as deployment slot settings ("sticky") stay pinned to their slot instead, so production keeps its own connection string and its own APPCONFIG_LABEL=prod no matter which build swaps in. Get that backwards and your new build boots in production talking to the staging database. Azure also watches for special WEBSITE_* settings during a swap: set WEBSITE_SWAP_WARMUP_PING_PATH and WEBSITE_SWAP_WARMUP_PING_STATUSES, and the swap aborts unless the incoming slot answers healthy first.

slot-swap.sh
# One-time: create a staging slot cloned from production's config
az webapp deployment slot create --resource-group rg-shop --name shop-web \
--slot staging --configuration-source shop-web
# Pin per-environment settings to their slot (sticky = survives the swap)
az webapp config appsettings set -g rg-shop -n shop-web --slot staging \
--slot-settings APPCONFIG_LABEL=staging ENVIRONMENT=staging
[
{ "name": "APPCONFIG_LABEL", "value": "staging", "slotSetting": true },
{ "name": "ENVIRONMENT", "value": "staging", "slotSetting": true }
]
# Refuse to swap an unhealthy build: warm-up must get 200 from /healthz
az webapp config appsettings set -g rg-shop -n shop-web \
--settings WEBSITE_SWAP_WARMUP_PING_PATH=/healthz \
WEBSITE_SWAP_WARMUP_PING_STATUSES=200
# Two-phase swap: preview applies prod's sticky settings to staging first
az webapp deployment slot swap -g rg-shop -n shop-web \
--slot staging --target-slot production --action preview
# ...smoke-test https://shop-web-staging.azurewebsites.net, then complete:
az webapp deployment slot swap -g rg-shop -n shop-web \
--slot staging --target-slot production --action swap
# No output = success. Old prod build now sits in 'staging' as rollback.

For high-stakes services, use the two-phase form shown above. --action preview copies production's sticky settings onto the staging slot and then pauses, so you smoke-test the *exact* configuration that is about to go live. --action swap completes it, and --action reset backs the whole thing out.

The pipeline that ships it

Written as pipeline-as-code, the whole flow is one deployment job with two tasks. (The YAML schema, the agents that run it, and environment approvals each get their own lesson. What matters here is the deploy-then-swap pair.)

azure-pipelines.yml
trigger: [ main ]
stages:
- stage: deploy_dark
jobs:
- deployment: web
environment: production # approvals & checks: later lesson
strategy:
runOnce:
deploy:
steps:
- task: AzureWebApp@1
inputs:
azureSubscription: 'shop-prod-sc'
appType: webAppLinux
appName: shop-web
deployToSlotOrASE: true
resourceGroupName: rg-shop
slotName: staging
package: $(Pipeline.Workspace)/drop/shop.zip
- task: AzureAppServiceManage@0
inputs:
azureSubscription: 'shop-prod-sc'
action: 'Swap Slots'
webAppName: shop-web
resourceGroupName: rg-shop
sourceSlot: staging
# Run log (abridged):
# Starting: AzureWebApp
# Package deployment using ZIP Deploy initiated.
# Successfully deployed web package to App Service 'shop-web' slot 'staging'
# Starting: AzureAppServiceManage
# Swapping App Service 'shop-web' slot 'staging' with 'production' slot
# Successfully swapped slots.
# Finishing: AzureAppServiceManage

Every merge to main now produces a production deployment. Unfinished features are flagged off, and the swap only promotes a slot that has been warmed and health-checked, which is what makes "deploy on every merge" boring rather than brave. The decision about what users actually see has left the pipeline. It lives in App Configuration now.

Flags as code: Bicep and Terraform

A flag is production configuration, and production configuration earns the same review and the same history as code. Bicep (Microsoft's own language for describing Azure resources) and Terraform can both declare flags, so a flag's existence, its default state, and its rollout filter arrive through a pull request instead of out of somebody's shell history.

flags.bicep
// A feature flag is just a key-value with a reserved content type.
// In the resource name, '/' must be escaped as '~2F'.
resource store 'Microsoft.AppConfiguration/configurationStores@2024-05-01' existing = {
name: 'shop-config'
}
resource newCheckout 'Microsoft.AppConfiguration/configurationStores/keyValues@2024-05-01' = {
parent: store
name: '.appconfig.featureflag~2Fnew-checkout'
properties: {
contentType: 'application/vnd.microsoft.appconfig.ff+json;charset=utf-8'
value: '{"id":"new-checkout","enabled":false,"conditions":{"client_filters":[]}}'
}
}
// az deployment group create -g rg-shop -f flags.bicep \
// --query properties.provisioningState
// "Succeeded"
flags.tf
resource "azurerm_app_configuration_feature" "new_checkout" {
configuration_store_id = azurerm_app_configuration.shop.id
name = "new-checkout"
description = "Checkout v2 rollout"
enabled = false
targeting_filter {
default_rollout_percentage = 10
}
# Operators flip flags at runtime; don't let the next apply revert them
lifecycle {
ignore_changes = [enabled]
}
}
# terraform plan
# # azurerm_app_configuration_feature.new_checkout will be created
# + resource "azurerm_app_configuration_feature" "new_checkout" {
# + enabled = false
# + key = (known after apply)
# + name = "new-checkout"
# ...
# Plan: 1 to add, 0 to change, 0 to destroy.

There is a real tension here. IaC (infrastructure as code, describing your cloud resources in files you commit and review) wants to own the flag's state, but operators flip flags at runtime, so a later terraform apply would quietly undo their change mid-incident. The usual resolution sits in the file above: the code owns whether the flag exists and which filters it carries, ignore_changes = [enabled] leaves the toggle to humans, and the record of who flipped what lives in App Configuration's audit log rather than in git. One Azure-specific detail catches people out. The azurerm provider writes flags over the store's *data plane*, so the pipeline identity needs the App Configuration Data Owner role, and plain ARM (Azure Resource Manager) Contributor gets you a 403.

Stale flags are unaudited production switches
Long-lived branches and long-lived flags rot the same way. A branch that drifts for weeks comes back as a giant merge reviewed by exhausted humans, which is the opposite of continuous integration. A flag that outlives its rollout is worse: a production switch wired to unreviewed, half-remembered code, flippable in seconds with no pipeline standing in the way. Knight Capital lost $440M in 45 minutes when a repurposed flag woke up eight-year-old dead code. Treat flags like credentials. Restrict who can flip them with App Configuration's data-plane RBAC (role-based access control), alert on flag changes, give every flag an owner and an expiry date, and delete the flag *and its dead code path* once the feature reaches 100%.
Deploy dark, release on demand
1Short-lived branch
PR to main within days; policies gate the merge
2Merge behind a flag
incomplete code ships disabled
3Deploy to staging slot
warm-up must pass /healthz
4Swap to production
sticky settings stay put; old build = rollback
5Release via flag
10% targeting → 100%; disable = kill switch
Deployment ends at the swap. Release is a config change, reversible in seconds, no pipeline required.

The habits that keep this flow safe once a team grows: branches measured in days, every flag born *off* with an owner and a deletion date, sticky settings reviewed as carefully as code, flag flips audited like credential use. One thing has been quietly assumed the whole way through, though. That shop.zip the pipeline swapped into production came from somewhere. Where build outputs live, how they get versioned, and how you stop a poisoned package from riding this pipeline into production is next: *Artifacts & dependencies*.

GitFlow-style long branches feel safe, and they are how merge hell gets built. Trunk-based flow with small PRs feels reckless right up until your CI and your flags are solid, at which point it is both faster and safer. Rule of thumb: a branch that lives longer than a couple of days is risk you have chosen to save up.

App Configuration is one option among several. LaunchDarkly and a hand-rolled config table do the same job, and the tool you pick matters less than the discipline around it. Progressive exposure (internal users, then 5%, then 50%, then everyone) belongs with the progressive delivery lesson. The shift to make here is a working rhythm: merge daily, keep unfinished behavior dark behind a flag, delete the flag once the feature is done.

Try this

Sketch your team's current branching on a whiteboard, then run one trunk-based micro-merge against it: a single-commit PR into main carrying a feature flag that defaults to off. Deploy main whenever you like after that, and flip the flag when you are ready.

terminal
# Feature flag as an App Configuration key (lab):
az appconfig kv set -n <appconfig> --key "FeatureManagement:NewCheckout" --value false -y
az appconfig kv show -n <appconfig> --key "FeatureManagement:NewCheckout" -o json
output
$ az appconfig kv show -n contoso-config --key "FeatureManagement:NewCheckout" -o json
{
"key": "FeatureManagement:NewCheckout",
"value": "false",
"label": null
}
# Sample output — deploy freely; release by changing the value to true.

Takeaway

Trunk-based development keeps branches short enough that merging stays cheap. Feature flags split "the bits are on the server" from "users can see the change", which is what lets you deploy on a Tuesday afternoon and release on Thursday morning.

Next: ban long-lived release branches for ordinary features, and file the flag-cleanup ticket at the same time as the launch ticket, so today's rollout switch does not turn into next year's permanent debt.

Quick check
01You push a build to the staging slot, which carries a staging database connection string, and swap it into production. Ordinary app settings travel with the app during a swap. What goes wrong if the production database connection string was never marked as a deployment slot setting (sticky)?
Correct — This is the failure mode the lesson calls out: settings travel with the app on a swap unless you pin them, so the new build boots in production talking to the staging database.
Incorrect — and a common belief. By default connection strings and app settings both swap with the app. Anything that belongs to one environment has to be marked sticky or it travels.
Incorrect — That is the separate WEBSITE_SWAP_WARMUP_PING_PATH and STATUSES health gate, which aborts an unhealthy swap. It does nothing about which settings stay with which slot.
Incorrect — Rollback still works, because the old build waits pre-warmed in the staging slot. The damage here is production pointed at the wrong database, not a lost rollback.
02You flip a feature flag in Azure App Configuration from off to on. How does that change reach the app instances already running in production?
Incorrect — No. The provider does not hit the store per request. It keeps a cached copy locally.
Correct — Cache plus a refresh poll is exactly how the flip spreads, which is why no restart is needed.
Incorrect — No restart happens. Needing one would defeat the point of a runtime flag.
Incorrect — No. Splitting release from deployment is the whole idea. Flipping a flag needs no deploy.
03It is late at night. A feature you released to 100% of users behind an Azure App Configuration flag starts throwing errors. You want users off it as fast as possible and with the least risk. What is the BEST action?
Correct — feature disable is the kill switch: live at the next refresh, with no build, no pipeline and no rollback deployment.
Incorrect — No. That burns many minutes and redeploys the whole app when a configuration flip would do the job.
Incorrect — No. A slot swap rolls back the entire deployment, which is slower and far broader than turning off one flag.
Incorrect — No. That takes the whole application offline to deal with one failing feature.

Related