Estimate ARM template and Bicep costs before you deploy

·

By

Glenn Gillen

Estimate ARM template and Bicep costs before you deploy

·

By

Glenn Gillen

ARM pull request showing cost breakdown

Infracost CLI v2.15.0 reads ARM templates natively. Nothing deployed, and no Azure credentials.

Infracost now reads ARM templates

Four things decide what this deployment costs. Not one of them is a number you can read off the page.

{
  "type": "Microsoft.Compute/virtualMachines",
  "apiVersion": "2024-07-01",
  "copy": { "name": "nodeLoop", "count": "[parameters('nodeCount')]" },
  "name": "[concat(parameters('prefix'), '-node-', copyIndex())]",
  "location": "[resourceGroup().location]",
  "properties": {
    "hardwareProfile": {
      "vmSize": "[if(equals(parameters('env'), 'prod'), 'Standard_D8s_v5', 'Standard_B2s')]"
    }
  }
}
{
  "type": "Microsoft.Compute/virtualMachines",
  "apiVersion": "2024-07-01",
  "copy": { "name": "nodeLoop", "count": "[parameters('nodeCount')]" },
  "name": "[concat(parameters('prefix'), '-node-', copyIndex())]",
  "location": "[resourceGroup().location]",
  "properties": {
    "hardwareProfile": {
      "vmSize": "[if(equals(parameters('env'), 'prod'), 'Standard_D8s_v5', 'Standard_B2s')]"
    }
  }
}
{
  "type": "Microsoft.Compute/virtualMachines",
  "apiVersion": "2024-07-01",
  "copy": { "name": "nodeLoop", "count": "[parameters('nodeCount')]" },
  "name": "[concat(parameters('prefix'), '-node-', copyIndex())]",
  "location": "[resourceGroup().location]",
  "properties": {
    "hardwareProfile": {
      "vmSize": "[if(equals(parameters('env'), 'prod'), 'Standard_D8s_v5', 'Standard_B2s')]"
    }
  }
}

The count here is a parameter, the size depends on which way env branches, the region comes from a function that has no answer until a deployment exists, and whether the resource gets created at all can hinge on a condition further down the file.

Here's the pull request that changed it:

   "nodeCount": {
-    "value": 3
+    "value": 12
   }
   "nodeCount": {
-    "value": 3
+    "value": 12
   }
   "nodeCount": {
-    "value": 3
+    "value": 12
   }

One line in a different file that can be approved in thirty seconds.

That change costs $2,522.88 a month. Unless it costs $273.31 a month.




Two projects out of one template, because env picks the VM size and each parameters file answers differently. Before the diff, those two came to $91.10 and $840.96.

Same diff. Same template. A 9.2× spread, decided by a file that isn't in the pull request.

Nobody catches this by reading it

Nothing here is hidden. The problem is it's scattered, and solely look at the diff hands you none of it.

The count is in front of you, but what it multiplies is back in the template. The size is in the template, but which branch applies depends on env, set in whichever parameters file this environment deploys with. The region isn't anywhere at all. Then there's a list price to look up, and the multiplication.

So it surfaces six weeks later on an invoice, in a thread between people who never saw the template. By then nine extra Standard_D8s_v5 instances have been running for six weeks. And nobody refunds those. Whoever wrote the change wasn't wrong to write it. The cost just wasn't visible while fixing it was still free.

If you run Terraform, Infracost has had your back for years. If your shop runs ARM or Bicep, you've been doing it the hard way: eyeballing templates and pasting resources into the Azure pricing calculator one at a time.

You don't have to do that anymore.

Point it at a directory

There's nothing to set up and nothing to deploy. You don't need a subscription or credentials. You point the CLI at your template directory and it tells you what those resources cost:

It works out the type on its own. Any deploymentTemplate.json# schema counts, across all four deployment scopes, with a fallback for nested templates that leave the schema out. It's the same command that already runs Terraform, Terragrunt, CloudFormation, and Kubernetes.

Then slice the results without re-running anything:

infracost inspect --missing-tag
infracost inspect --missing-tag
infracost inspect --missing-tag

Results are cached locally, so inspect comes back instantly.

We had to write an interpreter to do it

This was the expensive part to build, and it's why ARM took longer than CloudFormation.

CloudFormation has about fifteen intrinsic functions, and you can apply them while walking the document. ARM has 106, including map, filter, reduce, sort and groupBy driven by lambda(). It lets a template declare its own functions. It puts copy in four different positions.

Once reduce(range(0, parameters('n')), 0, lambda(...)) is legal input to a price, reading the JSON won't get you a number. Reading the JSON gets you a list of resource types and a pile of strings in square brackets.

So Infracost evaluates the template the way Azure would, with a real lexer and parser behind it, and an interpreter that carries scope, lambda bindings, and whatever functions the template declares for itself. Names resolve case-insensitively, so parameters('VmSize') finds a vmSize declaration. format() follows .NET composite formatting, which is why format('{0:D3}', 5) gives you 005 instead of 5. String functions count characters rather than bytes, which matters the first time a resource name runs through substring.

copy loops price as twelve nodes. condition means the resource that only exists in prod is only costed in prod. Nested templates expand and price in both scope modes.

Your parameters files decide the number, so we read them

The template above says [parameters('env')]. The money is in the file that answers it.

Values resolve the way az deployment resolves them: defaultValue first, then parameters files, then inline overrides. You wire up none of it. In the example at the top of this post, Infracost found azuredeploy.parameters.dev.json and azuredeploy.parameters.prod.json on naming convention alone, then priced each one separately.

That's why the scan returned two projects instead of one average.

Bicep: compile it, then point us at the output

Infracost parses ARM JSON. It won't run the Bicep transpiler for you.

Answering "what does this directory cost" shouldn't mean executing a build toolchain we didn't write, with whatever side effects it carries, every time someone runs a scan. We made the same call with CDK.

bicep build main.bicep --outfile

bicep build main.bicep --outfile

bicep build main.bicep --outfile

Point it at a .bicep file and you get an error telling you to transpile first, rather than a parse failure on something that was never JSON.

That boundary costs you one thing: bicep build strips @metadata decorators, so anything your team expressed as Bicep metadata is gone before the JSON reaches us.

For the person rolling this out across teams

Azure shops are rarely only ARM shops. One team standardised on Bicep. Another inherited Terraform. A platform group runs Helm charts on AKS. The subsidiary you acquired last year is on something else.

Until you have coverage everywhere, your cost policies stop at the first language boundary they hit, and you get to explain the gap to Finance.

ARM runs through the same engine as everything else. Same pricing data, and the same FinOps and tagging rules you already wrote, whether the resource was declared in HCL, in CloudFormation YAML, in a Helm chart, or in ARM JSON. One rule, written once, enforced locally, in CI, in the editor, and in the pull request, which is where that thirty-second approval at the top of this post would have gone differently:

![Infracost cost diff on an ARM template pull request, showing the monthly increase per environment](IMAGE-SLOT: PR comment screenshot showing the cost diff lines for azuredeploy.json-dev and azuredeploy.json-prod)

Which is the only way I know to hold a standard across four IaC languages without sitting in every design review.

What it doesn't do

  • Template Specs aren't expanded. Resources declared inside one aren't priced.

  • .bicep isn't parsed directly, and Bicep @metadata isn't read.

  • environment() returns Azure public cloud values. Sovereign clouds aren't handled.

  • A few valid Azure values degrade to unknown instead of being priced: Classic container registry SKUs, VPNClient gateway enums, GZRS in some redundancy tiers, classic Front Door. They show up as unknown rather than getting quietly rounded to the nearest value we do recognise.

  • Tagging policies flag invalid tag values, not missing ones. Plenty of Azure tagging happens at the subscription, on a pre-existing resource group, or through Azure Policy, none of which a static scan can see, so a missing-tag check would fire constantly on correctly tagged production resources. Resource-group tags declared inside the scanned template do inherit, the way Terraform's default_tags do.

  • Usage values are keyed by ARM address, like Microsoft.Web/sites/my-function-app. An infracost-usage.yml written against Terraform addresses won't carry over.


Install it in the next two minutes

macOS (Homebrew):

Windows (Chocolatey):

Linux:

curl -fsSL https://raw.githubusercontent.com/infracost/cli/master/scripts/install.sh | sh
curl -fsSL https://raw.githubusercontent.com/infracost/cli/master/scripts/install.sh | sh
curl -fsSL https://raw.githubusercontent.com/infracost/cli/master/scripts/install.sh | sh

Once it's installed, run infracost setup to get your API key and authenticate.

It's free, and it parses your templates locally. No Azure credentials or secrets leave your machine.

See what your next Azure deployment costs before you deploy it. Point Infracost at your templates and the estimate shows up in your terminal, and on every pull request once CI is wired in. The first surprise it catches pays for the two minutes it took to install.

Infracost ROI Report

Learn how the ROI of shifting FinOps left is measured

Get started
with Infracost

© 2026 Infracost Inc

Manage cookies

Get started
with Infracost

© 2026 Infracost Inc

Manage cookies

Get started
with Infracost

© 2026 Infracost Inc

Manage cookies