You changed a value in Azure App Configuration — a timeout, a feature flag, a rate limit — clicked save, and watched the running app keep serving the old value. Five minutes later, still old. You restart one instance and that one picks it up, which “proves” the change is correct and makes the silence on the others even more baffling. This is the most common App Configuration support theme, and almost none of it is a bug in the service. It is a chain of small, invisible contracts — a sentinel key that has to be bumped, refresh middleware that has to be wired, a cache window that has to elapse, an options type that has to be the monitoring one — any of which, left undone, produces the identical symptom: “I changed it and nothing happened.”
App Configuration’s dynamic-refresh model is pull-based, and the polling is deliberately lazy — which is exactly why it surprises people. It does not poll every key; it watches one sentinel key and only re-reads the rest when that key’s ETag changes. It does not poll continuously; it checks at most once per refresh interval (default 30 seconds). And it does nothing at all unless something in your app actually drives the refresher — app.UseAzureAppConfiguration() in a web app, or a timer calling TryRefreshAsync() in a worker. Miss any one link and the value is correct in the store, correct in the portal, and stale in production.
This is the diagnostic playbook for that exact failure. We treat “config didn’t update” not as one bug but as a fan-out of distinct causes, each with a place that tells the truth: the sentinel’s ETag, the middleware registration in Program.cs, the SetRefreshInterval value, whether you injected IOptions<T> or IOptionsMonitor<T>, whether a Key Vault reference resolved or silently returned empty, and whether the instance is frozen on a pinned snapshot. Every diagnosis comes with the exact az appconfig command or code check, and the precise fix. If you have not yet built the refresh wiring, the Azure App Configuration production playbook is where you start; this is what you open when you built it and it still won’t update.
What problem this solves
Dynamic configuration is the whole promise of App Configuration: change a setting once, centrally, and have every instance pick it up without a redeploy. When that promise quietly fails, the cost is not a visible outage — it is the absence of a change you expected: a throttle limit you raised mid-incident that keeps throttling, a broken feature you disabled that users keep hitting. The change “didn’t work,” so someone redeploys to force it, masking the real defect (the refresh wiring) and teaching the team that App Configuration “needs a restart anyway” — defeating the reason you adopted it.
What breaks without this knowledge is the team’s mental model. Because the symptom is identical across half a dozen root causes, people fix it by superstition: they restart instances (which always “works” because startup re-reads everything), shorten the interval to one second (multiplying cost and still not fixing a missing sentinel bump), or conclude refresh is unreliable and rip it out. The actual cause is almost always one specific, findable link in the chain — and finding which link in two minutes is the difference between trusting your config plane and abandoning it.
Who hits this: every team running the App Configuration provider with dynamic refresh — most past the prototype stage. It bites hardest on multi-instance fleets (where one stale instance hides), apps that adopted IOptions<T> out of habit, anyone using Key Vault references (silent failures — an empty string, not an error), and pipelines that forget to bump the sentinel last. The fix is never “restart and hope” — it is “find the broken link and repair exactly that link.”
To frame the whole field before the deep dive, here is every symptom class this article covers, the question it forces, and the one place to look first:
| Symptom class | What’s really happening | First question to ask | First place to look |
|---|---|---|---|
| Nothing refreshes, ever | Refresh was never driven | Is UseAzureAppConfiguration() actually called? |
Program.cs middleware order |
| One key changed, app ignores it | Sentinel not bumped after the edit | Did the sentinel ETag change? | az appconfig kv show --key Sentinel |
| Change lands, but ~30 s late or “sometimes” | Refresh interval / per-instance timing | Has the cache window elapsed on this instance? | SetRefreshInterval value |
| Value updates in logs but not in my service | IOptions<T> captured at startup |
Are you reading IOptionsMonitor<T>.CurrentValue? |
The injected options type |
| Feature flag won’t flip, data keys do | Flags refresh on a separate registration | Is UseFeatureFlags() refresh wired? |
The AddAzureAppConfiguration block |
| A secret-backed setting is empty/stale | Key Vault reference failed or isn’t re-resolved | Did the reference resolve as the app identity? | az appconfig kv show content type + identity |
| This one instance is frozen | Pinned to an immutable snapshot | Is the instance reading a snapshot? | The SelectSnapshot call / startup args |
Learning objectives
By the end of this article you can:
- Explain the pull-based refresh model end to end — sentinel key, ETag conditional GET, refresh interval,
refreshAll, and what “drives” the refresher — and name the link that breaks for each symptom. - Diagnose “I changed a value and nothing happened” as one specific cause: missing middleware, un-bumped sentinel, un-elapsed cache window, wrong options type, unwired flag refresh, failed Key Vault reference, or a pinned snapshot — and confirm which.
- Use
az appconfig kv show/kv listto read a key’s value, label, content type and ETag and prove whether the sentinel actually changed. - Wire dynamic refresh correctly in ASP.NET Core (
ConfigureRefresh→Register("Sentinel", refreshAll: true)→SetRefreshInterval→UseAzureAppConfiguration()) and in a worker (IConfigurationRefresherProvider+TryRefreshAsync()). - Read live values through
IOptionsMonitor<T>.CurrentValuerather than the startup-frozenIOptions<T>— the cause of half these tickets. - Diagnose a Key Vault reference resolving to an empty string via content type, the managed identity, and
Key Vault Secrets UserRBAC — distinct from a normal stale value. - Right-size the refresh interval against request cost, and recognise when 429 throttling, a pinned snapshot, or a label mismatch — not refresh wiring — is the real cause.
Prerequisites & where this fits
You should already understand the basics: a key-value is a key plus an optional label (so App:Timeout with label prod and label dev are two distinct entries), and the .NET provider loads those into IConfiguration at startup by Selecting keys for a label. You should be comfortable running az in Cloud Shell and editing an ASP.NET Core Program.cs. If labels are new, read the Azure App Configuration getting-started guide first — this article assumes the foundation it builds.
This sits in the Observability & Troubleshooting track and is the debugging counterpart to the Azure App Configuration production playbook, which teaches the sentinel-and-refresh pattern; here we fix it when it won’t fire. It leans on System-assigned vs user-assigned managed identity patterns (Key Vault references resolve as the app’s identity) and Azure Key Vault secrets, keys and certificates for the secret side; when a reference fails on permissions or firewall, Key Vault 403 Forbidden — firewall, RBAC and soft-delete recovery is the next stop. Application Insights is the most useful instrument here, so Azure Monitor and Application Insights for observability pairs tightly.
A quick map of who owns which link in the chain, so you call the right person fast:
| Link in the chain | What lives here | Who usually owns it | Failure it causes |
|---|---|---|---|
| The store (keys, labels, sentinel) | Key-values, ETags, content types | Platform / release | Un-bumped sentinel; wrong label edited |
| The provider bootstrap | AddAzureAppConfiguration, Select, ConfigureRefresh |
App / dev team | Refresh never registered; wrong selector |
| The middleware / timer | UseAzureAppConfiguration(); TryRefreshAsync() |
App / dev team | Refresh never driven — nothing fires |
| The consumption point | IOptions<T> vs IOptionsMonitor<T> |
App / dev team | Value updates but the service never sees it |
| Key Vault (for references) | Secret value, RBAC, firewall | Security / platform | Reference resolves empty or stale |
| The pipeline | Import/export, sentinel bump | DevOps / release | Non-atomic refresh; sentinel never bumped |
Core concepts
Five mental models make every later diagnosis obvious. Internalise these and the playbook reads itself.
Refresh is pull-based — nothing is pushed. When you change a value, the service does not call your app; there is no webhook, no socket held open for a server push. Your provider polls. So “I changed it and nothing happened” is the default behaviour of a process that either isn’t polling, isn’t due to poll yet, or is polling something that didn’t change. Every fix makes the poll happen, happen sooner, or notice the change.
The sentinel key is the trigger, not the value. Polling every key would be wasteful and would race (you could read a half-applied batch mid-write). So the provider watches one key — the sentinel — issuing only a cheap conditional GET (an ETag check) on it each interval. When the sentinel’s ETag changes and you registered it with refreshAll: true, the provider reloads the entire registered key set atomically. The corollary that trips everyone: changing a data key does not trigger refresh by itself. Bump the sentinel last, after writing your batch, or the watched ETag never moves and nothing reloads.
Refresh has to be driven, and it is rate-limited. ConfigureRefresh only declares what to watch; something must call the refresher. In a web app that is the app.UseAzureAppConfiguration() middleware, which checks the sentinel on incoming requests; in a worker you inject IConfigurationRefresherProvider and call TryRefreshAsync() on your own timer. The check is throttled to the refresh interval (default 30 s) — between checks both are no-ops. So even fully wired, a change can take up to one interval to appear per instance, and an app with zero traffic never refreshes at all because nothing drives the middleware.
The consumption type decides whether your code ever sees the change. The provider can refresh IConfiguration perfectly and your service can still serve the old value — because IOptions<T> is a singleton captured once at startup and never re-read. To see a refreshed value you must read IOptionsMonitor<T>.CurrentValue (re-evaluated on each access) or bind to IConfiguration and read it live. This single distinction is behind a large share of “refresh isn’t working” tickets where refresh is working — the value updated in the configuration root, but the consuming class froze it at boot.
Some “config” travels different tracks. Feature flags refresh through a separate registration (UseFeatureFlags() with its own interval), so a data-key sentinel bump does nothing for flags. Key Vault references are pointers; the secret value is fetched from Key Vault as your app’s identity, and a failure resolves to an empty string with no error. A snapshot is an immutable copy you pin to — and a pinned instance does not get refresh, by design. Each looks like “refresh is broken” but is a different track entirely. (The Glossary at the end pins down every term for lookup.)
The refresh chain, link by link
Before the per-symptom anatomy, hold the whole chain in your head. A change reaches a running instance only if every link below is intact, and the diagnosis is always “which link is broken.” This table is the map — each link, how it fails, and the one check that proves it:
| # | Link | What must be true | Failure if not | One-line check |
|---|---|---|---|---|
| 1 | Value written to the right key+label | The edit hit the label the app loads | App reads a different label’s value | az appconfig kv show --key K --label L |
| 2 | Sentinel bumped after the batch | The watched key’s ETag changed | Nothing triggers a reload | az appconfig kv show --key Sentinel --label L (value changed) |
| 3 | Refresh registered | ConfigureRefresh + Register("Sentinel", refreshAll: true) |
Provider doesn’t watch anything | grep Program.cs for ConfigureRefresh |
| 4 | Refresh driven | UseAzureAppConfiguration() or timer calls refresher |
Refresh code never executes | grep for UseAzureAppConfiguration |
| 5 | Interval elapsed | More than SetRefreshInterval since last check |
Change waits up to one interval | Check the configured interval; wait it out |
| 6 | Traffic (web) / timer tick (worker) | A request arrives to drive the middleware | Idle app never refreshes | Send a request; confirm timer in worker |
| 7 | Consumed via IOptionsMonitor<T>/live IConfiguration |
The reader re-evaluates | Service holds the startup value | Inspect the injected type |
| 8 | (Flags) feature-flag refresh wired | UseFeatureFlags() with its own interval |
Flags never flip | grep for UseFeatureFlags |
| 9 | (Secrets) Key Vault reference resolves | Identity has Key Vault Secrets User; secret exists |
Setting is an empty string | az appconfig kv show content type + identity |
| 10 | Not pinned to a snapshot | The instance reads live keys | Instance frozen by design | Check for SelectSnapshot |
Read it top to bottom on every incident; the first link that fails the check is your root cause — in practice almost always link 2 (sentinel), 4 (middleware), 5 (interval), or 7 (options type).
Symptom 1 — Nothing ever refreshes (the middleware was never wired)
The most absolute version: you change any value, bump the sentinel, wait minutes, and no instance updates. Restarting one always fixes it (startup re-reads everything) — the tell that refresh is registered but never driven.
Root cause. ConfigureRefresh(...) declares what to watch, but in a web app the app.UseAzureAppConfiguration() middleware is what actually checks the sentinel on each request. If it was never added, the refresher is never called and nothing reloads, ever. The equivalent miss in a worker is having no timer calling TryRefreshAsync().
Confirm. Check the source for the middleware registration and its order:
# Is the middleware even present? (run at your repo root)
grep -rn "UseAzureAppConfiguration" ./src
# And the registration it depends on:
grep -rn "ConfigureRefresh\|AddAzureAppConfiguration" ./src
If ConfigureRefresh is present but UseAzureAppConfiguration is missing, that is the bug. The behavioural tell: a fresh process has the new value (restart works) but a long-running one never updates — the refresher isn’t being driven.
Fix. Add the middleware (web) early in the pipeline, or a timer (worker). The minimal correct ASP.NET Core wiring:
// Program.cs — register the provider with refresh...
builder.Configuration.AddAzureAppConfiguration(options =>
{
options.Connect(new Uri(appConfigEndpoint), new DefaultAzureCredential())
.Select("App:*", labelFilter: "prod")
.ConfigureRefresh(refresh =>
{
refresh.Register("Sentinel", refreshAll: true) // watch sentinel; reload all
.SetRefreshInterval(TimeSpan.FromSeconds(30));
});
});
var app = builder.Build();
app.UseAzureAppConfiguration(); // THIS drives the refresh on each request — easy to forget
For a worker with no HTTP pipeline, inject IConfigurationRefresherProvider and drive it on a timer inside a BackgroundService:
var refresher = provider.Refreshers.First(); // from IConfigurationRefresherProvider
await refresher.TryRefreshAsync(ct); // no-op until the interval elapses; call on a loop
How refresh gets driven, and how each way fails:
| App type | What drives refresh | Common miss | Symptom |
|---|---|---|---|
| ASP.NET Core web | app.UseAzureAppConfiguration() middleware |
Omitted, or no traffic to drive it | Never updates until restart |
| Worker / background service | Timer calling TryRefreshAsync() |
No timer at all; or interval-only | Never updates until restart |
| Console / one-shot job | (none — reads once) | Expecting live refresh in a short job | Use snapshot or just re-run |
Symptom 2 — One key changed but the app ignores it (the sentinel)
You edited a single data key, the app never picks it up, yet a batch change last week (where you happened to bump the sentinel) worked. The sentinel pattern is doing exactly what it promises: the provider watches the sentinel, not your individual keys.
Root cause. Refresh fires on a change to the registered watched key (the sentinel) only. Editing App:Timeout without bumping Sentinel leaves the sentinel’s ETag unchanged, so the conditional GET returns “not modified” and nothing reloads — even though the key genuinely changed. The design trades “any key triggers refresh” for “one atomic reload per batch,” and the price is that you must bump the sentinel last.
Confirm. Read the sentinel and your changed key, and compare their lastModified:
# Compare lastModified on the sentinel vs the key you actually changed:
az appconfig kv show -n appcs-platform-prod --key "Sentinel" --label prod --query "{v:value, etag:etag, modified:lastModified}" -o jsonc
az appconfig kv show -n appcs-platform-prod --key "App:Timeout" --label prod --query "{v:value, modified:lastModified}" -o jsonc
If App:Timeout’s lastModified is newer than the sentinel’s, you changed the value but never bumped the trigger — that is the bug.
Fix. Bump the sentinel after writing your change(s), so the watched ETag moves and refreshAll reloads the whole set:
# Write the real change(s) first...
az appconfig kv set -n appcs-platform-prod --key "App:Timeout" --value "45" --label prod --yes
# ...then bump the sentinel LAST to trigger a coherent, atomic refresh:
az appconfig kv set -n appcs-platform-prod --key "Sentinel" --value "v43" --label prod --yes
The ordering rules that prevent the subtle races:
| Rule | Why | What goes wrong if you break it |
|---|---|---|
| Bump sentinel last | The watched ETag is the trigger | Refresh fires before your keys are written → reloads old values |
| Bump sentinel once per batch | One atomic reload of the whole set | Bumping mid-batch can expose a half-applied state |
| Use the same label on sentinel and keys | The provider watches the sentinel under the loaded label | Bumping the wrong-label sentinel changes nothing the app watches |
A subtlety worth knowing: with refreshAll: false, a sentinel change reloads only the sentinel itself, not your data keys — so they still look stale. For the batch pattern you almost always want refreshAll: true.
Symptom 3 — Change lands, but ~30 seconds late or “only sometimes”
It does update — just not instantly, and not uniformly across instances. One pod has the new value, another serves the old one for half a minute, then catches up. People mistake this delayed propagation for unreliability.
Root cause. Two timing facts combine. First, the sentinel check is throttled to the refresh interval (SetRefreshInterval, default 30 seconds) — between checks the refresher is a no-op, so a change can take up to one full interval to be noticed. Second, the interval is tracked per instance, out of phase because each instance’s clock started when it last refreshed: one checks 2 seconds after your edit, another 28. During that window the fleet legitimately serves a mix of old and new — by design, eventually consistent.
Confirm. There is no error to find; you reason about the interval. Check what the app uses (it is in code) and observe the lag never exceeds it — an Application Insights trace per refresh shows the new value reaching each cloud_RoleInstance within one interval:
# Find the configured interval in source (defaults to 30s if SetRefreshInterval is omitted):
grep -rn "SetRefreshInterval" ./src
Fix. This is correct behaviour, not a defect — the “fix” is to set the interval to match your tolerance, accepting the request-cost trade. Lower it for faster flips; do not chase zero:
.ConfigureRefresh(refresh =>
{
refresh.Register("Sentinel", refreshAll: true)
.SetRefreshInterval(TimeSpan.FromSeconds(15)); // faster propagation, ~2x the checks
});
How the interval trades latency against cost — pick deliberately:
| Interval | Worst-case staleness | Relative sentinel checks | Use when |
|---|---|---|---|
| 5 s | ~5 s | ~6x baseline | Fast feature flips; small fleet; cost not a concern |
| 30 s (default) | ~30 s | baseline | The sensible default for most apps |
| 5 min | ~5 min | ~0.1x | Rarely-changed config; cost-sensitive; large fleet |
| (none set) | ~30 s | baseline | You get the 30 s default silently |
The caution: a 1-second interval does not make refresh “instant” — it multiplies request volume toward any quota for a barely-perceptible gain, and the per-instance phase offset still leaves a brief mixed state. Match the interval to how fast a change genuinely must propagate.
Symptom 4 — The value updates in logs but my service still uses the old one
Your logging prints the config value on refresh and shows the new value — yet the code that uses it behaves as if nothing changed. Refresh is working; your consumption pattern is not.
Root cause. IOptions<T> resolves the options object once, at first access, as a singleton, and never re-reads — a class that injected IOptions<MyOptions> froze the value at startup and is immune to refresh. The provider can update IConfiguration all day; that frozen object will not move. Read instead through IOptionsMonitor<T>.CurrentValue, which re-evaluates on each access, or read IConfiguration directly at the point of use.
Confirm. Inspect which options abstraction the affected class injects:
# Find startup-frozen IOptions<T> usages that should be IOptionsMonitor<T>:
grep -rn "IOptions<" ./src
grep -rn "IOptionsMonitor<" ./src
If the stale class injects IOptions<T> (not IOptionsMonitor<T>/IOptionsSnapshot<T>), that is the bug — and it explains a log showing the new value (the root updated) while the service stays old (its frozen object did not).
Fix. Switch the dependency to IOptionsMonitor<T> and read .CurrentValue at the point of use (not once in the constructor):
public class OrderService(IOptionsMonitor<OrderOptions> options)
{
public int CurrentTimeout =>
options.CurrentValue.TimeoutSeconds; // re-read live on every access — sees refreshes
}
The three options abstractions and exactly when each sees a change:
| Type | When it reads config | Sees dynamic refresh? | Use it for |
|---|---|---|---|
IOptions<T> |
Once, at first access (singleton) | No — frozen at startup | Truly static config that never changes at runtime |
IOptionsSnapshot<T> |
Once per scope (e.g. per HTTP request) | Per request (scoped) | Per-request consistency in web apps |
IOptionsMonitor<T> |
On every .CurrentValue access |
Yes — live | Singletons/long-lived services that must see refreshes |
The trap inside the trap: capturing .CurrentValue once in a singleton’s constructor re-introduces the freeze — functionally identical to IOptions<T>. Read CurrentValue at the point of use each time, or subscribe via OnChange.
Symptom 5 — Data keys refresh but a feature flag won’t flip
Ordinary settings update fine, but toggling a feature flag in the portal does nothing to running instances — even after a sentinel bump. Flags and data keys travel on different tracks.
Root cause. Feature flags load and refresh through a separate registration (UseFeatureFlags()), independent of the ConfigureRefresh for data keys. A sentinel bump triggers the data-key refresh, not the flag refresh. If the feature-flag registration has no refresh interval — or UseFeatureFlags() was never added — flags load once at startup and never flip live.
Confirm. Check the wiring, then confirm the flag genuinely changed in the store (flags live under the reserved .appconfig.featureflag/ prefix):
grep -rn "UseFeatureFlags" ./src # is flag loading + refresh wired at all?
az appconfig feature list -n appcs-platform-prod --label prod --query "[].{feature:key, enabled:state}" -o table
If the flag’s state is correct in the store but UseFeatureFlags is absent or has no refresh interval, that mismatch is the bug.
Fix. Register feature flags with their own refresh cadence inside AddAzureAppConfiguration — flags then refresh on their interval, no data-key sentinel bump needed:
builder.Configuration.AddAzureAppConfiguration(options =>
{
options.Connect(new Uri(appConfigEndpoint), new DefaultAzureCredential())
.Select("App:*", labelFilter: "prod")
.ConfigureRefresh(r => r.Register("Sentinel", refreshAll: true)
.SetRefreshInterval(TimeSpan.FromSeconds(30)))
.UseFeatureFlags(ff =>
{
ff.SetRefreshInterval(TimeSpan.FromSeconds(30)); // flags refresh on THIS interval
});
});
// In Program.cs also register: builder.Services.AddFeatureManagement();
Data keys vs feature flags — why “config” is two tracks:
| Aspect | Data keys | Feature flags |
|---|---|---|
| Loaded by | Select(...) |
UseFeatureFlags(...) |
| Refreshed by | ConfigureRefresh + sentinel |
UseFeatureFlags own interval |
| Triggered on | Sentinel ETag change | Its own refresh interval (no sentinel needed) |
| Read in code via | IOptionsMonitor<T> / IConfiguration |
IFeatureManager / IVariantFeatureManager |
| Stored as | Plain key-values | Reserved .appconfig.featureflag/ keys |
The takeaway: a sentinel bump is for data keys. If flags lag while data keys update, look at UseFeatureFlags, not the sentinel.
Symptom 6 — A secret-backed setting is empty or stale
A setting backed by a Key Vault reference comes through as an empty string (or never updates after you rotate the secret), while plain keys are fine. This masquerades as a refresh bug but is really a silent identity/permission or resolution failure.
Root cause. A Key Vault reference is a key whose value is a JSON pointer to a secret and whose content type is application/vnd.microsoft.appconfig.keyvaultref+json. The provider dereferences it against Key Vault as your app’s managed identity. If the identity lacks Key Vault Secrets User, the vault firewall blocks the app, the secret doesn’t exist, or the URI is wrong, the reference resolves to an empty string with no exception — the app boots “successfully” with a blank secret. And rotation only propagates if the reference is re-resolved on refresh, so the same sentinel/interval rules apply on top of a working Key Vault path.
Confirm. First verify the key is actually a reference (content type), then check the identity and its access:
# Is it a Key Vault reference? Look at the content type and the pointer value:
az appconfig kv show -n appcs-platform-prod --key "App:DbPassword" --label prod \
--query "{contentType:contentType, value:value}" -o jsonc
# contentType ending in keyvaultref+json → it's a reference, not a literal value
# Does the app even have an identity, and what is its principal?
az webapp identity show -n app-orders-prod -g rg-orders-prod -o jsonc
# Can that identity read the secret? Check the role assignment on the vault:
az role assignment list --assignee <principalId> \
--scope $(az keyvault show -n kv-orders-prod --query id -o tsv) \
--query "[].roleDefinitionName" -o tsv
If the content type is the reference type but the role list lacks Key Vault Secrets User (or an access policy granting Get on secrets), the reference cannot resolve — that is your empty string. The troubleshooting managed identity token acquisition 403 guide covers the identity side when the token itself fails.
Fix. Grant the identity read access on the vault, ensure the secret exists and the firewall allows the app, then bump the sentinel to re-resolve the reference:
# Grant the app identity 'Key Vault Secrets User' on the vault (RBAC vaults):
az role assignment create --assignee <principalId> \
--role "Key Vault Secrets User" \
--scope $(az keyvault show -n kv-orders-prod --query id -o tsv)
# After fixing access (or rotating the secret), bump the sentinel to re-resolve references:
az appconfig kv set -n appcs-platform-prod --key "Sentinel" --value "v44" --label prod --yes
Why a Key Vault reference looks like a refresh bug but isn’t — the distinguishing signals:
| Signal | Plain stale value | Failed Key Vault reference |
|---|---|---|
| Content type | none / plain | ...keyvaultref+json |
| Value in app | old (non-empty) value | empty string |
| Error thrown | none | none (silent) — the trap |
| Root cause | refresh wiring | identity / RBAC / firewall / missing secret |
| Confirm with | sentinel + interval checks | content type + az role assignment list |
| Fix | bump sentinel / fix middleware | grant Key Vault Secrets User, then bump sentinel |
The note that catches people: permissions on the resource are not permissions on the data. The app needs an explicit Key Vault data-plane role to read the secret, just as it needs App Configuration Data Reader (not Contributor) to read keys. The Key Vault RBAC vs access policies — the permission model guide covers which model your vault uses.
Symptom 7 — One instance is frozen on old config
Across an otherwise-healthy fleet, a single instance (or a whole deployment) never reflects changes, no matter how many sentinel bumps. It is not lagging — it is intentionally frozen.
Root cause. The instance was started reading an immutable snapshot — a named, point-in-time copy of key-values — via SelectSnapshot(...). Snapshots are read-only and explicitly do not participate in dynamic refresh; that is their purpose (known-good, roll-back-able config frozen per release). A pinned instance serves the snapshot’s values forever, ignoring every live edit — correct behaviour mistaken for a stuck refresh.
Confirm. Look for a snapshot selection in the bootstrap, and verify the snapshot exists:
# Is the app pinned to a snapshot in code?
grep -rn "SelectSnapshot\|snapshot" ./src
# List snapshots in the store (a pinned instance reads one of these):
az appconfig snapshot list -n appcs-platform-prod \
--query "[].{name:name, status:status, created:created}" -o table
If the bootstrap calls SelectSnapshot(...), the instance is frozen by design — live edits never reach it.
Fix. This is usually intended. To track live keys, switch the bootstrap from SelectSnapshot back to Select(...) with refresh; to change the frozen values, create a new snapshot and roll the deployment to it. Do not edit live keys and expect the pinned instance to follow — it never will.
Live keys vs a pinned snapshot — set expectations correctly:
| Dimension | Live keys (sentinel refresh) | Pinned snapshot |
|---|---|---|
| Dynamic refresh | Yes, on sentinel bump | No — frozen by design |
| Blast radius of a bad edit | Affects all live instances | Pinned instances unaffected |
| How you change values | Edit keys + bump sentinel | Create a new snapshot, redeploy |
| Best for | Fast-moving flags / tunables | Connection-string-grade config per release |
| “Won’t update” is… | A bug to fix | Correct behaviour |
Architecture at a glance
Picture the refresh path as a chain, left to right, because the diagnosis is always “which link broke.” On the left sits the store: your key-values under a label, plus one sentinel you bump last after a change — the hinge of the whole design. In the middle is your app’s provider: at startup it Selects keys by label into IConfiguration and holds a tiny watch on the sentinel. It does not poll your data keys; it issues one cheap conditional GET on the sentinel’s ETag, and only when that ETag moves does it reload the whole registered set atomically (because you registered refreshAll: true).
But the provider only checks when two gates open together: time (the refresh interval must have elapsed) and a driver (a request through UseAzureAppConfiguration() in a web app, or your timer calling TryRefreshAsync() in a worker). Miss the driver and the clock never matters because nothing ever asks. On the right is the consumption point: even a perfectly refreshed IConfiguration is invisible to a class holding an IOptions<T> singleton frozen at boot — only IOptionsMonitor<T>.CurrentValue (or live IConfiguration) sees the new value.
Two side-channels hang off this spine. A Key Vault reference is a pointer the provider dereferences against Key Vault as the app’s identity — a separate hop with its own RBAC that fails silently to an empty string if the identity can’t read the secret. And a snapshot is an off-ramp: a pinned instance reads frozen values and ignores the sentinel entirely. The mental model: a change reaches a running instance only if the store wrote it (right key+label), the sentinel moved, the interval elapsed, a driver fired, and the code read it through a monitoring type.
Real-world scenario
Northwind Logistics runs a shipment-tracking API on Azure App Service (.NET 8, three instances of an S1 Standard plan in Central India), with all tunables and feature flags in a single App Configuration Standard store reached via managed identity. Config-as-code is imported by an Azure DevOps release. The platform team is three engineers; the store bill is about ₹900/month.
The incident was quiet, which made it dangerous. During a carrier outage an engineer raised Carrier:RetryBudget from 3 to 8 to ride out the flakiness, saved it, and watched the dashboards. Retries stayed pinned at 3. They re-edited it, confirmed the portal showed 8, and still nothing changed. The reflex kicked in: restart the instances. All three came back honouring 8 — which “proved” the value was right and deepened the confusion about why the live edit hadn’t taken.
The breakthrough came from walking the chain. First check: az appconfig kv show --key Carrier:RetryBudget --label prod confirmed the value was genuinely 8 in the store. Second check: az appconfig kv show --key Sentinel --label prod showed the sentinel’s lastModified was two weeks old — nobody had bumped it. The team edited keys directly in the portal during incidents while relying on the release pipeline to bump the sentinel on deploys, so out-of-band edits never triggered refresh. Bug one: an un-bumped sentinel, so the conditional GET kept returning “not modified.”
Fixing the sentinel revealed bug two. They bumped Sentinel and watched: logs now printed Carrier:RetryBudget=8 on refresh — yet the retry behaviour still used 3. A grep for IOptions< found the offender: RetryPolicy injected IOptions<CarrierOptions>, a singleton frozen at startup. They switched it to IOptionsMonitor<CarrierOptions> and read .CurrentValue where the retry budget was needed. After that one-line change and a sentinel bump, a live edit took effect across all three instances within the 30-second interval — no restart.
The durable fix was two parts: a wrapper script that writes the key and bumps the sentinel (so no manual edit can forget), and IOptionsMonitor<T> replacing the frozen IOptions<T> in the three classes reading live tunables — plus an Application Insights trace per refresh to see propagation. The lesson on the wall: “A config change that ‘didn’t work’ almost always means the sentinel didn’t move or the code froze the value — check those two before you ever restart.”
The incident as a timeline, because the order of moves is the lesson:
| Time | Symptom | Action taken | Effect | What it should have been |
|---|---|---|---|---|
| 14:02 | Raised retry budget to 8; behaviour unchanged | (edit saved) | — | Ask: did the sentinel move? |
| 14:06 | Still 3 | Re-edit; confirm portal shows 8 | No change | Don’t re-edit — check the chain |
| 14:10 | Still 3 | Restart all 3 instances | Now honours 8 | Restart masks the real bug |
| 14:25 | Recurs on next edit | az appconfig kv show Sentinel → 2 weeks old |
Bug one found | This is the first check |
| 14:32 | Logs show 8, behaviour still 3 | grep IOptions< → frozen singleton |
Bug two found | Read via IOptionsMonitor<T> |
| 15:10 | Mitigated | Deploy IOptionsMonitor<T>; bump sentinel |
Live edit takes in <30 s | Correct fix |
| +1 week | Fixed | Wrapper script (key + sentinel); AI refresh trace | No manual edit can forget | The durable fix |
Advantages and disadvantages
The pull-based, sentinel-driven refresh model is what makes App Configuration cheap, race-free and predictable — and it is also exactly why “I changed it and nothing happened” is so common. Weigh it honestly:
| Advantages (why the model is good) | Disadvantages (why it bites) |
|---|---|
| Polling is cheap — one conditional GET on the sentinel per interval, not per key | Nothing is pushed, so a change is invisible until the next poll — silence is the default |
| The sentinel gives atomic batch refresh — readers never see a half-applied set | You must remember to bump the sentinel last; a forgotten bump = no refresh |
| The interval bounds request cost and is tunable per app | Up to one interval of staleness, out of phase across instances — looks “unreliable” |
IOptionsMonitor<T> makes live values a first-class pattern |
The default-feeling IOptions<T> freezes at startup — a silent, common false alarm |
| Key Vault references keep secret material out of config entirely | A failed reference resolves to an empty string with no error — silent and dangerous |
| Snapshots give frozen, roll-back-able config per release | A pinned instance ignores all live edits — correct behaviour mistaken for a stuck app |
| Flags refresh independently on their own cadence | That independence means a sentinel bump does nothing for flags — a separate track to wire |
The model is right for almost every app that wants centralised, race-free, restart-free configuration. It bites hardest on teams new to the pattern (who forget the sentinel or the middleware), multi-instance fleets (where one stale instance hides), and anyone who reaches for IOptions<T> by reflex. Every disadvantage is manageable once you know it exists — the entire point of keeping this playbook open.
Hands-on lab
Reproduce the two most common “config won’t update” causes — an un-bumped sentinel and a wrong-label edit — then fix them, all on the free App Configuration tier. We use az in Cloud Shell (Bash) so you see the mechanics without writing a full app.
Step 1 — Variables and a free store.
RG=rg-appcs-lab
LOC=centralindia
STORE=appcs-lab-$RANDOM # globally-unique
az group create -n $RG -l $LOC -o table
az appconfig create -n $STORE -g $RG -l $LOC --sku Free -o table
Expected: a store row with sku.name = Free.
Step 2 — Seed a data key and a sentinel under one label.
az appconfig kv set -n $STORE --key "App:Timeout" --value "30" --label prod --yes -o table
az appconfig kv set -n $STORE --key "Sentinel" --value "v1" --label prod --yes -o table
Step 3 — Record the sentinel’s ETag (this is what a provider watches).
az appconfig kv show -n $STORE --key "Sentinel" --label prod \
--query "{value:value, etag:etag}" -o jsonc
# Note the etag — a real app's conditional GET compares against this.
Step 4 — Change the data key only (reproduce the un-bumped-sentinel bug).
az appconfig kv set -n $STORE --key "App:Timeout" --value "45" --label prod --yes -o table
az appconfig kv show -n $STORE --key "Sentinel" --label prod --query "{value:value, etag:etag}" -o jsonc
Expected: the sentinel’s ETag is unchanged even though App:Timeout is now 45. A watching provider sees “sentinel not modified” and never reloads — the bug, reproduced: the value changed but the trigger did not.
Step 5 — Bump the sentinel and watch the ETag move (the fix).
az appconfig kv set -n $STORE --key "Sentinel" --value "v2" --label prod --yes -o table
az appconfig kv show -n $STORE --key "Sentinel" --label prod --query "{value:value, etag:etag}" -o jsonc
Expected: a new ETag. A provider’s next conditional GET now returns “modified,” and refreshAll: true reloads the whole set — App:Timeout=45 propagates.
Step 6 — Prove the label trap. Edit the wrong label and confirm the prod app would see nothing:
az appconfig kv set -n $STORE --key "App:Timeout" --value "99" --label dev --yes -o table
az appconfig kv show -n $STORE --key "App:Timeout" --label prod --query value -o tsv # still 45
Expected: the prod value is still 45; the dev edit is invisible to an app loading prod — “I changed it and nothing happened,” caused by the wrong label.
Validation checklist. You reproduced two “config won’t update” causes without an app — an un-bumped sentinel (Step 4) and a wrong-label edit (Step 6) — and watched the sentinel’s ETag stay put then move. The steps mapped to what each proves:
| Step | What you did | What it proves | Real-world analogue |
|---|---|---|---|
| 4 | Change key, not sentinel | The data key is not the trigger | Manual portal edit during an incident |
| 5 | Bump the sentinel | The ETag move is what fires refresh | The correct edit + bump sequence |
| 6 | Edit the wrong label | Wrong-label edits are invisible | Editing dev while the app loads prod |
Cleanup.
az group delete -n $RG --yes --no-wait
Cost note. The Free App Configuration tier has no hourly charge (it is request- and key-limited, not time-billed), so this lab is effectively ₹0; deleting the resource group removes the store cleanly.
Common mistakes & troubleshooting
This is the playbook — the part you bookmark. A scannable table you can read mid-incident, with the confirm command on every row. Each is a real “config didn’t update” cause, from the simple (forgot the sentinel) to the subtle (a Key Vault reference resolving empty).
| # | Symptom | Root cause | Confirm (exact cmd / code check) | Fix |
|---|---|---|---|---|
| 1 | Changed a key; no instance ever updates; restart fixes it | Refresh registered but never driven | grep -rn "UseAzureAppConfiguration" ./src (missing) |
Add app.UseAzureAppConfiguration() (web) or a timer calling TryRefreshAsync() (worker) |
| 2 | Edited one key; app ignores it; a past batch worked | Sentinel not bumped after the edit | az appconfig kv show --key Sentinel --label L — lastModified older than the key’s |
Bump the sentinel last: az appconfig kv set --key Sentinel --value vN |
| 3 | It updates, but ~30 s late and unevenly across instances | Refresh interval + per-instance phase (default 30 s) | grep -rn "SetRefreshInterval" ./src; lag never exceeds the interval |
Expected; lower SetRefreshInterval if you need faster (cost trade) |
| 4 | Logs show the new value but the service uses the old one | IOptions<T> singleton frozen at startup |
grep -rn "IOptions<" ./src on the stale class |
Use IOptionsMonitor<T> and read .CurrentValue at point of use |
| 5 | Captured CurrentValue once; still frozen |
.CurrentValue read in the constructor of a singleton |
Inspect the ctor — value cached to a field | Read .CurrentValue each use, or subscribe via OnChange |
| 6 | Data keys refresh; a feature flag won’t flip | Flags use a separate UseFeatureFlags refresh |
grep -rn "UseFeatureFlags" ./src (missing/no interval) |
Add UseFeatureFlags(ff => ff.SetRefreshInterval(...)) + AddFeatureManagement() |
| 7 | A secret-backed setting is an empty string | Key Vault reference failed (RBAC/firewall/missing) | az appconfig kv show --key K --query contentType = ...keyvaultref+json; az role assignment list |
Grant identity Key Vault Secrets User; fix firewall/secret; bump sentinel |
| 8 | Rotated a secret; app keeps the old value | Reference not re-resolved (no sentinel bump) | Same as #7 + sentinel lastModified stale |
Bump the sentinel to re-resolve references |
| 9 | App loads but a key is just missing/default | Label/selector mismatch — wrong label loaded | az appconfig kv list --label L --query "[].key"; check Select(labelFilter:) |
Edit the right label; align Select filter to the app’s label |
| 10 | One instance (or deployment) never updates, others do | Pinned to an immutable snapshot | grep -rn "SelectSnapshot" ./src; az appconfig snapshot list |
Intended; switch to Select(...) or roll to a new snapshot |
| 11 | Refresh stopped after a burst of edits/short interval | 429 Too Many Requests throttling the provider | App Insights dependency to *.azconfig.io with 429s; store request metrics |
Raise SetRefreshInterval; reduce edit/poll frequency; Standard SKU |
| 12 | Refresh silently stops; app can’t reach the store | Network/DNS — private endpoint without linked DNS zone | nslookup <store>.azconfig.io returns public IP; provider connect errors |
Link privatelink.azconfig.io private DNS zone; allow the subnet |
| 13 | “But I have permission!” — app can’t even load keys | Contributor ≠ data plane (Data Reader) |
az role assignment list --scope <storeId> lacks App Configuration Data Reader |
Assign App Configuration Data Reader to the app identity |
| 14 | Everything wired, but an idle app never refreshes | No traffic drives the web middleware | No requests in the window; IOptionsMonitor unchanged |
Send traffic / health-ping; or use a worker timer that always ticks |
Rows 1, 2, 4 and 7 each have a full symptom section above (missing middleware, un-bumped sentinel, IOptions<T>, failed Key Vault reference). Two rows deserve a closing word because they bite hardest in a busy store: row 11 is 429 Too Many Requests — the provider’s conditional GETs exceed the store’s request quota (a low interval across a large fleet, or a tight edit loop; the Free SKU’s quota is small), so raise the interval and move to Standard. Row 13 is the Contributor ≠ data plane trap — managing the store does not grant reading its keys (App Configuration Data Reader), so the provider loads nothing until you assign the data-plane role.
Error and status reference
The explicit errors and signals you see when config won’t update, and what each means:
| Code / signal | Where it shows | Means | Likely cause | First fix |
|---|---|---|---|---|
| 403 Forbidden (load) | Provider startup / az appconfig kv list |
Identity lacks data-plane read | Contributor only, no Data Reader |
Assign App Configuration Data Reader |
| 429 Too Many Requests | Provider dependency calls | Request quota exceeded | Interval too low / edit loop / Free SKU | Raise interval; Standard SKU |
| Empty string value | The app’s setting at runtime | Key Vault reference didn’t resolve | Missing Key Vault Secrets User / firewall / secret |
Grant role; fix vault access; bump sentinel |
...keyvaultref+json content type |
az appconfig kv show --query contentType |
The key is a Key Vault reference | (informational) | If empty, treat as the row above |
| Sentinel ETag unchanged | az appconfig kv show --key Sentinel |
The trigger never moved | Forgot to bump after edit | Bump the sentinel last |
| “not modified” (304) | Provider conditional GET | Sentinel hasn’t changed | Working as intended (no change) | Only act if you did change something |
| No new value while idle | Web app with no traffic | Middleware never driven | Zero requests in the window | Send traffic; or worker timer |
| DNS returns public IP | nslookup <store>.azconfig.io |
Private endpoint DNS not linked | Missing privatelink.azconfig.io zone link |
Link the private DNS zone |
Best practices
- Bump the sentinel last, every time, even for one-key changes — wrap edits in a script (write key → write sentinel) so no manual or pipeline edit can forget.
- Read live values through
IOptionsMonitor<T>.CurrentValueat the point of use — neverIOptions<T>, and never cacheCurrentValuein a singleton constructor. This one rule prevents the most common false “refresh is broken.” - Always add
app.UseAzureAppConfiguration()(web) or a timer drivingTryRefreshAsync()(worker). Registration without a driver silently never refreshes. - Register the sentinel with
refreshAll: trueso a single bump reloads the whole set atomically — readers never see a half-applied batch. - Wire feature-flag refresh separately with
UseFeatureFlags(ff => ff.SetRefreshInterval(...)); a data-key sentinel bump does not flip flags. - Pin the refresh interval deliberately (default 30 s) — lower only when a change must propagate fast, weighing request-cost and 429 risk across the fleet.
- Keep sentinel and data keys on the same label, or a wrong-label sentinel bump triggers nothing the app watches.
- Grant data-plane roles explicitly:
App Configuration Data Readerfor the app,Key Vault Secrets Userfor references.Contributoris not enough for either. - Treat an empty secret-backed value as a reference failure, not a stale value — check the content type and vault RBAC first.
- Emit a trace/metric on each refresh (per
cloud_RoleInstance) so you see propagation across the fleet instead of guessing. - Use snapshots for config frozen per release — and remember pinned instances do not refresh, so don’t edit live keys expecting them to follow.
- Fail fast at startup if config can’t load — a process that can’t read its config should not pass readiness and take traffic.
Security notes
The refresh path touches two data planes (App Configuration and Key Vault), so least-privilege spans both. Grant the app’s identity exactly App Configuration Data Reader — read-only is all a running app needs; reserve Data Owner for the release pipeline that writes keys and bumps the sentinel. Never give an app Contributor as a shortcut; it over-permissions the control plane and still doesn’t grant key reads. Use a managed identity rather than a connection-string access key so there is no secret to leak — the system-assigned vs user-assigned managed identity patterns guide covers the choice.
For Key Vault references, the app identity needs only Key Vault Secrets User — not Contributor on the vault, not key or certificate permissions. Keep the secret value in Key Vault and only the pointer in App Configuration, so secret material never lands in config exports, snapshots, or revision history — all of which are readable to anyone with data-plane read. For production stores, lock the data plane with a private endpoint on privatelink.azconfig.io and disable public access; if you do, link the private DNS zone or the provider silently fails to a public IP it can’t reach.
Cost & sizing
App Configuration is one of the cheapest services in Azure. The Free tier has no hourly charge but a low daily request quota (~1,000/day) that a real multi-instance app polling a sentinel will blow through — surfacing as 429 and “refresh stopped.” The Standard tier carries a small per-store charge (roughly ₹100–150/month) plus a generous request allowance, and unlocks snapshots, private endpoints, and geo-replication. Past a prototype, run Standard.
The bill driver this topic controls directly is request volume, and the refresh interval is the throttle: each instance issues ~one sentinel conditional GET per interval. Across N instances at interval I seconds, that is roughly N × (60/I) checks per minute — drop I from 30 s to 1 s on a 10-instance fleet and you go from ~20 to ~600 checks/minute, a 30× jump for a barely-perceptible latency gain and a fast route to throttling. So the interval is a cost lever as much as a latency lever: keep it at 30 s unless a change genuinely must land faster.
What drives the App Configuration bill, and how to keep it trivial:
| Cost driver | What it is | How to control it | Typical impact |
|---|---|---|---|
| Per-store hourly (Standard) | Fixed charge per store | One store + labels, not a store per environment | ~₹100–150/mo per store |
| Request volume | Loads + sentinel checks | Sensible refresh interval; don’t over-poll | Tiny unless interval is too low |
| Replicas (geo-replication) | Each replica adds quota + cost | Add only for customer-facing HA | Per-replica monthly + requests |
| Snapshots | Stored point-in-time copies | Retain what you need; prune old | Minimal |
| Key Vault reference lookups | Secret fetches on resolve/refresh | Fewer references; bump sentinel only when needed | Key Vault request cost, minor |
Right-sized — one Standard store, labels instead of a store per environment, a 30 s interval — a real app’s config plane costs about the price of a coffee per month.
Interview & exam questions
Q1. Why does changing a value in App Configuration not immediately update a running app?
Refresh is pull-based — nothing is pushed. The SDK polls a watched sentinel key at most once per refresh interval (default 30 s), only when something drives the refresher (request through UseAzureAppConfiguration(), or a timer). So a change is invisible until the next poll notices a sentinel ETag change. Relevant to AZ-204.
Q2. What is a sentinel key and why bump it last?
A sentinel is one watched key whose ETag change triggers a reload of the entire registered key set (with refreshAll: true). Bumping it last, after writing your batch, guarantees the provider sees the trigger only once all values are written — giving an atomic, race-free refresh rather than a half-applied state.
Q3. An app’s config logs show the new value but its behaviour doesn’t change. Why?
The consuming class almost certainly injected IOptions<T>, a singleton captured once at startup that never re-reads. The configuration root refreshed but the frozen object did not. Fix: inject IOptionsMonitor<T> and read .CurrentValue at the point of use.
Q4. What actually “drives” dynamic refresh?
In a web app, the app.UseAzureAppConfiguration() middleware checks the sentinel on incoming requests. In a worker, you inject IConfigurationRefresherProvider and call TryRefreshAsync() on a timer. Registration via ConfigureRefresh alone does nothing — without a driver, refresh never fires.
Q5. A Key Vault reference resolves to an empty string. What are the likely causes?
The app’s managed identity lacks Key Vault Secrets User, the Key Vault firewall blocks the app, the secret doesn’t exist, or the reference URI is wrong. Critically, the failure is silent — an empty string, no exception. Confirm via the key’s content type (...keyvaultref+json) and the identity’s vault RBAC.
Q6. Feature flags won’t flip but data keys refresh fine. Why?
Feature flags refresh through a separate registration (UseFeatureFlags with its own SetRefreshInterval), independent of the data-key sentinel. A sentinel bump on data keys does nothing for flags; you must wire flag refresh separately.
Q7. What’s the difference between control-plane and data-plane access on App Configuration?
Control plane (Contributor) manages the store — create, delete, configure. Data plane (App Configuration Data Reader/Data Owner) reads and writes keys. Contributor does not grant key access, which is the most common “but I have permission!” error.
Q8. How does the refresh interval trade off, and what’s the danger of setting it very low? Lower interval = faster propagation but more requests (each instance does ~one sentinel check per interval). Setting it to single-digit seconds across a large fleet multiplies request volume toward the store’s quota and risks 429 Too Many Requests, which stops refresh — for a latency gain users barely notice.
Q9. You edited a key and nothing happened. What are the first two checks?
(1) Did the sentinel’s ETag change? (az appconfig kv show --key Sentinel) — if not, you forgot to bump it. (2) Is the value consumed via IOptionsMonitor<T>, not IOptions<T>? Those two cover the large majority of “config won’t update” tickets — check them before ever restarting.
Q10. Why might an idle web app never pick up a config change at all?
The UseAzureAppConfiguration() middleware only runs when a request flows through it. With zero traffic, nothing drives the refresher, so even a correctly-bumped sentinel is never noticed. Send synthetic traffic (a health probe) or move refresh to a worker timer calling TryRefreshAsync().
Quick check
- You changed a data key in the portal but the app keeps serving the old value. What is the first thing to verify in the store?
- Your refresh logs print the new value, yet a service class behaves on the old one. What’s the most likely code-level cause, and the fix?
- What single piece of middleware (or worker mechanism) actually drives dynamic refresh, and what happens if it’s missing?
- A secret-backed setting comes through empty with no error. What does its content type tell you, and what RBAC role does the app identity need?
- You set
SetRefreshIntervalto 1 second across a 12-instance fleet and refresh starts failing intermittently. What error are you likely hitting, and what’s the fix?
Answers
- Whether the sentinel key’s ETag/
lastModifiedactually changed (az appconfig kv show --key Sentinel --label <label>). If it didn’t move, you edited the data key but never bumped the sentinel, so nothing triggers a reload. - The class injected
IOptions<T>, a startup-frozen singleton. Switch it toIOptionsMonitor<T>and read.CurrentValueat the point of use (not cached in the constructor). app.UseAzureAppConfiguration()in a web app (or a timer callingTryRefreshAsync()in a worker). If it’s missing, refresh is registered but never executes — the app updates only on restart.- The content type
application/vnd.microsoft.appconfig.keyvaultref+jsontells you it’s a Key Vault reference, so the empty value means the reference failed to resolve. The app’s identity needsKey Vault Secrets Useron the vault (plus a working firewall and an existing secret). - 429 Too Many Requests — the interval is too low for the fleet size, exceeding the store’s request quota. Raise
SetRefreshIntervalback toward 30 s and/or move to the Standard SKU for a much higher quota.
Glossary
- Pull-based refresh — the SDK polls; nothing is pushed. Silence is the default until a poll notices a change.
- Sentinel key — a single watched key you bump last after a batch; its ETag change triggers a reload of all registered keys.
- ETag — a version stamp on a key-value; the provider’s conditional GET on the sentinel reloads only when it differs.
refreshAll—Register("Sentinel", refreshAll: true); reloads the entire registered key set on a sentinel change (atomic batch).- Refresh interval — minimum time between sentinel checks (
SetRefreshInterval, default 30 s); bounds staleness and request cost. - Drives refresh — what actually calls the refresher:
app.UseAzureAppConfiguration()(web) or a timer callingTryRefreshAsync()(worker). IOptions<T>— a singleton resolved once at startup; never sees a refresh.IOptionsMonitor<T>— re-evaluates on each.CurrentValueaccess; the correct type for refreshed values.IOptionsSnapshot<T>— resolved once per scope (per HTTP request); for per-request consistency.- Key Vault reference — a key pointing to a Key Vault secret (content type
...keyvaultref+json), resolved as the app’s identity; fails silently to an empty string. - Snapshot — an immutable, named copy of key-values; a pinned instance reads it and does not refresh.
- Label — a variant selector on a key (
prod/dev); the provider loads one label, so editing the wrong label is invisible. - Control plane vs data plane — managing the store (
Contributor) vs reading its keys (App Configuration Data Reader); the former does not grant the latter. - Feature flag — a key under
.appconfig.featureflag/, refreshed viaUseFeatureFlagson its own interval, independent of the data-key sentinel.
Next steps
- Build the refresh wiring from scratch (sentinel,
ConfigureRefresh, Key Vault references, snapshots, the full bootstrap): Azure App Configuration production playbook — dynamic refresh, feature flags, Key Vault references, snapshots. - Solidify the foundation (keys, labels, references, snapshots): Azure App Configuration getting started — keys, labels and references.
- Debug the Key Vault side when a reference can’t resolve: Key Vault 403 Forbidden — firewall, RBAC and soft-delete recovery.
- Get the identity behind references right: System-assigned vs user-assigned managed identity patterns.
- See propagation per instance with proper telemetry: Azure Monitor and Application Insights for observability.