Zero-downtime deployment

Deployment slots and health-aware routing.

Advanced25 min · lesson 12 of 15

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.

slot-create-deploy.sh
# Create a staging slot, cloning config from production
az 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 traffic
curl -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.

sticky-settings.sh
# Mark environment-shaped settings sticky, with per-slot values
az 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 slots
az 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
Secrets you forget to mark sticky ride into production
If a staging connection string is not marked as a slot setting, the swap carries it across with the code. Your live site quietly starts writing to the staging database, losing real data on every request, while production's credentials land in the staging slot, where more of the team has access and the network rules are looser. Audit with --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.

swap-preview.sh
# Set the health probe — failing instances leave the rotation
az webapp config set -g app-rg -n contoso-web \
--generic-configurations '{"healthCheckPath": "/healthz"}'
# Phase 1: apply production config to staging, then pause
az 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 again
az webapp deployment slot swap -g app-rg -n contoso-web --slot staging
Anatomy of a slot swap
1Deploy to staging slot
prod keeps serving
2Prod config applied
workers restart with sticky settings
3Warm-up pings
every instance must pass
4Routing flip
instant, zero cold starts
5Old build parked
swap again = rollback
The flip happens only after every production-bound worker has restarted with production configuration and passed warm-up. That ordering is the zero-downtime mechanism.

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.

azure-pipelines.yml
# deploy stage — build stage publishes drop/app.zip
- stage: deliver
jobs:
- deployment: ship
environment: production # approvals & checks attach here
pool:
vmImage: ubuntu-latest
strategy:
runOnce:
deploy:
steps:
- task: AzureWebApp@1
inputs:
azureSubscription: sc-contoso-prod # service connection
appType: webAppLinux
appName: contoso-web
resourceGroupName: app-rg
deployToSlotOrASE: true
slotName: staging
package: $(Pipeline.Workspace)/drop/app.zip
- task: AzureAppServiceManage@0
inputs:
azureSubscription: sc-contoso-prod
Action: 'Swap Slots'
WebAppName: contoso-web
ResourceGroupName: app-rg
SourceSlot: staging
SwapWithProduction: true
run-pipeline.sh
# 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.

slot.bicep
// Staging slot with health check + warm-up contract
resource site 'Microsoft.Web/sites@2024-04-01' existing = {
name: 'contoso-web'
}
resource staging 'Microsoft.Web/sites/slots@2024-04-01' = {
parent: site
name: 'staging'
location: resourceGroup().location
properties: {
siteConfig: {
healthCheckPath: '/healthz'
appSettings: [
{ name: 'WEBSITE_SWAP_WARMUP_PING_PATH', value: '/healthz' }
{ name: 'WEBSITE_SWAP_WARMUP_PING_STATUSES', value: '200' }
]
}
}
}
slots.tf
resource "azurerm_linux_web_app_slot" "staging" {
name = "staging"
app_service_id = azurerm_linux_web_app.main.id
site_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.

terminal
# Clone production's configuration into the new slot
az 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 took
az webapp config appsettings set -g rg-lab -n contoso-web --slot staging \
--slot-settings DB_HOST=staging-sql.database.windows.net
az webapp config appsettings list -g rg-lab -n contoso-web \
--query "[?slotSetting].name" -o tsv
# Deploy, find the staging hostname, smoke-test it
az webapp deploy -g rg-lab -n contoso-web --slot staging --src-path app.zip --type zip
az 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 production
az webapp deployment slot swap -g rg-lab -n contoso-web --slot staging --target-slot production
output
$ az webapp config appsettings list -g rg-lab -n contoso-web --query "[?slotSetting].name" -o tsv
DB_HOST
$ az webapp show -g rg-lab -n contoso-web --slot staging --query defaultHostName -o tsv
contoso-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.

Quick check
01Before the first swap you audit with 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?
Incorrect — Only the names recorded in slotConfigNames stay behind. Everything else, connection strings included, travels with the code when the slots exchange places.
Incorrect — App Service never compares the two slots' values. The swap runs happily, and the difference is exactly what does the damage.
Correct — The audit is telling you what is missing, not what is there: only DB_HOST is pinned to its slot, so the telemetry endpoint follows the code into production.
Incorrect — Each slot really does keep its own identity, but identity decides what your code can log into, not which app settings follow the code.
02Both slots carry 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?
Correct — Warm-up only enforces the codes you name, so an unset list turns the check into a formality. Set it to 200 on both slots.
Incorrect — Warm-up has no opinion of its own about status codes. It waves through whatever comes back unless you list the ones that count.
Incorrect — A pause happens only when you ask for one with --action preview. A plain swap has no phase to stall in, and reset applies to a preview you already started.
Incorrect — Health check runs after the routing flip, pulling sick instances out of rotation once traffic is already on them. It is not a gate in front of the swap.
03Your build deploys to the staging slot and the site starts, but 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?
Incorrect — A private endpoint created for production does not extend to the slot, so borrowing the name changes nothing about who is allowed to reach it.
Incorrect — Stickiness decides which values travel during a swap. It has no bearing on whether the slot can open a connection right now.
Incorrect — Slots always share the plan's compute, so a bigger plan buys headroom for load tests and no path at all into a private network.
Correct — Cloning the configuration source copies settings, not private networking, so the slot starts life outside the network until you wire it in yourself.

Related