Martin Rylko
  • Services
  • Blog
  • About
  • Contact
  • Get in Touch
Martin Rylko

Senior Cloud Architect & DevOps Engineer. Specializing in Microsoft Azure, IaC, Cloud Security and AI.

Navigation

  • Services
  • Blog
  • About
  • Contact

Collaboration

Looking for an experienced architect for your Azure project? Get in touch.

rylko@cloudmasters.cz

© 2026 Martin Rylko. All rights reserved.

Built in the cloud. Deployed via Azure Static Web Apps.

Home/Blog/Bicep Deployment Stacks: Lifecycle Management Without Manual Cleanup
All articlesČíst česky

Bicep Deployment Stacks: Lifecycle Management Without Manual Cleanup

4/28/2026 4 min
#Bicep#Azure#IaC#DevOps#Deployment Stacks

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 denyDelete

Three Key Properties of a Deployment Stack

PropertyWhat it doesDefault
Managed resources trackingPersistent list of every resource the stack createdAuto-managed
actionOnUnmanageWhat to do with resources removed from the templatedetachResources (safe)
denySettingsCreates a denyAssignment that blocks manual changesnone

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 deleteAll

My 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 first

On 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 detachResources

CI/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

  1. DenyAssignments block approved operations too – if the Bicep template contains a roleAssignment that already exists, the deploy fails with RoleAssignmentExists. Either add the principal to the excluded list or use --deny-settings-excluded-actions
  2. 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
  3. detachResources without 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.

Tags:#Bicep#Azure#IaC#DevOps#Deployment Stacks
LinkedInX / Twitter

About the author

Martin Rylko

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.

Email LinkedInFull profile

Frequently Asked Questions

What is a Deployment Stack and how is it different from a classic ARM/Bicep deployment?▾
A classic deployment is a one-shot operation – it deploys resources and Azure then "forgets" which deployment created them. A Deployment Stack maintains persistent state with a reference to every resource it deployed – so when you remove a resource from the template, the stack will delete it (or detach it depending on the action) on the next update. Plus it optionally adds denyAssignments that block manual changes outside IaC.
When should I pick a Deployment Stack over a classic deployment?▾
Whenever resources form a logical unit with a shared lifecycle – e.g. a whole Landing Zone, a whole microservice (Function App + Storage + KV + role assignments), or a whole ephemeral test environment. For one-off operations (creating a single resource group, a single resource update) stacks are overhead. At Christie's we converted all application deployments to stacks; shared platform modules stayed on classic deployments.
What does actionOnUnmanage do and how should I set it?▾
actionOnUnmanage decides what happens to resources that are in the stack but no longer in the template. Three options: deleteResources (delete resources), detachResources (keep resources, just remove them from the stack), deleteAll (delete resources including the RG). For production stacks I recommend deleteResources with manual review – never deleteAll, because a single bad PR can wipe out the whole RG.
Can I migrate existing resources into a Deployment Stack without recreate?▾
Yes, since September 2024 Azure supports "adoption" – existing resources are added to the stack via a redeploy with the same parameters. The stack registers them as managed without recreate. Caveat: if a resource has a lock or a manually added tag not in the template, adoption will not see it. Always run what-if first and verify the output.

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.

Read

Bicep 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.

Read

Terraform 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