Resources and infrastructure as code
Learn the Azure resource model (tenant, subscription, resource group, resource, region, SKU), when to reach for the portal, the `az` CLI or Bicep, why click-ops drifts, and how to read an agent-generated deployment template critically.
15 cards · 7 quiz questions · 11 min read
You open the Azure portal to check one thing and find forty-odd items with names like iff-api-prod-2, stiffexports, appi-research, and a resource group called Default-Web-WestEurope that nobody remembers creating. Some of it was clicked into existence, some of it an agent deployed from a template, and there is no single place that says what the system is meant to look like. This article is about the model underneath all of that, and about reading a deployment template well enough to catch the expensive mistakes.
The model: five words
Tenant. Your Microsoft Entra ID directory — the identity boundary. It decides who your users are. One organisation, normally one tenant.
Subscription. A billing and quota boundary that holds resources. A tenant can contain many; “prod” and “sandbox” are usually separate ones. Choosing the wrong one is how production resources end up on the experiments bill.
Resource group. A container for resources that share a lifecycle. You deploy, update and delete them together.
Resource. A manageable thing: a web app, a storage account, a database. Resource groups, subscriptions and tags are themselves resources.
Region and SKU are properties of a resource, not levels of the hierarchy, but they’re where the money and the latency live. A SKU (stock keeping unit) is the named tier and size: Standard_LRS for storage, B1 or P1v3 for an App Service plan. It sets price and capability — virtual-network integration on App Service needs Basic tier or higher, for instance.
Above all of these sit management groups, and the four scope levels — management group, subscription, resource group, resource — inherit downwards. A policy applied to the subscription reaches every resource group and resource in it; a policy on one resource group leaves its siblings alone.
Two resource-group rules are worth tattooing somewhere:
- Each resource lives in exactly one group, and it can be moved.
- Deleting a group deletes everything in it. This makes
az group deletea wonderful cleanup tool for a throwaway environment and a catastrophe in a group that also holds a shared database. Resources with a different lifecycle belong in a different group — and anything you can’t afford to lose deserves a resource lock.
Why a resource group has a location
It looks redundant, because resources can sit in other regions. The reason: the group stores metadata about its resources, and its location is where that metadata lives — which can matter for compliance. Control-plane operations (create, update, delete via the Resource Manager API) targeting resources in the group are routed through that location. It has nothing to do with the data plane: HTTP requests to your web app don’t touch it.
Portal, CLI, Bicep: what each is for
Everything you do goes through Azure Resource Manager (ARM), the single management layer that authenticates and authorises requests before forwarding them to the service. The portal, the az CLI, PowerShell and the SDKs all call the same API — so anything the portal can do, the CLI can do too.
| Tool | Use it for |
|---|---|
| Portal | Looking, diagnosing, reading logs, one-off emergency changes |
az CLI | Scripted investigation, one-off tasks, anything for a runbook |
| Bicep / Terraform | Anything that should still exist, identically, next month |
Click-ops — building infrastructure by clicking in the portal — fails not because clicking is wrong but because it leaves no artefact. Nobody can diff it, review it or reproduce it in a second environment, and the change is invisible to the next deployment. That gap between what your code says and what exists is configuration drift, and its most common form is a setting toggled during a 2am incident that silently disappears the next time anyone deploys the template.
The az CLI is the right tool for finding out what you actually have:
# What's in this subscription, grouped by type? (Start every audit here.)
az resource list --query "[].{name:name, type:type, group:resourceGroup, region:location}" -o table
# What SKU is that App Service plan really on, and how many instances?
az appservice plan show -g rg-research-prod -n asp-research-prod \
--query "{sku:sku.name, tier:sku.tier, workers:sku.capacity}" -o json
# What's the startup command and the Python version?
az webapp config show -g rg-research-prod -n app-research-api \
--query "{startup:appCommandLine, runtime:linuxFxVersion, alwaysOn:alwaysOn}"
What a Bicep file looks like
Bicep is a declarative language for describing Azure resources. Microsoft calls it a transparent abstraction over an ARM JSON template that loses none of the JSON’s capabilities: the Bicep CLI compiles your .bicep file to ARM JSON at deploy time. Anything valid in ARM JSON is valid in Bicep.
Three properties matter more than the syntax. It’s idempotent — deploy the same file many times and you get the same resources in the same state, so redeploying after a failure is normally safe. It orchestrates dependencies for you: use one resource’s symbolic name inside another and the ordering is inferred, no dependsOn needed. And there is no state file — Azure stores the state, so there’s nothing to lock, share or corrupt (this is the main day-to-day difference from Terraform, which keeps its own state and must have it protected).
Here is a small but real file: a Container App environment, an app, and the storage account its exports land in.
targetScope = 'resourceGroup'
param location string = resourceGroup().location
param env string = 'prod'
@description('Container image, e.g. crresearch.azurecr.io/api:sha-1a2b3c')
param image string
var tags = { workload: 'research-api', environment: env, managedBy: 'bicep' }
resource storage 'Microsoft.Storage/storageAccounts@2023-05-01' = {
// Globally unique, 3-24 chars, lowercase+digits only — hence uniqueString.
name: 'stexports${uniqueString(resourceGroup().id)}'
location: location
tags: tags
sku: { name: 'Standard_LRS' } // the cost line: check this first
kind: 'StorageV2'
properties: { allowBlobPublicAccess: false, minimumTlsVersion: 'TLS1_2' }
}
resource capEnv 'Microsoft.App/managedEnvironments@2026-07-01' = {
name: 'cae-research-${env}'
location: location
tags: tags
properties: {}
}
resource api 'Microsoft.App/containerApps@2026-07-01' = {
name: 'ca-research-api-${env}' // 2-32 chars, lowercase, starts with a letter
location: location
tags: tags
identity: { type: 'SystemAssigned' } // so it can reach storage without a key
properties: {
// Symbolic reference: Bicep infers the dependency, no dependsOn required.
environmentId: capEnv.id
configuration: { ingress: { external: true, targetPort: 8000 } }
template: {
containers: [ { name: 'api', image: image } ]
scale: { minReplicas: 1, maxReplicas: 10 } // 1, not 0: no cold start on a dashboard
}
}
}
output apiFqdn string = api.properties.configuration.ingress.fqdn
Naming and tagging, so future-you can navigate
Azure’s own naming rules are inconsistent and unforgiving, so learn the scopes rather than guessing:
| Type | Scope | Length | Characters |
|---|---|---|---|
Microsoft.Storage/storageAccounts | global | 3–24 | lowercase letters and numbers only |
Microsoft.KeyVault/vaults | global | 3–24 | alphanumerics and hyphens, starts with a letter |
Microsoft.Web/sites (web or function app) | global / per domain | 2–60 | alphanumeric and hyphens, not at either end |
Microsoft.Web/serverfarms (App Service plan) | resource group | 1–60 | alphanumeric and hyphens |
Microsoft.App/containerApps | resource group | 2–32 | lowercase, numbers, hyphens; starts with a letter |
resourcegroups | subscription | 1–90 | letters, digits, _ - . ( ); can’t end with a period |
The Cloud Adoption Framework publishes short prefixes so a name tells you what it is: rg resource group, asp App Service plan, app web app, func function app, ca container app, cae container apps environment, stapp static web app, st storage account, kv key vault, appi Application Insights, log Log Analytics workspace, cr container registry, vnet virtual network. Compose them as type-workload-environment-region: rg-surveys-prod-uksouth, appi-surveys-prod.
Tags are key-value metadata used mainly to slice cost reports — Environment = Production. Three facts agents get wrong:
- Resources do not inherit tags from their resource group or subscription. Tagging the group tags the group. To push tags down you need an Azure Policy assignment.
- Limits: 50 tag pairs per resource, group or subscription; name up to 512 characters, value up to 256.
- Tags are stored as plain text and surface in cost reports, exported templates and deployment histories. Never a connection string, never a key.
Reading an agent-generated template critically
Run the dry run first, every time. The what-if operation predicts changes without making any:
# Preview only.
az deployment group what-if -g rg-research-prod --template-file main.bicep --parameters @prod.bicepparam
# Preview, then be asked whether to go ahead (-c is the short form).
az deployment group create -g rg-research-prod --template-file main.bicep -c
It reports seven change types: Create, Delete, Ignore, NoChange, NoEffect, Modify and Deploy. A resource-level Delete is the one to stop at — it only appears in complete mode, meaning something that exists isn’t in the template and would be removed.
But read the output with a known bias. Microsoft documents what-if noise: properties absent from your template but set to defaults during deployment get reported as deletions even though the deployed resource will still have them. And what-if can’t evaluate non-deterministic functions (utcNow(), newGuid()), secure parameters, resource functions like listKeys(), or references to resources outside the template, so those always look like changes. Read the resource-level verbs first, then only the properties you actually declared.
Beyond what-if, here’s the checklist I’d apply to any template an agent hands me:
- Every SKU. This is where money hides.
P1v3whereB1would do, a Premium Container Apps workload profile, aStandard_GRSstorage account replicating globally for temp exports. - Secrets as plain parameters. A
param dbPassword stringwith a default value, or a connection string written into an app setting, means the secret is in Git and in the deployment history. It should be a Key Vault reference, or better, replaced by a managed identity:identity: { type: 'SystemAssigned' }plus a role assignment. - Public surface.
allowBlobPublicAccess: true,ingress.external: trueon an internal worker,publicNetworkAccess: 'Enabled'on a database. Agents default to open because open makes their demo work. minReplicas: 0on anything a human waits for, andminReplicas: 0on anything with no ingress (that one never starts at all).- Hard-coded names.
name: 'stexports'will collide globally; it should derive fromuniqueString(resourceGroup().id).location: 'eastus'hard-coded next to a UK resource group means cross-region latency and possibly a data-residency problem. - Missing
tags. No tags means the resource is invisible in cost reporting six months from now. - An unfamiliar
@API version. The version is part of the type string. A very old one may not support the property the agent then sets; a-previewone may change under you.
What to check in your own code
az resource list -o tableacross each subscription. Anything you can’t explain is either drift or someone else’s.- Grep your
.bicep/.tffiles forsku,tier,minReplicas,capacityand compare against the Cost Analysis blade. - Grep for
password,connectionString,key,secret,accountKeyin templates and parameter files. Anything that isn’t a Key Vault reference or a managed identity is a leak. - Grep for
allowBlobPublicAccess,publicNetworkAccess,external: trueand confirm each one is deliberate. - Check whether any resource group mixes lifecycles — a long-lived database alongside disposable app infrastructure. Split it, and lock the database.
- Run
az deployment group what-ifagainst production with the current template. If it wants to change things you didn’t change, you’ve found your drift. - Read the Deployments history on a resource group. A long gap since the last template deployment, plus recent portal edits, means the template no longer describes reality.
An agent's cleanup script runs `az group delete --name rg-research-prod --yes`. Your survey database lives in that group but was created by a different team. What happens?
Sources
- Microsoft — What is Azure Resource Manager? website The official definitions of resource, resource group, resource provider, scope levels, and the rules about resource-group location and deletion.
- Microsoft — What is Bicep? website Bicep's relationship to ARM JSON, idempotency, modules, and the fact that Azure stores the state.
- Microsoft — Bicep what-if: preview changes before deployment website The exact what-if commands, the seven change types, and the noise/false-positive caveats.
- Microsoft — Use tags to organize your Azure resources website Tag limits (50 per resource, 512/256 characters), no inheritance, and the warning that tags are plain text.
- Microsoft (Cloud Adoption Framework) — Abbreviation recommendations for Azure resources website The canonical resource-type prefixes: rg, asp, app, func, ca, cae, st, kv, appi, log, cr, vnet.