Bicep Deployment Stacks: Lifecycle Management Without Manual Cleanup
Bicep Deployment Stacks: Lifecycle Management Without Manual Cleanup
A classic Bicep deployment solves one problem: how to deploy resources. It does not solve two others: how to maintain them as a unit over time and how to remove them safely. Deployment Stacks (GA since late 2024) fill that gap, and at Christie's and Nespresso we adopted them in 2026 as the default pattern for application deployments.
This article summarizes when to use them, how to migrate existing environments, and where the traps are.
The Problem Deployment Stacks Solve
A scenario every IaC engineer knows: a team has main.bicep with a Function App, Storage Account, Key Vault, and three role assignments. Six months later the Storage Account is no longer needed in the template, someone removes it from main.bicep, merges the PR, runs az deployment group create. What happens?
The Storage Account stays in Azure. A classic deployment is idempotent only for create/update, not for delete. The resource remains until manually deleted. After a year you have 50 orphan resources someone has to click through.
Deployment Stacks fix this:
# Without a stack – Storage stays in Azure even after removal from the template
az deployment group create \
--resource-group rg-prod \
--template-file main.bicep
# With a stack – Storage is deleted on the next update if it is missing from the template
az stack group create \
--name "stack-app-prod" \
--resource-group rg-prod \
--template-file main.bicep \
--action-on-unmanage deleteResources \
--deny-settings-mode denyDeleteThree Key Properties of a Deployment Stack
| Property | What it does | Default |
|---|---|---|
| Managed resources tracking | Persistent list of every resource the stack created | Auto-managed |
| actionOnUnmanage | What to do with resources removed from the template | detachResources (safe) |
| denySettings | Creates a denyAssignment that blocks manual changes | none |
actionOnUnmanage – Three Options and When to Use Them
# For dev/test – we delete actively
--action-on-unmanage deleteResources
# For production – detach (manual cleanup after review)
--action-on-unmanage detachResources
# For ephemeral environments – delete everything including the RG
--action-on-unmanage deleteAllMy default pattern: dev gets deleteResources, production gets detachResources plus a monitoring alert on detached resources, ephemeral environments get deleteAll.
denySettings – Protection Against Manual Changes
This is the most innovative bit. A stack can create a denyAssignment that blocks specific operations on resources – even for the subscription Owner:
az stack group create \
--name "stack-prod" \
--resource-group rg-prod \
--template-file main.bicep \
--deny-settings-mode denyDelete \
--deny-settings-excluded-principals "<oncall-team-objectId>" \
--deny-settings-excluded-actions "Microsoft.Authorization/roleAssignments/write"What this enforces:
- Nobody (not even an Owner) can delete stack-managed resources via the Portal/CLI
- Exception: the oncall team can during incident response
- Exception: role assignments can be modified outside the stack (e.g. when an Entra ID admin grants a role)
For regulated workloads (banking, healthcare) this is a game changer – it replaces resource locks with far more granular control.
A Real Stack: Application Deployment at Christie's
The pattern we use for every application:
// main.bicep
targetScope = 'resourceGroup'
param appName string
param environment string
param location string = resourceGroup().location
// Storage for app data
module storage 'br:cmnregistry.azurecr.io/bicep/cmn-storage:1.4.3' = {
name: 'storage-${appName}'
params: {
storageAccountName: 'st${appName}${environment}'
location: location
sku: 'Standard_LRS'
}
}
// Function App with VNet integration
module funcApp 'br:cmnregistry.azurecr.io/bicep/cmn-funcapp:2.1.0' = {
name: 'func-${appName}'
params: {
appName: 'func-${appName}-${environment}'
location: location
subnetId: subnetId
storageAccountId: storage.outputs.id
}
}
// Key Vault
module kv 'br:cmnregistry.azurecr.io/bicep/cmn-keyvault:1.2.0' = {
name: 'kv-${appName}'
params: {
name: 'kv-${appName}-${environment}'
location: location
enableRbacAuthorization: true
}
}
// Role assignment – Function MI -> KV Secrets User
module kvRole 'br:cmnregistry.azurecr.io/bicep/cmn-roleassignment:1.0.0' = {
name: 'role-${appName}-kv'
params: {
principalId: funcApp.outputs.identityPrincipalId
roleDefinitionId: '4633458b-17de-408a-b874-0445c86b69e6' // KV Secrets User
scope: kv.outputs.id
}
}Deploy via stack:
az stack group create \
--name "stack-${APP_NAME}-${ENV}" \
--resource-group "rg-${APP_NAME}-${ENV}" \
--template-file main.bicep \
--parameters appName=${APP_NAME} environment=${ENV} \
--action-on-unmanage detachResources \
--deny-settings-mode denyDelete \
--deny-settings-excluded-principals "${ONCALL_TEAM_OID}"If someone later removes module storage from main.bicep, the next az stack group create detaches the storage (with a log entry). The operations team sees it in az stack group show under detached resources and can delete it manually.
Migrating an Existing Environment Into a Stack: Adoption
If you already have resources deployed via classic deployments, you do not need to recreate them. The stack supports "adoption":
# Step 1: dry-run, verify what will happen
az stack group validate \
--name "stack-app-prod" \
--resource-group rg-app-prod \
--template-file main.bicep
# Step 2: create the stack with the same parameters as the original deployment
az stack group create \
--name "stack-app-prod" \
--resource-group rg-app-prod \
--template-file main.bicep \
--parameters @prod.parameters.json \
--action-on-unmanage detachResources \
--deny-settings-mode none # first time without deny – verify firstOn the first create Azure registers existing resources as managed by the stack. On the second update with --deny-settings-mode denyDelete you add deny assignments.
Caveat: adoption does not ignore drift. If an existing Storage Account has a different SKU than the template, the stack will update it to the declared SKU. Always run what-if first:
az stack group create \
--name "stack-app-prod" \
--resource-group rg-app-prod \
--template-file main.bicep \
--parameters @prod.parameters.json \
--what-if \
--action-on-unmanage detachResourcesCI/CD Integration: a Stack in GitHub Actions
# .github/workflows/deploy.yml
name: Deploy app stack
on:
push:
branches: [main]
paths: ['infra/**']
permissions:
id-token: write
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v4
- uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: Deploy stack
run: |
az stack group create \
--name "stack-${{ vars.APP_NAME }}-prod" \
--resource-group "rg-${{ vars.APP_NAME }}-prod" \
--template-file infra/main.bicep \
--parameters @infra/prod.parameters.json \
--action-on-unmanage detachResources \
--deny-settings-mode denyDelete \
--deny-settings-excluded-principals "${{ vars.ONCALL_OID }}" \
--yes--yes skips the interactive prompt about destructive changes. In production: always have a preceding step with --what-if and a manual approval gate via Environments.
Three Traps Worth Mentioning
- DenyAssignments block approved operations too – if the Bicep template contains a
roleAssignmentthat already exists, the deploy fails withRoleAssignmentExists. Either add the principal to the excluded list or use--deny-settings-excluded-actions - Stack name is capped at 90 characters – for the convention
stack-${appname}-${env}-${region}you have to watch this. We got burned at Christie's on long feature branch deployments detachResourceswithout monitoring = orphan farm – without an alert on detached resources, they accumulate. At Christie's we have a weekly KQL query that reports detected detached resources into the platform team's Teams channel
Cost: Zero, But Watch Quotas
Deployment Stacks themselves cost nothing – they are part of the ARM service. But: every stack occupies the subscription deployments quota (default 800 per RG). For an environment with 50+ apps in a single RG (which you should not have, but…) this can run out. Fix: one RG per app, not one RG per environment.
Conclusion
Deployment Stacks are a mature technology as of April 2026 that addresses three long-standing pains: orphan resources, drift from manual changes, and destructive cleanup mistakes. Migration cost is low (adoption pattern), benefit is high (denyAssignments + automatic lifecycle).
At Christie's and Nespresso we have been doing every new application deployment via stacks since January 2026. Legacy deployment migration is rolling without recreating production resources.
If you are evaluating a migration to Deployment Stacks in your environment, check out our cloud architecture services or reach out for an IaC review.
About the author

Martin Rylko
Senior Cloud Architect & DevOps Engineer
14+ years in IT – from on-premises datacenters and Hyper-V clustering to cloud infrastructure on Microsoft Azure. I specialize in Landing Zones, IaC automation, Kubernetes and security compliance.
Frequently Asked Questions
What is a Deployment Stack and how is it different from a classic ARM/Bicep deployment?▾
When should I pick a Deployment Stack over a classic deployment?▾
What does actionOnUnmanage do and how should I set it?▾
Can I migrate existing resources into a Deployment Stack without recreate?▾
You might also like
Bicep Shared Modules: Skip-if-Exists Pattern Instead of --force
How to publish Bicep modules to ACR in enterprise environments without risking overwriting existing versions. Skip-if-exists pattern, PR validation, and semver hygiene.
ReadBicep CI/CD: GitHub Actions Pipeline for Azure
Build a production Bicep deployment pipeline with GitHub Actions. Covers what-if previews, environment approvals, OIDC authentication, and rollback strategies.
ReadTerraform Azure Modules: Private Registry and Testing
Build reusable Terraform modules for Azure with private registry publishing, automated testing with Terratest, and versioned module consumption in production.
Read