Business Challenge
Post #24 established where a template runs. This one is about what is inside it, and the reason it deserves a post rather than a reference lookup is that the ARM template format teaches badly. Most people learn it by copying a working template and changing values, which produces working templates and no model of why anything is where it is.
The result is a predictable set of confusions, and they all have the same root:
- A parameter default that calls
reference()and fails, with an error that seems to be about the function. - A parameter default that tries to use a variable, which is not allowed, while a default built from another parameter is fine.
- An
allowedValueslist that rejects a deployment before anything is created. - A
securestringoutput that appears to work and then turns out to be unusable by the template that needed it.
None of those are arbitrary. Resource Manager resolves parameter values before starting the deployment operations, and wherever a parameter is used it substitutes the resolved value. Everything above follows from that sentence: at the moment parameters resolve, no resource exists, no variable has been computed, and nothing has runtime state to read.
Parameters see only other parameters. Variables see parameters. Resources see both. Outputs are the only stage that can read the runtime state of what was just deployed. Almost every "why can't I do that here" question in this file format is answered by locating which stage you are in.
Architecture
Three required sections, and what the others are for
A valid template needs $schema, contentVersion and resources. That is the whole obligation. parameters, variables, outputs, functions, definitions, languageVersion and apiProfile are all optional, which is worth knowing because it reframes them: they are not parts of a template you must fill in, they are devices for keeping the resources section readable.
$schema is where post #24's scope decision physically lives. The URI is different for each deployment scope — deploymentTemplate.json# for resource groups, subscriptionDeploymentTemplate.json#, managementGroupDeploymentTemplate.json#, tenantDeploymentTemplate.json#. In Bicep this is the one-line targetScope; in JSON it is a URL, and picking the wrong one produces validation errors that never mention scope.
contentVersion is documented as meaning whatever you decide. You can provide any value, and the stated use is to make sure the right template is being deployed. It is the rare required field with no enforced semantics, which means it is only useful if a team agrees on one — and worthless otherwise.
Parameters: the stage with the least context
Parameters take one of seven types: string, securestring, int, bool, object, secureObject and array. Beyond a type, the useful properties are constraints and documentation:
| Property | What it does | Worth knowing |
|---|---|---|
defaultValue | Used when no value is supplied. | May contain expressions, and may chain from another parameter. May not reference a variable. |
allowedValues | An array of permitted values. | The deployment fails during validation — before anything is created — which is the cheapest place for a bad value to fail. |
minLength, maxLength | Bounds for strings and arrays. | Characters for strings, item count for arrays. A 3-to-24 bound on a storage account name catches the mistake in the caller rather than in the provider. |
minValue, maxValue | Inclusive bounds for integers. | Inclusive, which matters at the edges. |
metadata.description | Documentation. | Becomes the tip shown in the portal. The guidance is to add one only when it says more than the parameter name already does. |
Two restrictions catch everyone once. Expressions are not allowed with other parameter properties — only defaultValue takes them, so a computed maxLength is not possible. And reference() and the list*() functions cannot be used in the parameters section at all, because they read the runtime state of a resource and there is no runtime state before deployment begins.
One small courtesy worth knowing: when a template is deployed through the portal, camel-cased parameter names become space-separated. storageAccountName is presented as Storage Account Name. Parameter names are user interface, not just identifiers.
The limit is 256 parameters, and the guidance for staying under it is explicit: reduce the number of parameters by using objects that contain multiple properties. A vNetSettings object carrying name, location, address prefixes and subnets is one parameter instead of eight, and it keeps related values together where a reviewer can see them as a set. Under languageVersion 2.0 those objects can be validated with properties, additionalProperties and discriminator, which turns the object from a bag into a schema.
Resources: the only required section that does anything
The resource block has a long list of optional properties, and three of them decide behaviour rather than configuration.
apiVersion is a stability decision. The documented advice is to set it to the latest version when creating a template, then keep using the same API version for as long as the template works, because continuing to use the same version minimises the risk of a new one changing how the template behaves. Update it when you want a feature, not on a schedule. That is unusual advice from a vendor and it is correct: an API version is a contract, and silently moving to a newer one is a change with no diff.
dependsOn should be as short as possible. Resource Manager evaluates dependencies and deploys in the correct order; resources that do not depend on each other deploy in parallel. So every unnecessary entry in dependsOn is a serialisation of something that could have been concurrent, and the documentation warns that unnecessary dependencies slow deployments and create circular ones. Only resources deployed in this template belong there.
condition makes a resource optional. When false the resource is skipped for that deployment — the mechanism behind "deploy the jump box only in non-production" without maintaining two templates.
And scope, which appeared in post #24 from the other direction: it is available only for extension resource types, for when the target differs from the deployment scope.
Outputs: the section that gets redesigned
Outputs return values after deployment, take the same types as parameters, and support a condition that defaults to true. The limit is 64, which against 800 resources is a ratio of 800 ÷ 64 = 12.5 resources per available output — a strong hint that outputs are meant to be a summary, not an inventory.
The rule that forces redesign is about secrets, and it is worth stating precisely because the two halves are asymmetric. A securestring parameter is protected sensibly: its value is not saved to the deployment history and is not logged. A securestring output is protected to the point of uselessness: the value is not displayed in the deployment history and cannot be retrieved from another template.
So the natural design — a module that creates a secret and returns it to a caller that needs it — does not work, and the failure is discovered late because the deployment succeeds. The documented alternative is the right one anyway: store the secret in Key Vault and reference it from the parameter file. The secret then has a lifecycle, an access policy and an audit trail, none of which an output would have given it.
The two sections most people never use
User-defined functions exist, and their restrictions explain why they are rare: a function cannot access variables, can only use parameters defined on the function itself, cannot call other user-defined functions, cannot use reference(), and its parameters cannot have default values. That is a pure function over its own arguments — useful for a naming convention, and not much else.
definitions requires languageVersion 2.0, which also brings symbolic names, existing resource declarations, user-defined types, and one change worth flagging: the default for expressionEvaluationOptions becomes inner, and outer is blocked. Anyone who has debugged a nested template evaluating an expression in the parent's context will recognise that as a fix rather than a restriction.
Why This Architecture Holds Up
Constraints in the template are the cheapest validation available
allowedValues, minLength, maxValue and their siblings fail the deployment during validation, before any resource is created. That places the error at the cheapest possible point — earlier than the resource provider rejecting a name, far earlier than a half-deployed template needing to be unwound.
This is the same argument post #17 made about effects: put the cost in front of the person who can fix it, at the moment they can fix it. A storage account name constrained to 3–24 characters in the template tells the caller what is wrong. The same name rejected by the storage provider tells them a request failed.
Parameters are the template's public interface
Everything a caller can influence is a parameter, so the parameter list is the API. Two habits follow. Descriptions belong on parameters whose names cannot carry the meaning — and only those, per the documented guidance, because a description restating the name is noise a reader has to check. And related values belong in objects, which keeps the interface small and the relationships visible.
The corollary is that adding a parameter is a breaking change to anyone deploying the template, in the way adding a required argument to a function is. Defaults are what make it not one.
Bicep is the recommended front end, and this is still worth knowing
Microsoft's own documentation recommends Bicep, on the grounds that it offers the same capabilities with easier syntax. That recommendation is worth taking — and it does not make this post redundant, because Bicep compiles to exactly this. The staged evaluation is the same, the limits are the same, the securestring output rule is the same, and the schema URI is what targetScope becomes.
When a Bicep deployment fails, the error frequently arrives in the language of the compiled JSON. Knowing the four stages and what each can see is what makes those errors legible.
Key Architecture Decisions
The shape that works
| Decision | What to do | Why |
|---|---|---|
| Validation | Constrain parameters with allowedValues, lengths and ranges |
A bad value then fails during validation, before anything is created. |
| Parameter count | Group related values into objects rather than adding parameters | The documented way to stay under 256, and it keeps related settings visible as a set. |
| Defaults | Chain from other parameters where useful; never expect a variable to be available | Parameters resolve first, so a variable does not exist yet. |
| Secrets | Key Vault reference in the parameter file, never a securestring output |
A secure output cannot be retrieved from another template, so the pattern fails silently. |
apiVersion |
Latest at authoring time, then pinned until a feature requires moving | Keeping the version minimises the risk of behaviour changing under a template nobody edited. |
dependsOn |
Only what is genuinely required, and only resources in this template | Independent resources deploy in parallel; every extra dependency serialises the deployment. |
| Optional resources | condition rather than a second template |
One reviewed artefact instead of two that drift. |
| Outputs | Return identifiers a caller cannot derive; nothing else | 64 outputs against 800 resources says a summary, and a caller can look up what it already knows how to name. |
| Descriptions | Only where the name cannot carry the meaning | Documented guidance, and a description restating the name is something a reader has to read to discard. |
Closing Thought
The ARM template format has a reputation for being fiddly, and some of that is earned — it is JSON with a string-embedded expression language, which is a hard thing to read. But most of the rules people find arbitrary are not arbitrary at all. They are visible consequences of a pipeline: resolve the parameters, compute the variables, deploy the resources in dependency order, then report what happened.
Once that order is in mind, the restrictions stop needing to be memorised. Of course a parameter default cannot call reference() — there is nothing to reference yet. Of course outputs are the only place runtime state is available — they run last. Of course a secure output cannot be read by another template — that is what makes it secure.
The one genuinely awkward corner is that last point, because it breaks a design that feels natural and breaks it quietly. If a template needs to hand a secret to another template, the answer is not a better output. It is that the secret should have been in Key Vault from the start, where it can outlive both deployments.
#26 moves to Bicep proper: modules, loops, and what it compiles to — including why reading the generated JSON is still occasionally the fastest way to understand what a Bicep file is doing.
Comments