Azure Bicep Parameters, Variables, and Conditions

All right, so by the end of the last post you had a real storage account written from a blank file, deployed, and proven idempotent. It works. But look at it honestly and it has a problem you can't ship with: every value is hardcoded. The name is baked in. The location is baked in. The SKU is baked in. The moment someone asks you to stand up a second copy for production, your only move is to copy the file, rename it, and start editing strings by hand. That's the portal problem all over again, just with extra steps.
The fix is the thing that turns Bicep from a tidy config file into actual reusable infrastructure, and it's only four moves. Parameters let values come in from the outside at deploy time. Variables let you compute values inside the template so you stop repeating yourself. Parameter files let you keep one template and feed it different values per environment. And conditions let one template decide what to deploy based on those values. Put those four together and you get a single main.bicep that deploys your dev environment and your production environment from the same source, with no editing in between.
That's the whole goal of this post: one template, many environments. Let's build it.
Parameters Come In, Variables Stay In
This is the one distinction that, once it clicks, makes everything else in this post obvious. So I want to be precise about it.
A parameter is a value passed into the template from outside, at deploy time. You declare it with the param keyword, give it a name, give it a type, and optionally a default:
param location string = 'canadacentral'
param environment string = 'dev'
param storageSkuName string = 'Standard_LRS'
A variable is a value computed inside the template. You declare it with var, and you don't write a type at all, because Bicep infers it from whatever you assign:
var storageAccountName = 'st${environment}${uniqueString(resourceGroup().id)}'
var tags = {
environment: environment
managedBy: 'bicep'
}
Here's the test I give people, and it answers almost every "is this a param or a var?" question on the spot. If the value should be able to change from one deployment to the next, it's a parameter, because that's the door the outside world comes through. If the value is derived from other values and is the same logic every time, it's a variable, because that's work the template does for itself. Location changes between dev and prod, so it's a parameter. The storage account name is built from the environment and a hash, so it's a variable.
Notice the difference in how you write them, too, and that the difference is telling you something. Parameters have an explicit type because Bicep can't know in advance what the outside world will hand it, so you have to declare the contract. Variables don't, because Bicep can see exactly what you assigned and works the type out itself.
Pro tip: A parameter with a sensible default is the best of both worlds. The default makes the template deployable with zero arguments while you're testing, and the fact that it's a parameter means you can still override it for production. Default to the safe, cheap, dev-friendly value and override upward.
Decorators Are Guardrails the Compiler Enforces
A bare parameter accepts anything of the right type, and "anything" is how you end up with a half-deployed stack and a confusing error. Decorators fix that by attaching rules to a parameter, and the important part is when those rules run. They're checked at compile time, before a single call goes out to Azure. Bad input fails on your machine, in your editor, not ten minutes into a deployment.
@description('Deployment environment. Drives naming and redundancy.')
@allowed([
'dev'
'prod'
])
param environment string = 'dev'
@minLength(3)
@maxLength(24)
param storageNamePrefix string = 'st'
@secure()
param adminPassword string
Walk through what each one buys you. @allowed restricts the parameter to a fixed set of values, so environment can only ever be dev or prod; pass production by mistake and the deployment refuses to start. @minLength and @maxLength enforce length limits, which matters for storage account names because Azure caps them at 24 characters and you'd much rather learn that from a red squiggle than from a failed deploy. @description shows up in IntelliSense when someone uses your template and gets written into the generated ARM, so it's documentation that travels with the code. And @secure marks a value as sensitive so it stays out of deployment logs, out of the portal's deployment history, and out of CLI output.
The mental shift here is the same one that made the editor so valuable back in episode one. You're moving the moment of failure as early as possible. An invalid environment name, a too-long prefix, a secret about to be printed to a log: every one of those becomes a problem you see while typing instead of a problem you debug after Azure has already created half of what you asked for.
Warning:
@secureis not optional cleanup you do later. Any parameter that carries a password, a connection string, or a key should be marked@securethe moment you declare it. Without it, that value can land in the deployment history where anyone with read access to the resource group can retrieve it. Treat the decorator as part of declaring the parameter, not a polish step.
One Template, Many Environments, With .bicepparam Files
Parameters with defaults are fine for testing, but the real payoff is that you can keep one template and feed it a different set of values per environment without touching the template at all. That's what .bicepparam parameter files are for, and they're the cleanest multi-environment pattern Bicep has.
Create two files next to your main.bicep. First, dev.bicepparam:
using './main.bicep'
param environment = 'dev'
param location = 'canadacentral'
param storageSkuName = 'Standard_LRS'
Then prod.bicepparam:
using './main.bicep'
param environment = 'prod'
param location = 'canadacentral'
param storageSkuName = 'Standard_GRS'
The first line of each file is the part that makes this work. The using './main.bicep' statement binds the parameter file to a specific template, and that binding is what unlocks the tooling: the editor now knows exactly which parameters exist, what types they expect, and which ones are required, so you get the same IntelliSense and the same red-squiggle validation inside the parameter file that you got inside the template. Misspell a parameter name or assign the wrong type and you find out immediately, in the param file, before you deploy.
Deploying is then just a matter of pointing at the parameter file. Because the file already declares its template through using, you don't even repeat --template-file:
# Deploy the dev environment
az deployment group create \
--resource-group rg-demo-bicep \
--parameters dev.bicepparam
# Deploy production from the EXACT SAME template
az deployment group create \
--resource-group rg-demo-bicep-prod \
--parameters prod.bicepparam
That's the whole multi-environment story. One main.bicep is the single source of truth for what gets deployed. Each .bicepparam file is the source of truth for how a given environment differs. Dev gets locally redundant storage because it's cheap and nobody cares if a dev blob disappears. Production gets geo-redundant storage because losing production data is a different kind of day. Same template, two files, two environments, and the difference between them is readable at a glance.
Gotcha: A
.bicepparamfile only sets parameters; it can't add a resource or change logic. If you find yourself wishing prod could deploy something extra that dev doesn't, that's not a parameter file's job. That's a condition, which is exactly where we're headed next.
Generate Names You Never Have to Type
Before conditions, there's one variable from earlier worth slowing down on, because it solves a problem that bites every beginner: storage account names have to be globally unique across all of Azure, and hand-typing unique names is a losing game.
This is what uniqueString is for, and the thing to understand is that it is not random:
var storageAccountName = 'st${environment}${uniqueString(resourceGroup().id)}'
uniqueString returns a deterministic 13-character hash of whatever you feed it. Deterministic is the key word. The same input always produces the same output, so seeding it with resourceGroup().id means you get the same name every time you deploy to that resource group, and a different name when you deploy to a different one. That's exactly the behavior you want: stable within an environment, distinct across environments, and never colliding with someone else's account on the other side of the planet.
The ${} around it is string interpolation, which just splices values into a string. So 'st${environment}${uniqueString(resourceGroup().id)}' reads as the literal st, then the environment, then the hash, producing something like stdeva1b2c3d4e5f6g. It's unique, it's tied to where it's deployed, it encodes the environment so you can tell dev from prod at a glance, and you never typed the unique part. The template did.
Pro tip: Because
uniqueStringis deterministic, redeploying the same template to the same resource group reuses the same account rather than creating a new one. That's not a quirk to work around; it's idempotency doing its job. If you ever need the name to actually change, change the seed, not the function.
Conditions Decide What Exists, at Compile Time
Now the last move, and it's the one that gets misunderstood the most. Sometimes an environment shouldn't deploy a resource at all. Maybe production needs a blob container for application data, but dev doesn't. A parameter file can't express that, because it only sets values. What you need is for the template itself to decide, and that's the if keyword on a resource.
param deployBlobContainer bool = false
resource container 'Microsoft.Storage/storageAccounts/blobServices/containers@2025-06-01' = if (deployBlobContainer) {
name: '${storageAccount.name}/default/appdata'
}
When deployBlobContainer is true, the container deploys. When it's false, it doesn't. Flip the flag per environment from your parameter files: prod.bicepparam sets it true, dev.bicepparam leaves it false, and now production gets the container while dev doesn't, all from the same template.
Here's the part almost everyone gets wrong, and it's worth being exact about. People assume that when the flag is false, Azure provisions the container and then removes it, or that it sits there disabled somehow. Neither. The if condition is evaluated at compile time, when your Bicep is transpiled to ARM JSON. When the condition is false, the resource is never written into the generated ARM template at all. It doesn't get deployed and deleted. It was never in the deployment to begin with. Azure isn't told about it, doesn't create it, doesn't bill for it, doesn't have to clean it up.
That compile-time-versus-runtime distinction is the entire reason one template can safely serve both dev and prod. You're not deploying everything and then tearing down what you don't want. You're describing a different shape entirely for each environment, and the compiler builds exactly that shape and nothing more. If you remember the first post in this series, where we saw that Bicep is just a friendlier front end that compiles to ARM, this is that fact paying off: because the condition resolves before the ARM is ever generated, a false resource simply never makes it into the JSON that Azure receives.
Gotcha: Don't reach for a condition when a parameter would do. If dev and prod want the same resource with different settings, that's a parameter on the properties. Use a condition only when a resource should exist in one environment and genuinely not exist in another. Conditions decide existence; parameters decide configuration.
The Bigger Lesson
If you take one idea from this post, make it this: a reusable template separates what gets deployed from what's different about each deployment. The template holds the shape, the structure, the logic. The parameter files hold the per-environment differences. The instant you draw that line cleanly, the copy-paste-and-edit habit dies, and with it goes a whole category of mistakes, the prod template that drifted from dev because someone edited one and forgot the other.
Each of the four moves serves that one line. Parameters are how the outside differs from deploy to deploy. Variables are how the template stops repeating itself and computes what it can. Decorators make sure the values coming in are sane before Azure ever hears about them. And conditions let the template itself change shape per environment, at compile time, so each deployment gets exactly the resources it should and not one more. The same describe-the-end-state thinking that made idempotent deployments feel safe is what makes this feel safe too: you declare what each environment should look like, and Bicep builds precisely that.
Next in the series, we tackle the thing this post quietly sets up. Right now everything lives in one growing main.bicep, and that file gets long fast as real infrastructure piles on. The answer is modules, breaking the template into reusable pieces you can compose and share, and once you've got parameters and conditions down, modules are the natural next step. Where this whole road eventually leads is automation, which is what CI/CD pipelines with GitHub Actions give you: the same template, the same parameter files, deployed to each environment by a pipeline instead of by you running a command.
You started this post with a template that could deploy exactly one thing, exactly one way. You're ending it with a template that can deploy dev and prod, with different redundancy, different resources, and validated input, from a single source of truth. The storage account was never the point. One template that safely serves many environments is.
This article is based on episode three of my Bicep from Scratch series on YouTube (cloudwithsingh).




