---
title: "Automate Azure Cleanup with Bicep Deployment Stacks"
description: "Build a Bicep deployment stack for a four-resource lab, preview the delete before it runs, tear it all down, and prove the spending stopped."
canonical: "https://adamtheautomator.com/azure-bicep-deployment-stack-cleanup/"
---

# Automate Azure Cleanup with Bicep Deployment Stacks

> Build a Bicep deployment stack for a four-resource lab, preview the delete before it runs, tear it all down, and prove the spending stopped.

Source: https://adamtheautomator.com/azure-bicep-deployment-stack-cleanup/

---

ATA Learning

Tap to hide

[

ATA Learning

](/)

*   [Home](/)
*   [Tutorials](/tutorials/)
*   [Instructors](/author/)
*   [Advertising](/advertising/)
*   [Recommended Resources](/resources/)
*   [About Adam](/about-adam/)

Search for:  

*   [](https://twitter.com/adbertram)
*   [](https://github.com/Adam-the-Automator)
*   [](https://www.linkedin.com/company/adam-the-automator-llc)
*   [](/feed/)

![Automate Azure Cleanup with Bicep Deployment Stacks](https://adamtheautomator.com/wp-content/uploads/publisher/4065d9c85b2b815d950ff50679b56107/7577c432510f23b9a706af26d903f009819bcfc06f867d83c9717f1e7dc8009d.webp)

# Automate Azure Cleanup with Bicep Deployment Stacks

[![](https://secure.gravatar.com/avatar/d0b9d42e21e5622713f8b693aa5c0f9244d5f7dd200ed29b8398f52dee5de337?s=192&d=mm&r=g)Adam Bertram](https://adamtheautomator.com/author/adam-bertram/)6 November 202615 min. read

Categories: [Cloud](/category/cloud/)

Tags:[Azure](/tag/azure/)[Azure CLI](/tag/azure-cli/)[DevOps](/tag/devops/)

Table of Contents

*   [Why a Removed Bicep Block Keeps Billing](#why-a-removed-bicep-block-keeps-billing)
*   [The Ownership Record Bicep Deployment Stacks Add](#the-ownership-record-bicep-deployment-stacks-add)
*   [Prerequisites](#prerequisites)
*   [Build the Lab Template](#build-the-lab-template)
*   [Why the Resource Group Belongs in the Template](#why-the-resource-group-belongs-in-the-template)
*   [Create the Stack With Detach Semantics First](#create-the-stack-with-detach-semantics-first)
*   [What Each Required Flag Is Doing](#what-each-required-flag-is-doing)
*   [Verify Ownership, Not Intent](#verify-ownership-not-intent)
*   [Delete One Resource on Purpose](#delete-one-resource-on-purpose)
*   [Preview the Change Before It Runs](#preview-the-change-before-it-runs)
*   [Apply the Update](#apply-the-update)
*   [The Deny Assignment That Blocks Your Own Pipeline](#the-deny-assignment-that-blocks-your-own-pipeline)
*   [The Five-Principal Cap Fails Silently](#the-five-principal-cap-fails-silently)
*   [Tear Down the Whole Lab](#tear-down-the-whole-lab)
*   [Find the Orphans the Stack Never Knew About](#find-the-orphans-the-stack-never-knew-about)
*   [Across Subscriptions and Management Groups](#across-subscriptions-and-management-groups)
*   [Confirm the Bill Actually Dropped](#confirm-the-bill-actually-dropped)
*   [Troubleshooting a Stuck or Partial Delete](#troubleshooting-a-stuck-or-partial-delete)
*   [The Failures You Will Actually See](#the-failures-you-will-actually-see)
*   [Make the Teardown Repeatable](#make-the-teardown-repeatable)

Look inside the resource group before you let a deployment stack delete anything. A group owned by a stack is a container the stack will empty, including whatever a colleague created there by hand.

This walkthrough builds a four-resource training lab from Bicep, registers it as a deployment stack, deletes one resource deliberately, then tears the rest down.

## Why a Removed Bicep Block Keeps Billing

Delete a resource block from `main.bicep`, redeploy, and the storage account keeps running. Deployment history records what a deployment did and releases nothing when a resource stops appearing in the template. The deployment succeeds with no warning, the account bills at the same rate, and the only evidence it belonged to your template is a commit you have to go find.

Four mechanics make the mess. Removing a resource from a template is not a delete instruction, because nothing reconciles template against subscription. Deployment mode `Complete` is the exception, and it deletes every resource in the group the template does not declare, including whatever a human added. Deployment history does not span scopes, so you cannot feed it to a delete. Deleting the group is all or nothing, the colleague’s Key Vault included.

The training lab here is that shape: one resource group, a storage account for course data, a virtual network, and a staging account left from a migration. When the migration ends, that account goes while the other three stay.

### The Ownership Record Bicep Deployment Stacks Add

A [deployment stack](https://learn.microsoft.com/en-us/azure/azure-resource-manager/bicep/deployment-stacks) fixes this by recording ownership at deploy time. Microsoft Learn states: “An Azure deployment stack is a resource that enables you to manage a group of Azure resources as a single, cohesive unit.” The stack has its own ID, tags, and place in the hierarchy.

The part you query later is the managed resource list. `az stack sub show` returns a `resources[]` array, and each entry carries `id`, `status`, and `denyStatus`; entries below the group add `resourceGroup`. That array is the contract you trust before a destructive command.

Scope rules are strict: a resource-group-scoped stack deploys only into its own group, a subscription-scoped stack into a group or the subscription, and a management-group-scoped stack only into a subscription underneath it. Pick the scope above what you protect; the diagram shows each boundary.

![Stack ownership boundaries](https://adamtheautomator.com/wp-content/uploads/publisher/e95cb80f0dab69d186236d7a88c2af3abc44e2be3a85bceab64701e871ed1f3d.png)

## Prerequisites

The command line carries this tutorial, since the portal cannot create a stack: you use the CLI, the Az PowerShell cmdlets, or deploy the `Microsoft.Resources/deploymentStacks` resource from a template.

*   Azure CLI 2.61.0 or later for the [`az stack` command group](https://learn.microsoft.com/en-us/cli/azure/stack); the commands below ran on 2.85.0.
    
*   Azure PowerShell, with [Az.Resources](https://learn.microsoft.com/en-us/powershell/module/az.resources/new-azsubscriptiondeploymentstack) 9.1.0 or later if you prefer cmdlets, because the `*DeploymentStack*` cmdlets ship in it. The 9.1.0 install used here exposes `New-`, `Set-`, `Get-` and `Remove-AzSubscriptionDeploymentStack`. The what-if result cmdlets are the exception and need Az.Resources 10.1.0, the release that added them.
    
*   Permission to create resource groups and stacks at subscription scope. A deny setting also needs `Microsoft.Resources/deploymentStacks/manageDenySetting/action`, which the [Azure Deployment Stack Owner role](https://learn.microsoft.com/en-us/azure/azure-resource-manager/bicep/deployment-stacks#built-in-roles) includes and Contributor does not.
    
*   A subscription where a few cents of spend is acceptable, or a pass through the template without either create call.
    

## Build the Lab Template

The template carries the lab and its container, which is the point of subscription scope. It takes two files, because a subscription-scoped file cannot place a resource in the group it declares. Save this as `main.bicep`:

```text
targetScope = 'subscription'

param workloadName string = 'traininglab'
param environment string = 'dev'
param location string = 'eastus'

var suffix = uniqueString(subscription().id, workloadName, environment)
var rgName = 'rg-${workloadName}-${environment}'

resource rg 'Microsoft.Resources/resourceGroups@2025-04-01' = {
  name: rgName
  location: location
  tags: {
    workload: workloadName
    environment: environment
    managedBy: 'deployment-stack'
  }
}

module lab './lab.bicep' = {
  name: 'lab'
  scope: rg
  params: {
    location: location
    workloadName: workloadName
    environment: environment
    suffix: suffix
  }
}

output resourceGroupName string = rg.name
output storageAccountName string = lab.outputs.storageAccountName
```

Then the lab itself, in `lab.bicep` beside it:

```text
param location string = 'eastus'
param workloadName string = 'traininglab'
param environment string = 'dev'
param suffix string

resource labStorage 'Microsoft.Storage/storageAccounts@2025-06-01' = {
  name: 'stlab${suffix}'
  location: location
  kind: 'StorageV2'
  sku: { name: 'Standard_LRS' }
  properties: {
    minimumTlsVersion: 'TLS1_2'
    allowBlobPublicAccess: false
  }
  tags: {
    workload: workloadName
    environment: environment
  }
}

// Migration staging account. Delete this block when the migration is done.
resource stagingStorage 'Microsoft.Storage/storageAccounts@2025-06-01' = {
  name: 'ststage${suffix}'
  location: location
  kind: 'StorageV2'
  sku: { name: 'Standard_LRS' }
  properties: {
    minimumTlsVersion: 'TLS1_2'
    allowBlobPublicAccess: false
  }
}

resource labVnet 'Microsoft.Network/virtualNetworks@2025-01-01' = {
  name: 'vnet-${workloadName}-${environment}'
  location: location
  properties: {
    addressSpace: {
      addressPrefixes: [
        '10.20.0.0/16'
      ]
    }
    subnets: [
      {
        name: 'default'
        properties: {
          addressPrefix: '10.20.1.0/24'
        }
      }
    ]
  }
}

output storageAccountName string = labStorage.name
output stagingAccountName string = stagingStorage.name
```

The split is not stylistic. A subscription-scope file cannot set `scope:` on a child resource: `az bicep build` stops with `Error BCP037: The property "scope" is not allowed on objects of type "Microsoft.Storage/storageAccounts"` and `Error BCP139: A resource's scope must match the scope of the Bicep file for it to be deployable. You must use modules to deploy resources to a different scope.` A module with `scope: rg` is the only construct Bicep allows for deploying at a scope other than the file’s own, so the group is declared in `main.bicep` and the three resources inside it are declared in `lab.bicep`.

`targetScope = 'subscription'` lets the file declare the group as a [resource in its own right](https://learn.microsoft.com/en-us/azure/templates/microsoft.resources/resourcegroups), so the group becomes something the stack owns and can delete later. The `managedBy` tag is for humans; the stack tracks ownership in its own list.

`uniqueString` returns the same suffix on every run for the same subscription, workload name, and environment, so later steps can recognize `stlab` and `ststage` by name. `minimumTlsVersion` sets the TLS floor and `allowBlobPublicAccess: false` closes anonymous reads.

Build before you deploy, because the compiler is the only check on your scopes. On Bicep 0.44.1, `az bicep build --file main.bicep` exits 0 with no output, and `az deployment sub validate --location eastus --template-file main.bicep` lists five `validatedResources`: the group, the nested `lab` deployment, the virtual network, and both accounts.

### Why the Resource Group Belongs in the Template

A resource-group-scoped stack cannot manage its own container: the group is not in the template, so it is not a managed resource, so deleting it [bypasses the deny assignment](https://learn.microsoft.com/en-us/azure/azure-resource-manager/bicep/deployment-stacks-known-issues) meant to protect everything inside.

The price is a `--location` argument for the stack’s metadata and a stack object outside the group it manages.

## Create the Stack With Detach Semantics First

The first call records ownership and destroys nothing, which matters because adopting an existing environment is where most of the risk lives.

Before you choose a value, learn the three you have. They appear on the update and on the delete, and the delete call’s [value decides the blast radius](https://adamtheautomator.com/azure-bicep-production-patterns/). That guide treats the boundary as part of a wider production setup.

| `--action-on-unmanage` | Managed resources | Managed resource groups | Management groups |
| --- | --- | --- | --- |
| `detachAll` | detach | detach | detach |
| `deleteResources` | delete | detach | detach |
| `deleteAll` | delete | delete | delete |

### What Each Required Flag Is Doing

Create the stack at subscription scope with the safest of the three:

```bash
az stack sub create \
  --name 'st-traininglab-dev' \
  --location 'eastus' \
  --template-file './main.bicep' \
  --action-on-unmanage 'detachAll' \
  --deny-settings-mode 'denyDelete' \
  --deny-settings-excluded-principals '<platform-admins-group-object-id>' \
  --description 'Training lab: resource group, storage, vnet. Teardown via stack delete.'
```

There is no `--create` flag. `az stack sub create` is documented as “Create or update a deployment stack at subscription scope.” PowerShell splits the verbs; the CLI has no `set` equivalent.

`--action-on-unmanage 'detachAll'` says anything the stack stops managing is detached and left alive. `--deny-settings-mode 'denyDelete'` creates a [deny assignment](https://learn.microsoft.com/en-us/azure/role-based-access-control/deny-assignments) over the managed resources so nobody deletes the storage account from the portal while the lab is live. `--deny-settings-excluded-principals` takes a group object ID, because [the list silently caps at five](https://learn.microsoft.com/en-us/azure/azure-resource-manager/bicep/deployment-stacks?tabs=azure-cli#exclude-principals-from-deny-settings). Get it with `az ad group show --group 'platform-admins' --query id -o tsv`.

Without `manageDenySetting`, the call stops before it creates anything, and the message names the missing action rather than the permission you thought you had:

```text
{"code": "DeploymentStackActionForbidden", "message": "User '<object-id>' is not authorized to perform action
'Microsoft.Resources/deploymentStacks/manageDenySetting/action' over resource
'/subscriptions/<subscription-id>/providers/Microsoft.Resources/deploymentStacks/st-traininglab-dev'."}
```

A follow-up `az stack sub show` then returns `ResourceNotFound`, so a failed attempt leaves no half-built stack. Add the deny setting once the role is in place, or run with `--deny-settings-mode 'none'`.

### Verify Ownership, Not Intent

Ask the stack what it owns before you trust it with anything:

```bash
az stack sub show --name 'st-traininglab-dev' \
  --query '{state:provisioningState, resources:resources[].{id:id,status:status,deny:denyStatus}}'
```

Four managed resources should come back: the group, `stlab` and `ststage` with their shared suffix, and the virtual network. If the count is off, stop and fix the scope. The [stack resource reference](https://learn.microsoft.com/en-us/azure/templates/microsoft.resources/deploymentstacks) splits the lifecycle choice into `resources`, `resourceGroups`, `managementGroups`, and `resourcesWithoutDeleteSupport`; the CLI collapses those into one enum.

## Delete One Resource on Purpose

The migration is over, so the `stagingStorage` block goes. Delete it from `lab.bicep`, and delete the `output stagingAccountName` line at the bottom of the same file with it, because that output reads `stagingStorage.name` and the build fails with `BCP057: The name "stagingStorage" does not exist in the current context` if you leave it behind. The template now describes three resources while the stack manages four.

### Preview the Change Before It Runs

The [stack what-if](https://learn.microsoft.com/en-us/azure/azure-resource-manager/bicep/deployment-stacks-what-if) preview is the right tool and it is version-gated, so check your CLI first. On the 2.85.0 install used here, `az stack-whatif group create` returns `ERROR: 'stack-whatif' is misspelled or not recognized by the system.` The command group is not in that release, and the `*DeploymentStackWhatIfResult` cmdlets arrive with Az.Resources 10.1.0.

On a CLI that ships it, the what-if call is the subscription-scope counterpart of the `az stack sub create` you are about to run, with the same `--action-on-unmanage` value and a `--retention-interval` from `PT1H` to `PT3H`. The result resource expires on its own, so the preview is a review gate and not an audit record.

The fallback is [`az deployment sub what-if`](https://learn.microsoft.com/en-us/cli/azure/deployment/sub#az-deployment-sub-what-if) against the same file, and it needs `--location`. Without it the call stops at `ERROR: the following arguments are required: --location/-l`. Add `--location eastus`, drop the staging account from the template, and with `--exclude-change-types Create Modify NoChange Ignore` the run answers with one line:

```text
Resource changes: no change.
```

Unfiltered, the staging account appears under `* Ignore` rather than a delete symbol, and the virtual network under `~`:

```text
Scope: /subscriptions/<subscription-id>
  = resourceGroups/rg-traininglab-dev [2025-04-01]

Scope: /subscriptions/<subscription-id>/resourceGroups/rg-traininglab-dev
  ~ Microsoft.Network/virtualNetworks/vnet-traininglab-dev [2025-01-01]
    - properties.privateEndpointVNetPolicies: "Disabled"
  = Microsoft.Storage/storageAccounts/stlab<sfx> [2025-06-01]
  * Microsoft.Storage/storageAccounts/ststage<sfx>

Resource changes: 1 to modify, 2 no change, 1 to ignore.
```

`*` is the legend’s Ignore; `~` is a property the service filled in on its own. Deployment what-if compares the template against deployed state in Incremental mode, so a resource that stops appearing is not something the deployment is asked to remove; the removal comes from `actionOnUnmanage`. Filter down to deletes and you get an empty set at the moment the next command deletes a storage account.

So this preview does not enumerate the stack’s delete set. What you check is the stack’s managed-resource list: `az stack sub show --query 'resources[].id'` before the call against the edited template, where every id with no matching declaration is a candidate. For a real delete preview, use a CLI that ships `az stack-whatif`.

![What-if shows no delete](https://adamtheautomator.com/wp-content/uploads/publisher/c9bf5f4cb89520651e0a6fa9ca18d280a936c2f21474bb53bee49a004bfda33c.png)

### Apply the Update

Re-run the create command with the destructive value stated explicitly:

```bash
az stack sub create \
  --name 'st-traininglab-dev' \
  --location 'eastus' \
  --template-file './main.bicep' \
  --action-on-unmanage 'deleteResources' \
  --deny-settings-mode 'denyDelete' \
  --deny-settings-excluded-principals '<platform-admins-group-object-id>'
```

The stack does not remember intent from the previous run; it uses the value you pass on this call, which is why the flag has no default. The call also does not ask you to confirm it: on 2.85.0 the update wrote nothing but its JSON result, and repeating it with stdin closed still exited 0 with `provisioningState: succeeded`. There is no `Detaching: resources, resourceGroups (Y/N)` line to answer, so `--action-on-unmanage` is the only thing separating a detach from a delete.

Confirm the deletion by counting managed resources again:

```bash
az stack sub show --name 'st-traininglab-dev' --query 'resources[].id'
```

Three entries should remain, and `ststage` should be gone from the group as well. The update’s `deletedResources` array names what it removed, one entry for the staging account, and `failedResources` is empty on a clean run. A storage account that still answers to `az storage account show` means the update did not carry the value you thought.

* * *

_**Warning: `detachAll` and `deleteResources` differ by one word on the command line and by whether the resource keeps billing. Nothing prompts you to confirm the difference, so read the flag before you run the call.**_

* * *

## The Deny Assignment That Blocks Your Own Pipeline

Deny settings turn a cleanup helper into a governance tool, and they are also why a deny assignment fails a cleanup pipeline at 2 a.m.

Microsoft documents a deny assignment as evaluated against the caller, and your pipeline’s service principal is one. Turn on `denyDelete`, run the stack delete as that principal, and the documented outcome is a failed delete, because the principal sits inside the boundary it wants to remove. Name the runner in the exclusion list, and mind the cap.

The verified run behind this walkthrough did not watch that happen. Creating a stack with any deny mode other than `none` needs `manageDenySetting`, the test identity did not have it, and the built stack ran with `--deny-settings-mode none` and `denyStatus: none` on all four managed resources. Treat the behavior here as what the docs state, and test it in your tenant first.

| Purpose | Azure CLI | PowerShell |
| --- | --- | --- |
| Mode | `--deny-settings-mode` | `-DenySettingsMode` |
| Apply to children | `--deny-settings-apply-to-child-scopes` | `-DenySettingsApplyToChildScopes` |
| Excluded operations (max 200) | `--deny-settings-excluded-actions` | `-DenySettingsExcludedAction` |
| Excluded principals (max 5) | [`--deny-settings-excluded-principals`](https://learn.microsoft.com/en-us/azure/azure-resource-manager/bicep/deployment-stacks) | `-DenySettingsExcludedPrincipal` |

### The Five-Principal Cap Fails Silently

Excluding one Entra group and managing membership beats listing five people, because the list truncates without an error: a sixth principal is accepted and ignored.

Two limits decide whether deny settings protect what you think. They cover the control plane only, so blob containers, queue messages, and Key Vault secrets sit outside the boundary, and deleting the parent still destroys the data. Implicitly created resources fall outside too, so the virtual machines behind an AKS cluster are neither protected nor cleaned up.

Creating a stack with any deny mode other than `none`, or updating and deleting a stack that has one, requires `Microsoft.Resources/deploymentStacks/manageDenySetting/action` at the stack scope. A `DeploymentStackActionForbidden` you cannot explain usually means the role lacks that action, which Contributor does; one your assignments explain can mean the region does not enforce it yet.

## Tear Down the Whole Lab

The course ends and the lab should stop existing. Because the group is a managed resource, one call removes the group and everything inside it:

```bash
az stack sub delete \
  --name 'st-traininglab-dev' \
  --action-on-unmanage 'deleteAll' \
  --yes
```

`--action-on-unmanage` is required on delete for the same reason it is on create: the call is destructive, so the caller states the scope of the damage. `--yes` skips the confirmation prompt, which is fine in a pipeline and a bad habit at a terminal.

* * *

_**Warning: `deleteAll` deletes managed resource groups and every resource inside them, including resources the stack never created. A database someone attached, a disk someone restored, or a Key Vault someone made by hand all go with the group.**_

* * *

Cleanup either completes or stops with a partial result. Unmanaged resources in a group block deletion of the resources and the group, and the stack reports what failed without retrying.

The [official quickstart](https://learn.microsoft.com/en-us/azure/azure-resource-manager/bicep/quickstart-create-deployment-stacks) ends by deleting an unmanaged group by hand, which is the honest consequence of `deleteResources`: groups are detached, so the shell survives. If a leftover object refuses to delete, `--resources-without-delete-support` decides whether the run detaches it or fails.

## Find the Orphans the Stack Never Knew About

Everything above works for resources the stack deployed. Anything orphaned before you adopted the stack is invisible to it, along with the objects Azure creates alongside a resource: role assignments, resource locks, and policy assignments outlive what they referenced.

Start with an inventory across every subscription you can read. The `resource-graph` extension installs on demand the first time you run `az graph`, and [Azure Resource Graph](https://learn.microsoft.com/en-us/azure/governance/resource-graph/overview) answers this in one query:

```bash
az graph query -q "
  Resources
  | where isnull(tags['managedBy']) or tags['managedBy'] != 'deployment-stack'
  | project name, resourceType = type, resourceGroup, subscriptionId
  | order by resourceGroup asc
" --first 200 --query 'data[]' -o table
```

The projection matters. The extension returns an envelope with `count`, `data[]`, and `total_records`, and `-o table` renders the envelope rather than the rows, so without `--query 'data[]'` the output is a header reading `Count 23 / Total_records 23` and no resource names. The table printer drops a column named `type`, which is why the query projects it as `resourceType`.

The query is read-only, and `--subscriptions` takes a list, so a multi-subscription sweep is the same command with more subscriptions. Some of those resources belong to another team; the rest should be adopted or removed with a signed-off command.

Then check the objects a stack delete leaves behind in a scope it does not own:

```bash
az role assignment list \
  --scope '/subscriptions/<subscription-id>/resourceGroups/rg-traininglab-dev' \
  --include-inherited \
  --query "[].{role:roleDefinitionName,principal:principalId,scope:scope}" \
  -o table
az lock list --resource-group 'rg-traininglab-dev' -o table
```

The `--query` projection is required; without it the first command exits with `ERROR: Table output unavailable. Use the --query option to specify an appropriate query.` With it, inherited assignments render as rows.

Role assignments survive a stack delete when they live at resource group scope, because the assignment is not in the stack’s list. Locks behave the same way, and a leftover `CanNotDelete` lock blocks the next person who tries to remove the group. Policy assignments inherit from above until someone removes them at the owning scope.

### Across Subscriptions and Management Groups

Give each lab its own subscription-scoped stack so the group travels with it, and shared platform resources a management-group-scoped stack that deploys into a subscription below. A management-group-scoped stack cannot deploy into another management group, so a sweep is a loop over stacks:

```bash
az stack mg list --management-group-id '<management-group-id>' --query '[].{name:name, state:provisioningState}' -o table
```

That call needs read access on the management group itself, which a subscription-level role does not grant: it fails with `AuthorizationFailed` on `Microsoft.Resources/deploymentStacks/read`. Ask the platform team for the read before planning the sweep.

Read the list before you delete anything in it. A loop that deletes on its first pass is how a shared management group loses a resource nobody remembered.

## Confirm the Bill Actually Dropped

Deleting a resource stops the meter, and the invoice takes a while to agree: cost data lands hours after usage, so cleanup day shows partial usage. Check the next day.

Microsoft publishes no price for the stack resource itself, so this check covers the resources you removed. Pull usage for the group with the [consumption commands](https://learn.microsoft.com/en-us/cli/azure/consumption) and read the daily totals:

```bash
az consumption usage list \
  --start-date '<start-date>' \
  --end-date '<end-date>' \
  --query "[?instanceId != null && contains(instanceId, 'rg-traininglab-dev')].{day:usageEnd, cost:pretaxCost, meter:meterId}" \
  -o table
```

The `instanceId != null` guard keeps the command alive. Usage records exist with a null `instanceId`, and `contains()` rejects them one at a time: without the guard the call exits with `ERROR: Invalid jmespath query supplied for --query: In function contains(), invalid type for value: None, expected one of: ['array', 'string'], received: "null"`. With the guard, an empty result means the filter worked and no cost rows have landed yet.

The dates are ISO (`YYYY-MM-DD`). A subscription with a billing boundary can return nothing, and then Cost Analysis in the portal, filtered to the group, is the comparison to use.

## Troubleshooting a Stuck or Partial Delete

Most failures here have a handful of causes, each with its own fix, and the error text rarely names one, so start from the symptom.

### The Failures You Will Actually See

Each row pairs a symptom with its cause and the action that clears it:

| Symptom | What it means | What to do |
| --- | --- | --- |
| `The deployment stack '{0}' might not have an accurate list of managed resources` | The stack’s list is stale or unverifiable, so update and delete refuse to run | Fix the drift, or use `--bypass-stack-out-of-sync-error` once after reading every managed resource |
| Delete reports success but the resource group remains | You ran `deleteResources`, which detaches groups by design | Remove the empty group by hand, or move to a subscription-scoped stack that owns it |
| One resource survives `deleteAll` | An unmanaged resource in the group blocked the group deletion | Inventory the group, adopt or delete the straggler, then rerun the delete |
| A Key Vault secret is still there | Stacks cannot delete secrets, which live on the data plane | Delete the secret by hand; set `--resources-without-delete-support detach` so the run finishes |
| 403 mentioning `manageDenySetting` | The identity lacks the action at the stack scope, or the region does not enforce it yet | Grant Azure Deployment Stack Owner, or defer the deny setting |
| `stack-whatif` is misspelled | The CLI predates the what-if result resource type | Update to a CLI that ships `az stack-whatif`; `az deployment sub what-if` cannot show the stack’s pending deletes |

The stack-out-of-sync error deserves its own caution: the [flag that suppresses it](https://learn.microsoft.com/en-us/cli/azure/stack/sub) also suppresses the check that would have stopped a bad delete. Use it after a person has read the managed resource list, never as a standing pipeline argument.

## Make the Teardown Repeatable

A cleanup you cannot repeat is one you will do by hand next quarter. Keep the stack in source control with its template, adopt with `detachAll` before you turn on deny settings, and preview every destructive update on a CLI that ships `az stack-whatif`.

Keep the stack one scope above the resources it protects, so nobody inside the group can edit the stack or its deny assignment. The Azure Developer CLI already wraps provisioning in a deployment stack behind a feature flag, and Microsoft states that stacks “will become the default deployment behavior of `azd` in a future release.” Teams standardizing on [azd for environment provisioning](https://learn.microsoft.com/en-us/azure/developer/azure-developer-cli/azure-deployment-stacks-integration) get this record either way.

Share this article

[Share on X](https://twitter.com/intent/tweet?url=https%3A%2F%2Fadamtheautomator.com%2Fazure-bicep-deployment-stack-cleanup%2F&text=Automate%20Azure%20Cleanup%20with%20Bicep%20Deployment%20Stacks)[Share on Facebook](https://www.facebook.com/sharer/sharer.php?u=https%3A%2F%2Fadamtheautomator.com%2Fazure-bicep-deployment-stack-cleanup%2F)[Share on LinkedIn](https://www.linkedin.com/sharing/share-offsite/?url=https%3A%2F%2Fadamtheautomator.com%2Fazure-bicep-deployment-stack-cleanup%2F)

## Related Posts

![](https://adamtheautomator.com/wp-content/uploads/publisher/4065d9c85b2b81f8a4cbc18a260aacb3/bd0d0a4c7f5b174292f649a4165c834b4b5d5342386221679e5f76c92d5d3524.webp)

### [Rotate Azure AI Service Keys with Zero Downtime](/azure-ai-service-key-rotation/)

Rotate Azure AI service keys safely with Azure Key Vault, refresh every application instance, and verify the switch before retiring the old key.

![](https://adamtheautomator.com/wp-content/uploads/publisher/2e05d9c85b2b819f9960cc3af514f9a0/4edae5c1327c7aaa91f7cd7fb033f216b4ba0310906a424978cd0f2b4bca4345.webp)

### [Azure Data Sovereignty Guardrails: Cut Jurisdictional Risk](/azure-data-sovereignty-guardrails/)

Master Azure data sovereignty with residency policies, encryption keys, operator oversight, and compliance controls to prevent jurisdictional data access.

![](https://adamtheautomator.com/wp-content/uploads/2026/09/featured_image-1.webp)

### [Bicep: Never Hand-Write Azure ARM JSON Again](/azure-bicep-vs-arm-templates/)

Learn how Bicep simplifies Azure infrastructure deployment with domain-specific language, dependency inference, and reusable modules for cleaner IaC.

## Categories

*   [IT Ops](/category/it-ops/)
*   [Cloud](/category/cloud/)
*   [DevOps](/category/devops/)
*   [Home Ops](/category/home-ops/)
*   [Information Security](/category/infosec/)
*   [Software Development](/category/software-development/)

## Site

*   [Home](/)
*   [Tutorials](/tutorials/)
*   [Instructors](/author/)
*   [Advertising](/advertising/)
*   [Recommended Resources](/resources/)
*   [About Adam](/about-adam/)

Copyright 2026© ATA Learning | [Privacy Policy](/privacy/)
