Week 14 - Infrastructure as Code

Why use Infrastructure as Code

IaC concepts

Azure Bicep

Bicep in practice

Deploy Bicep from CI/CD

Practice

Assignment

Gotchas & Pitfalls

Glossary: Week 14

Career relevance: Week 14

History of IaC

Slides (PDF)

Bicep in practice

Your Azure Bicep template deploys one storage account. Real templates provision several related resources, keep secrets out of source control, and get re-run safely by a pipeline. This chapter covers the practices that get you there: modules, nested child resources, what-if, secure parameters, and teardown, and points to CI/CD for the next step.

By the end of this chapter, you should be able to:

Modules

Continue in azure-bicep-reference from your finished Azure Bicep template (keep the Bicep VS Code extension from that chapter installed):

cd azure-bicep-reference   # skip if your terminal is already here
git switch week-14-ch-4-bicep
export CLASS_RG=rg-hyf-students

That branch starts as the Azure Bicep solution (single main.bicep). By the end of this chapter your working tree should look like this:

azure-bicep-reference/
├── main.bicep                 # thin entry: params + module call (+ optional @secure param)
└── modules/
    └── storage.bicep          # storage account + nested blob container

Refactor into that shape step by step below. Compare with week-14-ch-4-bicep-solution when stuck.

As a template grows, a single file becomes hard to read. A module (a Bicep file you call from another Bicep file) works like a Python module: put the resource in its own file, then call it from main.bicep the way you import a helper. Put the storage account in its own file:

// modules/storage.bicep
param location string
param storageName string

resource storage 'Microsoft.Storage/storageAccounts@2026-04-01' = {
  name: storageName
  location: location
  sku: { name: 'Standard_LRS' }
  kind: 'StorageV2'
}

output storageId string = storage.id

Then call it from your main template, once per environment or once per resource you need:

// main.bicep
param location string = resourceGroup().location
param storageName string

module storage 'modules/storage.bicep' = {
  name: 'storageDeploy'
  params: {
    location: location
    storageName: storageName   // still a parameter: pass it at deploy time, same idea as before
  }
}

// re-export so az shows the id on the parent deployment
output storageId string = storage.outputs.storageId

This is the same "define once, reuse" instinct behind functions in Week 2 and dbt models in Week 10, applied to infrastructure. A module you trust becomes a building block you stop thinking about.

<aside> ⌨️ Hands on: On week-14-ch-4-bicep, create modules/storage.bicep, replace main.bicep with the module-calling sample above, and deploy once into $CLASS_RG with your unique storageName. Confirm it reports Succeeded. Diff against week-14-ch-4-bicep-solution if you get stuck.

</aside>

If the flags feel noisy, walk through the same module deploy in the terminal recording below.

https://gist.githack.com/lassebenni/8bdf60377fddbccfee4e65aef2d50d00/raw/week_14__az_bicep_module_deploy_terminal.html

That is the module split in one deploy: thin main.bicep, storage details in the module file.

<aside> 💡 Recap: Your storage account now lives in a module. main.bicep only calls it, and a deploy into rg-hyf-students still reports Succeeded.

</aside>

Nested child resources

A storage account alone is rarely enough. In Week 6 you put blobs in a container inside an account. In Bicep that container is a nested child resource. Azure creates it under the parent you name with parent:.

Extend modules/storage.bicep after the storage resource:

param containerName string = 'raw'

resource blobService 'Microsoft.Storage/storageAccounts/blobServices@2026-04-01' = {
  parent: storage
  name: 'default'
}

resource container 'Microsoft.Storage/storageAccounts/blobServices/containers@2026-04-01' = {
  parent: blobService
  name: containerName
  properties: {
    publicAccess: 'None'
  }
}

The blobService named default is the built-in blob endpoint every storage account has; the container hangs off that.

Pass containerName from main.bicep the same way you pass storageName. This parent/child pair is the locked student stack for the week: one account, one nested container. Do not reach for Postgres, Key Vault, or other services here.

<aside> ⚠️ parent: only accepts a resource declared in the same file, never a module. Swap your storage resource for a published module (see Modules at scale) and the nested container stops compiling: a module exposes name, identity and outputs, so there is no .id for parent: to use. Published modules take their containers as a parameter instead.

</aside>

That distinction only bites if you go looking for published modules, which is optional this week. For now, build both resources yourself.

<aside> ⌨️ Hands on: Add the nested container to modules/storage.bicep, wire containerName through main.bicep, and redeploy. In the portal, open the storage account and confirm the container exists under Containers. Deleting the account later removes the container with it.

</aside>

Azure portal Containers blade for sthyfw14lab, filtered to the nested blob container raw (Private). Open your own sthyf… account the same way: storage account → Data storage → Containers.

Azure portal Containers blade for sthyfw14lab, filtered to the nested blob container raw (Private). Open your own sthyf… account the same way: storage account → Data storage → Containers.

Your portal view should list the container name you passed (often raw).

<aside> 💡 Recap: The module now owns account + nested container. You saw the container under Containers in the portal after redeploy.

</aside>

Preview with what-if

Before you deploy a change to infrastructure that already exists, you want to know exactly what will change. what-if (an Azure CLI preview of what a deploy would create or change) shows you, without touching anything.

Run from the repo root of azure-bicep-reference (where main.bicep lives):

cd azure-bicep-reference   # skip if your terminal is already here
export CLASS_RG=rg-hyf-students

# az deployment group what-if = preview an ARM deployment at resource-group scope (no apply)
#   --resource-group  which resource group (rg-hyf-students)
#   --template-file   the Bicep file to compile and preview
#   --parameters       same params you would pass to create
az deployment group what-if \
  --resource-group "$CLASS_RG" \
  --template-file main.bicep \
  --parameters storageName=sthyf<yourname>

It prints a diff of what would change. Under the usual incremental deploy you will see creates (+) and modifications (~) most often. A delete (-) is uncommon here: standard Bicep deploys are additive, so removing a resource from the template does not make what-if offer to delete it. That is your defense against surprises, and it is also how you detect drift: if what-if shows changes you did not expect, someone edited the live resource by hand. Read the what-if output before every deploy to shared infrastructure.

A first preview against an empty name (nothing deployed yet) looks like a create:

https://gist.githack.com/lassebenni/c75ab27d3a1d7ee6d834a9884f243c30/raw/week_14__az_bicep_whatif_create_terminal.html

A second run with an unchanged template, back when the template was only a storage account, looks like this (exact wording varies by CLI version). The legend always lists the full symbol set, even when this run only uses =:

Resource and property changes are indicated with these symbols:
  + Create
  - Delete
  ~ Modify
  = Nochange
  * Ignore

The deployment will update the following scope:

Scope: /subscriptions/.../resourceGroups/rg-hyf-students

  = Microsoft.Storage/storageAccounts/sthyfyourname [2026-04-01]

Reading a noisy preview

Once you add the nested blob container, an unchanged template stops printing that clean result. rg-hyf-students is shared, so your preview also lists classmates' storage accounts with * (Ignore). Expect something closer to this, even straight after a successful deploy:

Resource and property changes are indicated with these symbols:
  + Create
  - Delete
  ~ Modify
  = Nochange
  * Ignore

The deployment will update the following scope:

Scope: /subscriptions/.../resourceGroups/rg-hyf-students

  ~ .../blobServices/default [2026-04-01]
    - properties.deleteRetentionPolicy.allowPermanentDelete: false
    - properties.deleteRetentionPolicy.enabled: false

  ~ .../blobServices/default/containers/raw [2026-04-01]
    - properties.defaultEncryptionScope:      "$account-encryption-key"
    - properties.denyEncryptionScopeOverride: false

  = Microsoft.Storage/storageAccounts/sthyfyourname [2026-04-01]

  * Microsoft.Storage/storageAccounts/sthyfclassmate1
  * Microsoft.Storage/storageAccounts/sthyfclassmate2

Resource changes: 2 to modify, 1 no change, 2 to ignore.

Nothing is wrong. Azure fills in defaults on the live container that your template never mentions, so what-if keeps reporting the gap between "what the file says" and "what Azure returns". Three things to read carefully:

So "clean no change" is not a success criterion once containers are in your template. What you want to see is: no + create for a resource you already have, and no ~ on a property you declared. Ignore lines for classmates are expected noise in rg-hyf-students.

When you change a tag or SKU on an already-deployed account, what-if reports a modification on the account itself:

https://gist.githack.com/lassebenni/ea50abd66795fc52c7ebe1e573c1d3d9/raw/week_14__az_bicep_whatif_modify_terminal.html

That ~ line is the planned change: read it before you apply.

<aside> ⌨️ Hands on: With the module already deployed, run what-if again with no changes to the template. The storage account line should read =, with container noise below it. Now bump the SKU or add a tag in the module and re-run what-if: see the account line switch to ~ with your change on it, before you apply.

</aside>

You now have both shapes of preview: idle (account =, containers noisy), and a planned modification you can point at.

<aside> 💡 Recap: Unchanged template → account reads =. Edit SKU or tags → the account line shows ~ with your change. Ignore * classmate lines and the - property lines under the containers. Always read the preview on shared infrastructure.

</aside>

⌨️ Hands on: make drift and catch it

Both previews above reacted to a change you made in the file. Drift is the other direction: someone changes the live resource by hand, and your template does not know. Here you play that someone, then catch yourself.

Step 1: Give the account something your template owns. In modules/storage.bicep, add an environment parameter, use it as a tag, and deploy once:

param environment string = 'dev'

resource storage 'Microsoft.Storage/storageAccounts@2026-04-01' = {
  name: storageName
  location: location
  sku: { name: 'Standard_LRS' }
  kind: 'StorageV2'
  tags: {
    Environment: environment
  }
}

A parameter rather than a hardcoded 'dev' on purpose: this is the same pattern assignment Task 1 asks for, so you write it once and reuse it.

This matters: what-if only reports drift on properties your template declares. Change something the file never mentions and an incremental deploy leaves it alone, so nothing shows up.

Step 2: Now play the colleague. In the portal, open your storage account, go to Tags, change Environment from dev to staging, and click Apply.

Your template still says dev, so the file and reality now disagree.

Step 3: Change nothing in your template. Run the same preview as before:

az deployment group what-if \
  --resource-group "$CLASS_RG" \
  --template-file main.bicep \
  --parameters storageName=sthyf<yourname>

Step 4: Read the output. You should see a ~ on the tag, with the live value on one side and the value your template wants on the other:

  ~ Microsoft.Storage/storageAccounts/sthyfyourname [2026-04-01]
    ~ tags.Environment: "staging" => "dev"

That is detection. You found a hand-made change without knowing in advance that it happened.

<aside> ⚠️ Do not try this with Redundancy instead of a tag. Switching a storage account to geo-redundant storage starts a replication conversion that can lock the account against deletion for hours, and the Redundancy blade needs permissions your student role does not grant.

</aside>

Step 5: Deploy the unchanged template with az deployment group create and the same parameters. The template wins and the tag goes back to dev. That is correction.

Step 6: Run what-if once more. The account line is back to =, because the file and reality agree again. The container - property lines are still there; ignore them, as above.

The portal edit did not survive the next deploy, and nobody reading your template would ever have known it happened. That is the whole reason for the rule you met in IaC concepts: change the file, never the portal.

<aside> 💡 Recap: what-if on an unchanged template is your drift check. If it reports changes you did not write, someone edited the live resource; re-deploying puts your template back in charge.

</aside>

Keeping secrets out of templates

Some resources need secrets: a database needs an admin password. Never write that password as a literal in a .bicep file, because the file goes into git, and a secret in git is a secret leaked. Bicep gives you two safe options.

Mark the parameter as secure with secure parameters (@secure()), so its value is never logged or stored in deployment history:

@secure()
param dbAdminPassword string

That @secure() pattern is what you practice this week (dummy values are fine in class). Better still for real secrets, reference Azure Key Vault (Azure's managed secret store) so the value never touches your template or your terminal at all: the template points at the vault, and Azure fetches the secret at deploy time. You already used that discipline when you fetched your Postgres URL from Key Vault in Week 12's sequential pipelines. You do not wire a Key Vault reference into Bicep in this chapter; the Extra reading link shows the parameters-file shape when you need it later.

<aside> ⚠️ A @secure() parameter still has to be supplied at deploy time. Supplying it as a plain command-line argument leaves it in your shell history. For anything real, use a Key Vault reference in a parameters file instead of typing the secret.

</aside>

Do not take that on trust. Right after a deploy that passed the parameter, run:

history | tail -3

The password is sitting there in plain text. @secure() guards Azure's deployment records, not your laptop.

Practice the decorator once with a disposable dummy value so you feel the CLI shape without putting a real password anywhere. Use what-if (not create) so the dummy never needs a real apply.

<aside> ⌨️ Hands on: In main.bicep, add @secure() param dbAdminPassword string (do not give it a literal default). Run what-if once with a dummy value so you see the deploy still validates, for example --parameters storageName=sthyf<yourname> dbAdminPassword=not-a-real-secret. Then drop the habit of putting secrets on the command line: prefer a parameters file for anything that matters. Do not commit real secrets.

</aside>

If the flags and the unused-param warning feel noisy, walk through the same preview in the terminal recording below.

https://gist.githack.com/lassebenni/7bb3986225d9a10dd4b6db72567f12cc/raw/week_14__az_bicep_secure_whatif_terminal.html

Treat that dummy run as practice only; remove the parameter again if you do not need it for later steps.

<aside> 💡 Recap: @secure() keeps the value out of deployment history. A plain CLI argument can still land in shell history, so dummy only for class, Key Vault for real secrets.

</aside>

Tearing down

Bicep deployments are additive (they create or update declared resources, but do not delete ones you removed from the file). Deploying never deletes a resource just because you removed it from the template. To actually remove resources, you delete them explicitly.

Your student path is to delete the individual resources you created. From the repo root (or any directory; this command does not need main.bicep):

export CLASS_RG=rg-hyf-students

# az resource delete = remove one live resource by name + type (not "delete from the template")
#   --resource-group   rg-hyf-students
#   --name             the storage account you created
#   --resource-type    ARM type of that account
az resource delete \
  --resource-group "$CLASS_RG" \
  --name sthyf<yourname> \
  --resource-type Microsoft.Storage/storageAccounts

<aside> ⌨️ Hands on: When you are done with this chapter's lab account, delete your storage account with the command above (use your real sthyf… name). Confirm it is gone from the portal under rg-hyf-students. The nested container disappears with the account.

</aside>

If a silent success feels odd, walk through the same teardown in the terminal recording below.

https://gist.githack.com/lassebenni/1a03f9c1e0cc339a63177a56f2ff577d/raw/week_14__az_bicep_teardown_terminal.html

Confirm the name is gone from the resource group list before you move on.