Zero-downtime deployment
Deployment slots and health-aware routing.
A theater with a revolving stage never makes the audience wait. While one scene plays out front, the crew builds the next one on the hidden half: full set, lights checked, actors already standing where they need to stand. The scene change is a rotation that takes seconds, and if something is wrong you rotate straight back. Azure App Service deployment slots work like that. The new version of your app runs, fully started and tested, on a hidden copy of production, and the release itself is a routing flip called a slot swap, not a redeploy. Users never see downtime, and rollback is that same flip run again.
Terms first. *App Service* is Azure's managed platform for web apps and APIs (application programming interfaces, the endpoints other software calls). You hand it code; it runs and patches the servers. An *App Service plan* is the pool of virtual machines underneath, the capacity you actually pay for. A *deployment slot* is a second live copy of your app on that same plan, named staging by convention, with its own hostname (contoso-web-staging.azurewebsites.net), its own configuration, and its own address you can aim tests at. Production is itself a slot. A swap exchanges what App Service's front-end routers point at, plus a defined subset of the configuration.
Create a slot and deploy to it
Slots start at the Standard tier: 5 on Standard, 20 on Premium v3 and Isolated v2, none on Free or Basic. The fine print that catches people is that slots share the plan's compute. Your staging slot runs on the same virtual machines serving live customers right now, so a heavy pre-swap load test on an undersized plan can slow down the very service you are trying to protect. Create the slot by cloning production's configuration, then deploy the new build into it while production keeps serving.
# Create a staging slot, cloning config from productionaz webapp deployment slot create \--resource-group app-rg --name contoso-web \--slot staging --configuration-source contoso-web# Deploy the new build to the slot (production untouched)az webapp deploy --resource-group app-rg --name contoso-web \--slot staging --src-path app.zip --type zip# Initiating deployment# Deploying from local path: app.zip# Polling the status of sync deployment. Start Time: 2026-07-14 09:14:03# Status: Site started successfully. Time: 41(s)# Deployment has completed successfully# Smoke-test the slot directly — real environment, zero production trafficcurl -s https://contoso-web-staging.azurewebsites.net/healthz# {"status":"ok","version":"2.4.1","db":"connected"}
That curl line is the whole point of the exercise. The slot is as close to production as you can get without being production: same plan, same region, same runtime. Close is not identical, though, and networking is where the gap shows. Creating a slot does not clone the private networking: virtual network integration is one of the settings that never swaps, and a private endpoint created for production does not cover the slot. So where the database only answers inside the virtual network, a fresh staging slot cannot reach it until you integrate that slot as well. Do that first, and configuration bugs that only appear under real conditions surface here, on a hostname no customer is using. A build agent can run your tests, but it can never tell you that.
Sticky settings: what swaps and what stays
A swap carries your configuration along with your code. App settings, connection strings and general settings all travel by default, so staging's values become production's. A few things *never* travel: custom domains, TLS (Transport Layer Security, the certificate machinery behind https) bindings, scale rules, IP restrictions, and, for security, managed identities (the built-in Azure login your app uses in place of a stored password). Each slot keeps its own identity, so code that lands in production runs as the *production slot's* identity. Grant Key Vault and database permissions per slot, deliberately. The third category is yours to choose. Settings marked as deployment slot settings, the ones everyone calls sticky, stay attached to the slot name instead of following the code. Stickiness attaches to the setting *name*, stored as slotConfigNames on the site, while each slot holds its own value. Anything environment-shaped (database hosts, Application Insights connection strings, downstream service URLs) has to be sticky, or staging's values ride the swap into production.
Two WEBSITE_* app settings spell out the swap's warm-up contract, which is how App Service asks each about-to-be-production worker whether it is awake yet. WEBSITE_SWAP_WARMUP_PING_PATH names the path it pings on every worker, and the default is /. WEBSITE_SWAP_WARMUP_PING_STATUSES lists the HTTP (HyperText Transfer Protocol) response codes that count as warm. Leave that one unset and *any* answer counts, a 500 server error included. Set both identically on both slots, as regular settings rather than sticky ones.
# Mark environment-shaped settings sticky, with per-slot valuesaz webapp config appsettings set -g app-rg -n contoso-web \--slot staging \--slot-settings DB_HOST=staging-sql.database.windows.net# [# {# "name": "DB_HOST",# "slotSetting": true,# "value": "staging-sql.database.windows.net"# }# ]# Same setting on production (no --slot means the production slot)az webapp config appsettings set -g app-rg -n contoso-web \--slot-settings DB_HOST=prod-sql.database.windows.net# Warm-up contract — regular settings, identical on both slotsaz webapp config appsettings set -g app-rg -n contoso-web --settings \WEBSITE_SWAP_WARMUP_PING_PATH=/healthz \WEBSITE_SWAP_WARMUP_PING_STATUSES=200# Audit: which settings are sticky?az webapp config appsettings list -g app-rg -n contoso-web \--query "[?slotSetting].name" -o tsv# DB_HOST# APPLICATIONINSIGHTS_CONNECTION_STRING
--query "[?slotSetting]" before the first swap of any app. Use swap with preview so you can read the merged configuration before traffic moves. Better still, use Key Vault references resolved by each slot's own managed identity, so raw secrets never sit in app settings at all.Inside the swap
A swap is a choreographed sequence, and the order is what makes it zero-downtime while a restart is not. First, App Service applies the *target* slot's slot-specific configuration (production's sticky settings, plus continuous deployment and authentication settings when those are on) to the *staging* workers, which restart running exactly the configuration they will have in production. Second, it warms them. Every instance gets pinged on the warm-up path (/ by default, or whatever WEBSITE_SWAP_WARMUP_PING_PATH names), and the swap will not move on until every instance answers with a status from your WEBSITE_SWAP_WARMUP_PING_STATUSES list. If any instance fails to restart or warm up, the operation reverts and production never notices. Third, and only then, the front-end routing rules are exchanged, so production traffic instantly lands on processes that are already running and already warm. Fourth, the old production code is now parked in the staging slot, which makes rollback the same swap command again, and it is equally fast. The cold-start problem is solved by that ordering, not by hope.
For a release that makes you nervous, use a swap with preview, which splits the swap in two. Phase one applies production's settings to staging and pauses. You are now testing the exact binary under the exact production configuration, reachable on the staging hostname. Phase two completes the routing flip, and --action reset abandons the whole thing with production untouched. Pair it with App Service health check (healthCheckPath): after the flip, any instance that fails the probe is pulled from rotation and eventually replaced, so one sick virtual machine never keeps serving errors.
# Set the health probe — failing instances leave the rotationaz webapp config set -g app-rg -n contoso-web \--generic-configurations '{"healthCheckPath": "/healthz"}'# Phase 1: apply production config to staging, then pauseaz webapp deployment slot swap -g app-rg -n contoso-web \--slot staging --action preview# (exit 0 — staging restarts with prod config; test it on the staging URL)curl -s https://contoso-web-staging.azurewebsites.net/healthz# {"status":"ok","version":"2.4.1","db":"connected"} <- db is now PROD# Phase 2: complete the flip (or --action reset to abandon)az webapp deployment slot swap -g app-rg -n contoso-web \--slot staging --action swap# (no output; routing flips once every instance passes warm-up — typically 1-3 min)# Rollback = the same command againaz webapp deployment slot swap -g app-rg -n contoso-web --slot staging
The pipeline that ships and swaps
In Azure Pipelines the whole sequence is two tasks. AzureWebApp@1 deploys the package to the slot, and AzureAppServiceManage@0 performs the swap. Target an environment so the flip can sit behind an approval gate; approvals and checks get their own treatment in *Release pipelines & approvals*, and splitting traffic between slots a few percent at a time belongs to *Progressive delivery*. The deeper principle here is build once. The artifact that passed staging validation is byte-identical to what production runs, because the swap promotes the running instance instead of rebuilding anything.
# deploy stage — build stage publishes drop/app.zip- stage: deliverjobs:- deployment: shipenvironment: production # approvals & checks attach herepool:vmImage: ubuntu-lateststrategy:runOnce:deploy:steps:- task: AzureWebApp@1inputs:azureSubscription: sc-contoso-prod # service connectionappType: webAppLinuxappName: contoso-webresourceGroupName: app-rgdeployToSlotOrASE: trueslotName: stagingpackage: $(Pipeline.Workspace)/drop/app.zip- task: AzureAppServiceManage@0inputs:azureSubscription: sc-contoso-prodAction: 'Swap Slots'WebAppName: contoso-webResourceGroupName: app-rgSourceSlot: stagingSwapWithProduction: true
# Trigger and watch the run from the CLI (azure-devops extension)az pipelines run --name contoso-web-cd --branch main \--org https://dev.azure.com/contoso --project web \--query "{id:id, build:buildNumber, status:status}"# {# "build": "20260714.3",# "id": 214,# "status": "notStarted"# }az pipelines runs show --id 214 \--org https://dev.azure.com/contoso --project web \--query "{status:status, result:result, finished:finishTime}"# {# "finished": "2026-07-14T09:31:47.06Z",# "result": "succeeded",# "status": "completed"# }
Slots as code
Slots clicked into existence in the portal drift. Someone changes a setting at 2am during an incident, nobody writes it down, and a month later staging and production quietly disagree. A slot declared in code drifts just as easily, but the drift stops being invisible. Bicep and Terraform are two file formats for describing Azure resources, kept in version control next to your app; both attach the slot to the site it belongs to, and both will tell you before they change anything which settings no longer match the file (terraform plan, or a what-if run for Bicep), so putting the slot back is one re-apply. Terraform goes one step further with a sticky_settings block: the same guarantee as --slot-settings, except a reviewer can see it while the change is still under review. Terraform can even represent the swap itself (azurerm_web_app_active_slot), though most teams keep the swap in the pipeline where it can sit behind an approval, and let infrastructure as code define only the structure.
// Staging slot with health check + warm-up contractresource site 'Microsoft.Web/sites@2024-04-01' existing = {name: 'contoso-web'}resource staging 'Microsoft.Web/sites/slots@2024-04-01' = {parent: sitename: 'staging'location: resourceGroup().locationproperties: {siteConfig: {healthCheckPath: '/healthz'appSettings: [{ name: 'WEBSITE_SWAP_WARMUP_PING_PATH', value: '/healthz' }{ name: 'WEBSITE_SWAP_WARMUP_PING_STATUSES', value: '200' }]}}}
resource "azurerm_linux_web_app_slot" "staging" {name = "staging"app_service_id = azurerm_linux_web_app.main.idsite_config {health_check_path = "/healthz"}app_settings = {DB_HOST = "staging-sql.database.windows.net"}}resource "azurerm_linux_web_app" "main" {# ... name, service_plan_id, site_config ...# Names listed here never follow a swap (slotConfigNames)sticky_settings {app_setting_names = ["DB_HOST","APPLICATIONINSIGHTS_CONNECTION_STRING",]}}
Know where slots stop. They are an App Service feature. Azure Functions inherits them with counts that depend on the plan: two slots including production on Consumption, three on Premium, up to twenty on Dedicated plans, and none at all on the newer Flex Consumption plan. On AKS (Azure Kubernetes Service) the same outcome comes from rolling updates gated by readiness probes, and on plain virtual machines from blue/green behind a load balancer. A swap is also all-or-nothing. 100% of traffic flips at once, so a subtle bug reaches every user in the same second, and gradual exposure through slot traffic-splitting is where *Progressive delivery* picks up. And because slots share compute, size the plan for two live copies of the app during a release window.
One thing about that one-command rollback. Your previous version sits in the staging slot only until the next deploy overwrites it. If the pipeline pushes a fresh build to staging every morning, last night's safety net is gone before anyone has reported a problem. Teams that care about this either hold the next staging deploy until the release has soaked for a day, or keep a second slot parked with the last known good build.
A swap also gives you two copies of the code and still only one database. Both slots point at the same one in most setups, so a schema change has to work for the old version and the new one at the same time, across the swap window and any rollback after it. Add the column, ship the code that writes to it, drop the old column a release later. App Service also offers auto-swap, which fires the moment a deploy finishes, but it exists only for Windows apps, not for Linux ones like the example above or for containers. Even where it is available, most teams prefer an explicit pipeline step with a smoke test in front of it, because a gate you can see is a gate you can stop.
Slots give you a safe door into production. The next question is what you let through it in the first place: scanning the build, its dependencies and its secrets before it ever reaches the staging slot. That is *DevSecOps in the pipeline*, next.
Try this
On a Standard or higher App Service plan, create a staging slot from production's configuration, pin one environment-shaped setting to the slot and check the audit query sees it, then deploy a build you can tell apart from production, smoke-test the staging URL, swap into production and swap back. Time both flips so you know what your rollback actually costs.
# Clone production's configuration into the new slotaz webapp deployment slot create -g rg-lab -n contoso-web --slot staging \--configuration-source contoso-web# Pin the environment-shaped setting to the slot, then confirm it tookaz webapp config appsettings set -g rg-lab -n contoso-web --slot staging \--slot-settings DB_HOST=staging-sql.database.windows.netaz webapp config appsettings list -g rg-lab -n contoso-web \--query "[?slotSetting].name" -o tsv# Deploy, find the staging hostname, smoke-test itaz webapp deploy -g rg-lab -n contoso-web --slot staging --src-path app.zip --type zipaz webapp show -g rg-lab -n contoso-web --slot staging --query defaultHostName -o tsv# Swap in, then swap back (the same command is the rollback)az webapp deployment slot swap -g rg-lab -n contoso-web --slot staging --target-slot productionaz webapp deployment slot swap -g rg-lab -n contoso-web --slot staging --target-slot production
$ az webapp config appsettings list -g rg-lab -n contoso-web --query "[?slotSetting].name" -o tsvDB_HOST$ az webapp show -g rg-lab -n contoso-web --slot staging --query defaultHostName -o tsvcontoso-web-staging.azurewebsites.net$ az webapp deployment slot swap -g rg-lab -n contoso-web --slot staging --target-slot production# Nothing prints. A successful swap says nothing and exits 0, usually after# 1-3 minutes of warm-up. Ask the live site which build it is serving instead:$ curl -s https://contoso-web.azurewebsites.net/healthz{"status":"ok","version":"2.4.1","db":"connected"}
Takeaway
Slots are live, warm copies of your app, and a swap is a routing and configuration flip rather than a deployment. Sticky settings stay put; everything else travels with the code, so decide which pile each setting belongs in before your first swap, not after it.
Two things to do before that first swap: mark every connection string that must not travel as a slot setting, and point WEBSITE_SWAP_WARMUP_PING_PATH at a real health endpoint, so the first request after the flip is not a customer waiting while the runtime compiles code for the first time.
az webapp config appsettings list -g app-rg -n contoso-web --query "[?slotSetting].name" -o tsv and it prints DB_HOST and nothing else. Staging's APPLICATIONINSIGHTS_CONNECTION_STRING points at a staging workspace. You run the swap. What happens to production?WEBSITE_SWAP_WARMUP_PING_PATH=/healthz, and nobody ever set WEBSITE_SWAP_WARMUP_PING_STATUSES. The new build starts, but /healthz answers 500 on every instance. What does the swap do?--action preview. A plain swap has no phase to stall in, and reset applies to a preview you already started.curl https://contoso-web-staging.azurewebsites.net/healthz reports the database unreachable while production reports it connected. The database only answers inside the virtual network. What fixes it?