How to Debug Azure InvalidTemplateDeployment Errors

All right, so you kicked off a deployment, waited four minutes, and Azure came back with this:
Deployment failed. Correlation ID: 9f2c1b44-0d5e-4a71-b8c3-2f6a99e41d07
{
"code": "InvalidTemplateDeployment",
"message": "The template deployment failed with error: 'The resource operation completed with terminal provisioning state Failed.'"
}
That message is telling you the deployment failed because the deployment failed. It is circular, it names no resource, and it names no cause. I've watched senior engineers stare at that block and start randomly commenting out sections of a Bicep file, which is a slow and expensive way to find out that a policy assignment three management groups up was the actual problem.
Here's the thing: the real error is almost always sitting in the API response already. InvalidTemplateDeployment is a wrapper, not a diagnosis. Azure Resource Manager nests the actual failure inside a details array, and the CLI prints only the outer layer by default. What follows is the order I work through when a deployment fails, from cheapest to most expensive, and the specific commands that pull the buried error out.
Why InvalidTemplateDeployment Tells You Almost Nothing
Resource Manager evaluates a deployment in stages, and the error you see depends on which stage rejected it.
A malformed template fails validation before anything is submitted, and you get a specific parse error pointing at a line. That case is easy. InvalidTemplateDeployment is different. It means the template was structurally valid, ARM accepted it, submitted it, and then something downstream said no. That "something downstream" is one of three things in my experience:
A resource provider rejected the request, usually for quota, SKU availability in the region, or a name that is already taken globally. Azure Policy denied the request, which is far more common in enterprise subscriptions than people expect. Or a nested deployment failed, and the outer deployment is simply reporting that its child died.
All three produce the same useless outer message. The distinguishing information lives one or two levels deeper in the error object, and the whole job is getting down there.
Gotcha: The correlation ID in the failure message is not a support ticket number. It is a queryable key that ties every activity log entry for that operation together, and it is the single most useful string in that output. Copy it before you clear your terminal.
Step 1: Read the Deployment's Own Error Object
Before anything else, ask the deployment what went wrong. The failed deployment record persists in the resource group's deployment history, and it carries the full error tree.
az deployment group show \
--resource-group demo-platform-rg \
--name demo-platform-deploy \
--query properties.error \
--output json
The --query properties.error is the part that matters. Without it the CLI hands you the entire deployment object, which is several hundred lines of template hash, parameter echo, and output bindings, and the error is buried in the middle of it.
What you get back looks like this:
{
"code": "InvalidTemplateDeployment",
"message": "The template deployment failed with error: ...",
"details": [
{
"code": "RequestDisallowedByPolicy",
"message": "Resource 'demostorageacct01' was disallowed by policy. Policy identifiers: '[{\"policyAssignment\":{\"name\":\"Deny public blob access\"...}}]'"
}
]
}
There it is. A policy called "Deny public blob access" rejected the storage account. That has nothing to do with your Bicep syntax, and no amount of rewriting the template would have found it.
details is an array, and it nests. A child error can carry its own details array with its own children, which is exactly what happens with Bicep modules, because every module compiles down to a nested deployment. To flatten the whole tree in one shot:
az deployment group show \
--resource-group demo-platform-rg \
--name demo-platform-deploy \
--query "properties.error" -o json \
| python3 -c "
import json, sys
def walk(e, depth=0):
print(' ' * depth + f\"[{e.get('code')}] {e.get('message', '')[:300]}\")
for child in e.get('details') or []:
walk(child, depth + 1)
walk(json.load(sys.stdin))
"
That prints the full chain, indented by depth, with the leaf node at the bottom. The leaf is your actual error. Everything above it is Resource Manager relaying bad news up the stack.
Pro tip: If
properties.errorcomes backnull, the deployment did not fail at the ARM layer at all. That usually means the deployment succeeded but a resource inside it landed in a Failed provisioning state afterwards. Skip to the activity log in Step 4.
Step 2: List the Deployment Operations
The error object tells you what failed. It does not always tell you which resource, especially in a template that creates twenty things and only one of them broke.
Deployment operations are the per-resource record of what ARM attempted. Every resource in the template gets one, with its own status and its own error.
az deployment operation group list \
--resource-group demo-platform-rg \
--name demo-platform-deploy \
--query "[?properties.provisioningState=='Failed'].{
resource: properties.targetResource.resourceName,
type: properties.targetResource.resourceType,
code: properties.statusMessage.error.code,
message: properties.statusMessage.error.message
}" \
--output table
The [?properties.provisioningState=='Failed'] filter is what makes this usable. A deployment with fifty resources produces fifty operations, and you only care about the ones that failed. Filtering server-side in JMESPath beats scrolling.
Expected output:
Resource Type Code Message
-------------------- ---------------------------------- ------------------------ -------------------------------
demostorageacct01 Microsoft.Storage/storageAccounts RequestDisallowedByPolicy Resource 'demostorageacct01'...
Now you have a resource name, a type, and a code. That is enough to act on.
For a subscription-scoped or management-group-scoped deployment, the subcommand changes but the shape is identical:
# Subscription scope
az deployment operation sub list --name demo-landing-zone \
--query "[?properties.provisioningState=='Failed']" -o json
# Management group scope
az deployment operation mg list --name demo-mg-baseline \
--management-group-id demo-mg-platform \
--query "[?properties.provisioningState=='Failed']" -o json
Getting this wrong is a common time sink. If you run az deployment operation group list against a deployment that was submitted at subscription scope, you get a DeploymentNotFound error, and it reads like the deployment vanished rather than like you queried the wrong scope.
Step 3: Follow the Nested Deployment Chain
This is where most people give up, and it is the case that matters most in real infrastructure, because modular Bicep is built entirely on nested deployments.
When your template calls a module, ARM creates a separate child deployment for it. If the child fails, the parent's operation list shows a failed resource of type Microsoft.Resources/deployments with a generated name, and the useful error is inside that deployment, not the one you launched.
Find the children first:
az deployment operation group list \
--resource-group demo-platform-rg \
--name demo-platform-deploy \
--query "[?properties.targetResource.resourceType=='Microsoft.Resources/deployments'
&& properties.provisioningState=='Failed'
].properties.targetResource.resourceName" \
--output tsv
That returns the names of the failed child deployments. Then run Step 1 and Step 2 against each one, because a child can have its own children:
CHILD=$(az deployment operation group list \
-g demo-platform-rg -n demo-platform-deploy \
--query "[?properties.targetResource.resourceType=='Microsoft.Resources/deployments' && properties.provisioningState=='Failed'] | [0].properties.targetResource.resourceName" \
-o tsv)
az deployment operation group list -g demo-platform-rg -n "$CHILD" \
--query "[?properties.provisioningState=='Failed'].properties.statusMessage.error" \
-o json
Three levels deep is normal in a landing zone. I've been five deep on a platform template that composed modules out of modules. Every level relays the same InvalidTemplateDeployment upward, which is precisely why the top-level message is worthless and why people conclude the error is unknowable.
Warning: Deployment history is capped at 800 deployments per resource group, and ARM prunes the oldest automatically once you cross it. On a resource group with a CI pipeline deploying on every commit, a failure from last month may simply not be there anymore. If you need the record, pull it the day it happens.
Step 4: When the Error Object Is Empty, Use the Activity Log
Sometimes properties.error is null, the operations all report Succeeded, and the resource is still broken. That happens when the control plane accepted the request and the failure occurred inside the resource provider afterwards. A VM extension that fails to install is the classic example, along with an Azure Policy remediation task that fires after creation.
This is what the correlation ID is for.
az monitor activity-log list \
--correlation-id 9f2c1b44-0d5e-4a71-b8c3-2f6a99e41d07 \
--offset 6h \
--query "[].{
time: eventTimestamp,
op: operationName.value,
status: status.value,
sub: subStatus.value,
msg: properties.statusMessage
}" \
--output table
One correlation ID covers every entry ARM emitted for that operation across every resource it touched, including the ones your deployment did not directly name. The --offset defaults to 6 hours, so for anything older, set an explicit window:
az monitor activity-log list \
--correlation-id 9f2c1b44-0d5e-4a71-b8c3-2f6a99e41d07 \
--start-time 2026-08-18T00:00:00Z \
--end-time 2026-08-19T00:00:00Z \
-o json
The properties.statusMessage field is usually a JSON string rather than an object, so it prints escaped and unreadable in a table. Pipe it through a parser when you need the detail:
az monitor activity-log list --correlation-id "$CORR" --offset 6h \
--query "[?status.value=='Failed'].properties.statusMessage" -o tsv \
| python3 -c "import json,sys; [print(json.dumps(json.loads(l), indent=2)) for l in sys.stdin if l.strip()]"
Gotcha: Activity log retention is 90 days and it is not configurable. If you want deployment failures to survive longer than that, route the Activity Log to a Log Analytics workspace with a diagnostic setting. Every team I've worked with that skipped this regretted it during their first incident review.
Verification: Catch It Before You Deploy
Everything above is forensics. The cheaper move is not failing in the first place, and two commands cover most of it.
what-if shows you what ARM would change, and it runs the same validation path a real deployment does, so policy denials and many provider rejections surface here:
az deployment group what-if \
--resource-group demo-platform-rg \
--template-file ./main.bicep \
--parameters ./main.bicepparam \
--result-format FullResourcePayload
And validation alone, which is faster when you only want to know whether it will be accepted:
az deployment group validate \
--resource-group demo-platform-rg \
--template-file ./main.bicep \
--parameters ./main.bicepparam \
--query "error" -o json
Note that validate returns its problems in an error object too, with the same nested details array, so the flattening script from Step 1 works on it unchanged.
Neither of these catches everything. Quota and regional SKU availability are evaluated at submission time, so a template that passes what-if can still fail on capacity. But policy denials, missing role assignments on the deploying identity, and malformed references all show up here, and those are the majority of what I see.
Pro tip: Run
what-ifin your pipeline on pull requests, against the real target subscription, using the real deploy identity. Running it against a sandbox with Owner rights proves nothing, because the failures you are trying to catch are policy and permission failures that only exist in the target.
The Bigger Lesson
The reason InvalidTemplateDeployment frustrates people is that it looks like an error message and behaves like an envelope. Azure is not withholding the cause. It is handing you a tree and the CLI is printing the root node.
Once you internalise that, the debugging loop stops being guesswork. You ask the deployment for its error object, you flatten the details array, you filter the operations to the failed ones, and if the failed one is a nested deployment you repeat the process one level down. That is a mechanical procedure, and it terminates. Four commands, and it works the same whether the template is a ten-line ARM file or a landing zone built out of forty Bicep modules.
The broader habit worth building is treating the Azure CLI as a query interface rather than a deployment trigger. Almost every "Azure won't tell me why" problem I've been handed turned out to be a --query away, in an object the caller already had. The information was never missing. It was one level down, and nobody looked.



