Welcome to Week 14

Infrastructure as Code with Azure Bicep

👋 Before we start

(Quick sense of the room: who has az login working against the HYF tenant? Who still hits a 403 on anything?)

az login
export CLASS_RG=rg-hyf-students
git clone https://github.com/lassebenni/azure-bicep-reference.git

Confirm az account show points at the shared subscription. If it does not, pick it:

az account list --all --output table
az account set --subscription "<the shared HYF subscription>"

Slides spell the group out in full so you can copy any command as-is. The chapters use $CLASS_RG for the same value.

You do not create resource groups. You deploy into rg-hyf-students.

🧭 The theme

From clicking the portal to declaring infrastructure

🧭 Where this week sits

Weeks 6 through 13 used Azure resources someone else provisioned: the Postgres database, the storage account, the Container Apps job, the Airflow VM.

In Week 6 you ran az group list and found you were deliberately blocked from creating one.

Week 14 is the week you author that infrastructure:

A resource you clicked into existence has no record of why it looks the way it does. A resource declared in a file does.

🧭 Why is a data track teaching infrastructure?

Because the wall you hit is never SQL.

It is "I need a container for the curated layer", "staging needs its own database", "the job needs a schedule". On a small team there is nobody else to ask, and on a big one you wait days for a ticket unless you can open a pull request against the platform repo.

Data engineers are not expected to design landing zones. They are expected to read the template, change it, and not break it.

🧭 What the job market actually says

From the Dutch postings behind this week's Career relevance page:

Role Mentions IaC
Data engineer ~13% (~18% of junior / trainee)
Data platform engineer Terraform on ~30%+
DevOps / cloud platform ~46%
Data analyst / scientist ~0 to 2%

So: not the headline skill next to SQL, Python and Azure. It shows up as "Terraform or Bicep is a plus".

A plus you can evidence beats a plus you claim. By the end of this week you have a PR with a real deploy in it.

🧭 What you can say by end of the session

  • Why portal-only setup drifts, and why IaC fixes reproducibility
  • Declarative desired state vs an imperative click-script
  • Deploy a Bicep template into rg-hyf-students with the Azure CLI
  • Refactor into a module, nest a container, read a what-if diff, tear down
  • Keep secrets out of git with @secure()
  • Map Week 5 CI habits onto Bicep: preview on PR, apply on merge

📅 Today: first half (0:00 → 1:07)

Time Activity Duration
0:00 Welcome + az login / resource-group check 8 min
0:08 Assignment frame 7 min
0:15 Ch1 Why use Infrastructure as Code 10 min
0:25 Ch2 IaC concepts 15 min
0:40 Ch3 Azure Bicep: live build 27 min
1:07 Break 10 min

📅 Today: second half (1:17 → 3:00)

Time Activity Duration
1:17 Ch4 Bicep in practice: live build 30 min
1:47 Ch5 Deploy Bicep from CI/CD 18 min
2:05 Assignment launch + scaffold tour 15 min
2:20 Live Quiz (one mixed set, 12 questions) 14 min
2:34 Buffer / Q&A / Week 15 preview 26 min

🎯 Where this is heading

After this session you fork c55-data-week-14 and extend a Bicep template that already exists: an environment tag, a second container, real deploy evidence, a write-up.

Everything today is a piece of that. Four landmarks to listen for:

You will need Comes from
A template that deploys Ch3
A module and a nested container Ch4
A what-if diff you can read Ch4
A CI workflow (optional) Ch5

We come back to the full hand-in at the end, just before the quiz.

📖 Chapter 1

Why use Infrastructure as Code

📖 Three failure modes of clicking

Picture one Azure VM created through the portal:

  • Drift. Someone resizes it by hand during an incident. Now the live VM matches nobody's notes.
  • No reproducibility. "Make staging identical to production" becomes an afternoon of careful clicking, and it never quite is.
  • Onboarding friction. "How is this deployed?" answered with "click these forty things, mostly in this order."

There is no artifact to read, review, or hand over.

📖 The same VM, declared

resource vm 'Microsoft.Compute/virtualMachines@2023-09-01' = {
  name: 'vm-hyf-demo014'
  location: location
  properties: {
    hardwareProfile: {
      vmSize: 'Standard_B1ls'   // change this here, then redeploy
    }
  }
}

The size lives in the file, not in someone's memory. Deploying is one command.

📖 And the portal agrees

📖 What this is actually for

A storage account is a small example on purpose. Every Azure resource you have used since Week 6 was written down by someone, in exactly this way.

The skill does not change with the resource type: a Databricks workspace is a different resource line in the same file, deployed with the same command.

📖 Bicep is one of four names you will hear

Tool Clouds You write Who tracks state
Bicep Azure Bicep DSL → ARM Azure does
Terraform any HCL a state file you operate
Pulumi any Python, TS, Go a state file
AWS CDK AWS Python, TS compiles to CloudFormation

We use Bicep because this track is Azure-first, and because Azure keeps the state itself: nothing extra to store, lock, or corrupt.

In NL job ads Terraform is the name you will see most (~41% of DevOps postings, ~10% of data engineer ones). The ideas today transfer: desired state, idempotency, preview before apply. The syntax is the small part.

📖 Chapter 2

The ideas that make IaC work

📖 Imperative: a script of steps

After portal clicking, teams put az commands in a bash file and commit it:

az storage account create \
  --name st-example-do-not-run \
  --resource-group rg-hyf-students \
  --location westeurope \
  --sku Standard_LRS

Better than keeping it in your head: it lives in git and a teammate can read it.

But it is still a list of steps, not a description of a result.

⌨️ Watch: what the script can and cannot do

I run the create from the last slide, then the exact same command again:

az storage account create --name sthyfw14demo \
  --resource-group rg-hyf-students --location westeurope --sku Standard_LRS
WARNING: A storage account with the provided name sthyfw14demo is found.
         Will continue to update the existing account.
Succeeded

No error. So: did anything just change?

⌨️ Now I change one setting

az storage account update --name sthyfw14demo \
  --resource-group rg-hyf-students --tags Environment=prod

It applies the change and tells me afterwards. It acts, then reports.

And notice why, because it is not about the CLI being limited:

To tell you what is about to change, something has to know what you want. A one-off create never says what the end state should be, so there is nothing to compare reality against.

A declared desired state can be diffed. A list of steps cannot.

⌨️ Together: same lesson, safely (4 min)

Not the account create: that leaves a resource no template owns, in a shared group.

We run this instead, on the lab account. I go first, you follow:

export MY_CONTAINER=lab-<your-github-handle>

Do: run the create twice, then list.

az storage container-rm create --storage-account sthyfw14lab \
  -g rg-hyf-students --name $MY_CONTAINER --query name -o tsv

az storage container-rm list --storage-account sthyfw14lab \
  -g rg-hyf-students --query "[].name" -o tsv

Success check: the second create prints your container name again rather than an error, and the list shows it exactly once.

⌨️ Clean up, then answer this

az storage container-rm delete --storage-account sthyfw14lab \
  -g rg-hyf-students --name $MY_CONTAINER --yes

It prints nothing and exits 0. Confirm with the same list command: your name is gone.

Now the question that matters:

Both creates succeeded. What told you what the second one was going to do?

Nothing did. That is the gap between az commands and Bicep.

📖 Declarative: describe the end state

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

You do not say how. The tool compares what you declared against what exists, and works out the steps.

You already know this split: SQL SELECT is declarative, a Python loop that filters by hand is imperative.

📖 Side by side

Imperative (az script) Declarative (Bicep)
You write Steps, in order Desired state
Re-run Succeeds, but silently updates Applies only what differs
Preview None what-if prints the diff
Portal edit Script does not notice Re-deploy brings it back
Remove a resource from the file The resource stays in Azure The resource stays in Azure

Only the last row is the same on both sides, and it catches everyone: neither approach deletes. Teardown is always its own step (Chapter 4).

📖 One control plane underneath

Portal clicks, az commands, and Bicep deploys all end up calling the same Azure Resource Manager APIs. The difference is what you hold, not what Azure receives.

📖 Idempotency: a name for what you already saw

Two things this hour ran twice without complaining: az storage account create, and your container-rm create. That property has a name.

Idempotent: running an operation once and running it five times leave you in the same place.

Pressing a lift button five times does not summon five lifts.

For a declarative deploy it holds across the whole file at once:

  • Nothing exists yet → everything is created
  • Deploy the same template again → nothing meaningful changes
  • Someone deleted one resource by hand → only that one comes back

That is what makes a deploy safe to re-run, and safe to put in CI on every merge.

📖 Drift, and the two tools against it

Drift is the gap between your template and what is actually running, usually because someone changed something by hand.

  • Detection: what-if shows the difference before you change anything.
  • Correction: re-deploying brings templated resources back in line.

It does not delete extra resources created outside the template. That stays an explicit teardown.

The discipline: change the file, never the portal.

⌨️ Together: is your old pipeline idempotent? (3 min)

Think of one ingestion script you wrote earlier in the track.

I read the three options, you call out which one yours does:

Your script does Where you have seen it Run it a second time
CREATE TABLE IF NOT EXISTS your Week 9 SQL setup Table survives, rows untouched. Idempotent.
Drop and rebuild the whole table dbt's table materialization, Week 10 Same end state every time, but it rewrites everything. Idempotent, just expensive.
Plain CREATE TABLE, or an INSERT with no key a first-draft ingest script Errors with "already exists", or silently doubles your rows. Not idempotent.

Nobody writes anything down. Three or four answers out loud is enough.

📖 Chapter 3

Azure Bicep: your first deploy

📖 Start here

git switch week-14-ch-3-bicep

main.bicep is a stub with TODOs. We fill it in together.

Install the Bicep VS Code extension if you have not: it gives you completion and inline errors for every resource type.

📖 The extension

📖 Bicep is a DSL, not a language like Python

Python is general purpose. Bicep is a domain-specific language, built for one job: declaring Azure resources.

Python Bicep
Built for anything declaring Azure resources
You describe the steps to take the end state you want
Loops, classes, I/O yes no classes, no I/O; loops only over resources
Runs on your machine it compiles; Azure applies it
Imports needed none, functions ship with the language
Failure mode wrong output wrong infrastructure, still running next week

📖 The whole language, on one slide

Bicep has a small vocabulary. Four words carry almost everything you will write this week:

Keyword What it is Set at deploy time?
param an input to the template yes, that is the point
var a value reused inside the file no, fixed when you write it
resource a thing that should exist in Azure it is the goal, not a step
output a value handed back after a deploy returned, not supplied

There is a fifth, targetScope, which says whether a file applies to a resource group, a subscription, or wider. You will not write it this week: resource group is the default, so leaving it out means exactly what you want.

That is the list. The five TODOs coming up are one of each, in that order.

⌨️ What you start with

// main.bicep: Chapter 3 starter
// TODO: add param location (default resourceGroup().location)
// TODO: add param storageName (required, no default)
// TODO: add var storageKind = 'StorageV2'
// TODO: add resource storage (Microsoft.Storage/storageAccounts@2026-04-01)
// TODO: add output storageId = storage.id

Five TODOs. We do them one at a time, and each one is a Bicep concept.

Nothing else: no imports, no boilerplate, no main(). A Bicep file that declares one resource is six lines long.

⌨️ TODO 1: param location

param location string = resourceGroup().location

param = a value supplied at deploy time. Type comes after the name.

The = makes it a default, so you only pass it when you want something else.

Where does resourceGroup() come from? It is built into the language. Bicep has no imports: the functions ship with the compiler, and this one asks Azure about the group you are deploying into. The full list is Bicep functions; resourceGroup() lives under scope functions.

Nobody has to remember "we use West Europe". The group already knows.

⌨️ TODO 2: param storageName

param storageName string

No =, so no default: this one is required. Leave it out and the deploy stops before it touches Azure.

Storage names are globally unique, so there is no sensible default to give you.

So where does the value come from? Not from the file. You hand it in on the command line when you deploy, which we do in a few minutes:

az deployment group create \
  --resource-group rg-hyf-students \
  --template-file main.bicep \
  --parameters storageName=sthyf<yourname>   # <-- fills the param above

Every param without a default has to show up on that last line, or the deploy refuses to start.

⌨️ TODO 3: var storageKind

var storageKind = 'StorageV2'

var = a value used inside this file. You cannot pass it at deploy time, and that is the point.

Set at deploy time Use it for
param yes things that differ per environment
var no things that must stay the same

Ask yourself which one a value is, every time.

⌨️ TODO 4: resource

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

storage is the symbolic name: how this file refers to it, not the name in Azure. That is name:.

You pick it, like a variable name: letters, digits, _, unique in the file. It never reaches Azure.

The string is type@apiVersion. The date is not decoration and not optional.

sku and kind are not ours either: every resource type publishes a schema, and those are its properties.

📖 Why a date in the middle of the type

'Microsoft.Storage/storageAccounts@2026-04-01'

Azure ships new resource properties constantly. Pinning the API version means your template keeps meaning the same thing next year as it does today.

Leave it out and Bicep refuses to compile: BCP029.

It is the same instinct as pinning a package version in requirements.txt.

📖 So how would you find any of this yourself?

There is nothing to import and nothing to memorise. Three places, in the order you will actually reach for them:

You need Go to
What properties does this resource take? Bicep resource reference
What functions exist (resourceGroup(), uniqueString(), …)? Bicep functions
Right now, while typing The VS Code extension: Ctrl+Space completes types, versions and properties

The extension is the fastest of the three, and it reads the same schema the docs are generated from. If it does not offer a property, that property does not exist.

⌨️ TODO 5: output

output storageId string = storage.id

output = a value handed back after a successful deploy.

storage.id reads a property off the resource you just declared. That is what the symbolic name is for.

Outputs matter more later: in Chapter 4 a module returns its storageId to the file that called it.

📖 All five, together

param location string = resourceGroup().location
param storageName string

var storageKind = 'StorageV2'

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

output storageId string = storage.id

Inputs, a constant, the thing that should exist, and what comes back. That is a Bicep file.

📖 Read the command before you run it

az deployment group create

Three separate ideas, not one phrase:

Word Means
deployment an ARM job that applies a template and leaves a record you can go and read
group the scope: run that job inside a resource group, not across the subscription
create start it now

There is no such thing as a "deployment group". You create a deployment, at resource-group scope.

⌨️ See it yourself

az bicep build --file main.bicep --stdout
"parameters": {
  "location": { "type": "string",
                "defaultValue": "[resourceGroup().location]" },
  "storageName": { "type": "string" }
},
"variables": { "storageKind": "StorageV2" },
"resources": [ {
    "type": "Microsoft.Storage/storageAccounts",
    "apiVersion": "2026-04-01",
    "name": "[parameters('storageName')]"
} ]

This is what Azure actually receives. Works signed out: it is a local compile.

📖 What the compiler did to your five lines

You wrote Azure receives
param location an entry in parameters, default kept as an expression
param storageName an entry in parameters, no default
var storageKind an entry in variables
resource storage '…@2026-04-01' an item in resources, with type and apiVersion split apart
output storageId an entry in outputs
  • The symbolic name storage is gone: it was only ever for your file.
  • resourceGroup().location was not evaluated. Azure resolves it at deploy time.

⌨️ Live: your first Succeeded

Start: git switch week-14-ch-3-bicep

Do:

az deployment group create \
  --resource-group rg-hyf-students \
  --template-file main.bicep \
  --parameters storageName=sthyf<yourname>   # <-- TODO 2's required param

Success check: the output contains "provisioningState": "Succeeded".

location is not on that line: it defaulted to the group's region, exactly as TODO 1 set it up.

💭 Done early? git switch week-14-practice-exercise-env-tags and add an environment parameter. That is assignment Task 1.

Solution check: week-14-ch-3-bicep-solution

📖 Why did that need --resource-group?

Because Azure has no default location. Every command has to say where.

You met the hierarchy in Week 6. Here is where each level attaches to what you just typed.

📖 Three rules for the name

Storage account names are:

  • globally unique across all of Azure
  • lowercase, letters and numbers only
  • 3 to 24 characters

A name clash is not a broken template. Pick another name.

Use sthyf<yourname> so you do not collide with the person next to you.

📖 The portal now agrees

📖 And the deployment is on the record

Break

10 minutes

📖 Chapter 4

Modules, nesting, preview, teardown

📖 Start here

git switch week-14-ch-4-bicep

That branch starts as the Chapter 3 solution: one main.bicep, no module yet.

By the end of this section:

main.bicep            # thin: params + module call
modules/
└── storage.bicep     # storage account + nested container

⌨️ Step 1: move the resource out

Cut the resource block from main.bicep into a new file, modules/storage.bicep, and give it its own params:

// 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'
}

A module is just a .bicep file. Nothing marks it as special.

⌨️ Step 2: call it from main.bicep

module storage 'modules/storage.bicep' = {
  name: 'storageDeploy'
  params: {
    location: location
    storageName: storageName
  }
}

module takes a path, and params: fills that file's params.

name: is the deployment name Azure records for this module. You will see storageDeploy in the Deployments blade next to the parent.

⌨️ Step 3: hand a value back

The module ends with an output, and main.bicep reads it:

// in modules/storage.bicep
output storageId string = storage.id

// in main.bicep
output storageId string = storage.outputs.storageId

Note .outputs. in the middle: storage here is the module, not the resource. The resource lives in the other file now, and main.bicep can only see what that file returns.

Params in, outputs out: the same contract as a Python function.

⌨️ Step 4: nest a container inside the account

Still in modules/storage.bicep, below the account:

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

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

parent: takes the symbolic name of the resource above it. That is what the symbolic name was for.

📖 Why two resources for one container

Every storage account has a built-in blob endpoint called default. You cannot skip it: a container hangs off the blob service, not off the account.

storage account
└── blobServices 'default'
    └── container 'raw'

The type strings say the same thing, one segment per level:

Microsoft.Storage/storageAccounts
Microsoft.Storage/storageAccounts/blobServices
Microsoft.Storage/storageAccounts/blobServices/containers

⌨️ Step 5: deploy what you just built

Four edits in, nothing has run yet. Deploy before you add anything else:

az deployment group create \
  --resource-group rg-hyf-students \
  --template-file main.bicep \
  --parameters storageName=sthyf<yourname>

Success check: provisioningState: Succeeded, and the portal lists raw under Containers.

Notice what you did not pass: containerName has a default, and the module gets its values from main.bicep, not from you.

⌨️ Your turn: same five steps (12 min)

You watched it. Now do it in your own clone.

Success check: Succeeded, and raw under Containers in the portal.

Behind? Catch up in one command:

git switch week-14-ch-4-bicep-solution

Then read the diff against your own attempt rather than starting over:

git diff week-14-ch-4-bicep-solution -- modules/storage.bicep

💭 Done early? git switch week-14-practice-exercise-secure-param for the @secure() drill.

📖 Container in the portal

📖 The same shape, at real scale

Microsoft publishes Azure Verified Modules: production-grade Bicep modules for Azure services, maintained in the open at Azure/bicep-registry-modules.

77 resource modules, and the list reads like the platform diagram you just saw:

avm/res/storage/storage-account
avm/res/db-for-postgre-sql/flexible-server
avm/res/key-vault/vault
avm/res/databricks/workspace
avm/res/data-factory/factory
avm/res/event-hub/namespace

Someone already wrote the module. It is versioned, tested, and public.

📖 Same keyword, someone else's file

module storage 'br/public:avm/res/storage/storage-account:0.33.0' = {
  name: 'storageDeploy'
  params: { name: storageName, location: location }
}

That is the same module keyword you just wrote. Only the path changed: instead of modules/storage.bicep on your disk, it points at a published module and a version.

There is no advanced dialect waiting for you. A platform team writes the lines you just wrote, then stops writing the common ones.

⚠️ Look, do not swap. A module is not a resource: parent: cannot point at one, and it has no .id. With AVM you pass containers into the module (blobServices: { containers: [...] }) instead of nesting them beside it.

⌨️ Step 6: keep a secret out of the file

A real stack needs a database password. Never as a literal: the file goes to git, and a secret in git is a leaked secret.

In main.bicep:

@secure()
param dbAdminPassword string

Nothing uses it this week. It is here so the habit exists before the week you need it.

⌨️ Step 7: deploy with it, and watch what happens

az deployment group create \
  --resource-group rg-hyf-students \
  --template-file main.bicep \
  --parameters storageName=sthyf<yourname> \
               dbAdminPassword=not-a-real-secret

Two things to point at in the output:

  • Warning no-unused-params for dbAdminPassword. Expected: we declared it and never used it.
  • The value itself appears nowhere in the result, and nowhere in the portal's deployment record. That is @secure() doing its job.

⚠️ What @secure() did not protect

You just typed the password into your terminal. Press the up arrow:

history | tail -3

There it is. @secure() guards Azure's records, not your laptop.

For anything real: a parameters file kept out of git, or a Key Vault reference so the value never reaches your shell at all. A throwaway value is fine this week. A real one never is.

⌨️ Step 8: preview before you apply

Change nothing. Run the preview against what you just deployed:

az deployment group what-if \
  --resource-group rg-hyf-students \
  --template-file main.bicep \
  --parameters storageName=sthyf<yourname>

Expect it to look messy, not clean. That is the lesson on the next slide.

📖 Why an unchanged template still reports changes

  ~ .../blobServices/default
    - properties.deleteRetentionPolicy.enabled: false
  = Microsoft.Storage/storageAccounts/sthyfyourname
  * Microsoft.Storage/storageAccounts/sthyfclassmate

Resource changes: 1 to modify, 1 no change, 1 to ignore.
  • A - under a ~ is a property, not a deletion
  • = is your account matching the template. That is the line to read
  • * is a classmate's account in the shared group

Azure fills in defaults your template never mentions, so the container never reads clean.

⌨️ Now make it say something useful

Change the live account by hand, the way a colleague would:

az storage account update -g rg-hyf-students \
  -n sthyf<yourname> --tags Environment=staging

Re-run the same what-if. Nothing in the file changed, but now:

  ~ Microsoft.Storage/storageAccounts/sthyfyourname
    ~ tags.Environment: "staging" => "prod"

Left is what Azure has, right is what your file wants. That is drift, caught before you apply anything.

Re-deploy and it goes back. The portal edit does not survive.

📖 Why did it not just delete it?

Because ARM deploys in incremental mode by default. Your template means "these should exist", not "only these should exist".

Look at where you are deploying and that default stops being an inconvenience:

rg-hyf-students holds ten students' work. If a deploy removed everything not in your file, your first main.bicep would wipe the whole class.

There is a --mode Complete that does delete. Microsoft marks it not recommended, and now you know why.

⌨️ Step 9: tear it down yourself

So removal is a separate, deliberate act:

az resource delete \
  --resource-group rg-hyf-students \
  --name sthyf<yourname> \
  --resource-type Microsoft.Storage/storageAccounts

Deleting the account removes its containers with it.
Never run az group delete on the class resource group.

⌨️ Together: delete the container from the file

I delete the whole resource container block, save, and run the same preview:

  ~ .../blobServices/default
  = Microsoft.Storage/storageAccounts/sthyfw14lab

Resource changes: 1 to modify, 1 no change.

No -. No deletion. Not one mention of the container I just deleted.

The container is still in the portal. Removing a line from a file is not a request to destroy anything.

To actually remove it: az resource delete, on purpose, by name.

📖 Chapter 5

The same commands, on a runner

📖 Same CI habit, different artifact

Week 5 (containers) This chapter (Bicep)
Lint / test az bicep build (optional)
docker build what-if on a pull request
Push image on green create on merge to main
AZURE_CREDENTIALS + azure/login Same login pattern

You are not learning a new Azure API. You are putting today's commands on a runner.

📖 Two repos, two jobs

  • Reference clone → Chapters 3 and 4, local az work. No secrets, ever.
  • Assignment fork → the graded home. Secrets, bicep.yml, your extensions.

Actions secrets only work on a repository you own, which is why CI lives on your fork.

Fetch your own JSON from Key Vault:

az keyvault secret show --vault-name kv-hyf-data \
  --name azure-credentials-week14-<yourname> --query value -o tsv

📖 Where the secret goes

📖 Name it AZURE_CREDENTIALS

⌨️ Building bicep.yml, step 1: when

Create .github/workflows/bicep.yml. First: when should this run?

name: Bicep

on:
  pull_request:
  push:
    branches: ["main"]

Two triggers, because we want two different behaviours: look at a pull request, apply on merge to main.

⌨️ Step 2: a shared value

env:
  CLASS_RG: rg-hyf-students

Same idea as export CLASS_RG=... in your terminal, except the runner is a fresh machine every time and remembers nothing.

Not a secret: a resource group name is not sensitive, and anyone reading a deploy log sees it anyway.

⌨️ Step 3: the preview job

jobs:
  what-if:
    if: github.event_name == 'pull_request'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

if: is what keeps preview and apply apart, on the one file.

runs-on is a clean Ubuntu VM. checkout puts your repo on it, because otherwise there is no main.bicep to deploy.

⌨️ Step 4: sign the runner in

      - uses: azure/login@v2
        with:
          creds: ${{ secrets.AZURE_CREDENTIALS }}

Nobody is at a keyboard, so az login is out: no browser, no device code.

${{ secrets.X }} reads the repository secret you stored earlier. GitHub masks it in the log, so it appears as ***.

⌨️ Step 5: the command you already know

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

Character for character, what you typed in Chapter 4. That is the whole trick: CI is your commands on someone else's computer.

⌨️ Step 6: the apply job

  deploy:
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: azure/login@v2
        with:
          creds: ${{ secrets.AZURE_CREDENTIALS }}
      - name: Apply deployment
        run: |
          az deployment group create \
            --resource-group "$CLASS_RG" \
            --template-file main.bicep \
            --parameters storageName=sthyfyourname \
                         dbAdminPassword=not-a-real-secret

Same steps, one word different: create instead of what-if.

📖 The whole file: the preview job

name: Bicep
on:
  pull_request:
  push:
    branches: ["main"]
env:
  CLASS_RG: rg-hyf-students
jobs:
  what-if:
    if: github.event_name == 'pull_request'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: azure/login@v2
        with:
          creds: ${{ secrets.AZURE_CREDENTIALS }}
      - name: Preview deployment
        run: |
          az deployment group what-if \
            --resource-group "$CLASS_RG" \
            --template-file main.bicep \
            --parameters storageName=sthyfyourname \
                         dbAdminPassword=not-a-real-secret

📖 …and the apply job

  deploy:
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: azure/login@v2
        with:
          creds: ${{ secrets.AZURE_CREDENTIALS }}
      - name: Apply deployment
        run: |
          az deployment group create \
            --resource-group "$CLASS_RG" \
            --template-file main.bicep \
            --parameters storageName=sthyfyourname \
                         dbAdminPassword=not-a-real-secret

Line for line the job above it, with create instead of what-if and a stricter if:. Nothing is left out: this plus the previous slide is the entire file.

⌨️ One more step: put the diff where it gets read

The preview is buried in the Actions log, so nobody opens it and the green check becomes the thing people approve.

Post it onto the pull request instead:

    permissions:
      pull-requests: write        # lets the job comment
...
      - name: Post the preview on the pull request
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          gh pr comment ${{ github.event.pull_request.number }} \
            --body-file comment.md

Add | tee whatif.txt to the preview step to capture it. gh is already on the runner: no third-party action to trust.

📖 Copy it from here

HTML deck only, with a copy button. The two slides before this one carry the same file in full for the PDF handout.

📖 What you just built

On a pull request On merge to main
what-if runs, deploy is skipped deploy runs, what-if is skipped
Nothing changes in Azure Azure changes
A reviewer reads the diff The diff was already agreed

Preview on the PR. Apply on merge. The same discipline as Week 5, aimed at infrastructure.

⌨️ Live: commit bicep.yml

Start: your fork of c55-data-week-14

Do: add .github/workflows/bicep.yml, then open a pull request inside your own fork.

Success check: the what-if job runs and deploy shows as skipped.

⚠️ Set the base repository to your own fork. GitHub never passes secrets to a PR opened from a fork, so azure/login fails if you target the cohort repo.

📖 What a green run looks like

📖 Read the preview, not the checkmark

📖 What CI does not replace

  • Teardown stays explicit. The workflow above never deletes anything.
  • Secrets hygiene is stricter, not looser: never echo a credential, rotate AZURE_CREDENTIALS if it leaks.

A green check is not permission to skip reading the diff. CI makes the preview automatic; judgement stays human.

🚀 Assignment launch

From today's build to the graded PR

🎯 What you hand in

Fork c55-data-week-14. It already starts at the Chapter 4 end state: thin main.bicep, a storage module, a nested raw container, an @secure() parameter.

Your job is to extend it:

  • An environment parameter driving an Environment tag
  • A second nested container, curated
  • Real deploy + what-if evidence, and a teardown line
  • The @secure() parameter stays: it keeps the value out of Azure's deploy history, and the grader checks it is still there
  • WRITEUP.md and AI_ASSIST.md

Do not paste a -solution branch over the starter. The skill is the extension.

🎯 How today maps to the tasks

Assignment task Chapter Built live in
Task 1: environment param + tag Ch2–Ch3 Ch3, then practice solo
Task 2: second container curated Ch4 live build
Task 3: deploy + what-if capture Ch3–Ch4 both live builds
Task 3 step 4: teardown recorded Ch4 live build
Tasks 4–5: write-ups Ch1 / assignment assignment launch
Task 6 (optional): CI workflow Ch5 Ch5 walk-through

🚀 Start the assignment

  1. Fork c55-data-week-14, clone it or open a Codespace
  2. Add your name to the top of README.md
  3. Run bash .hyf/test.sh → expect 40 / 100, pass=false
  4. Branch week14/your-name, never commit secrets
  5. Keep the PR template headings, or the body check fails your PR

The starter is already the Chapter 4 end state. Your job is to extend it.

🚀 Common pitfalls

  • Storage name already taken → change the name, not the template
  • Deploying into the wrong group, or with no --resource-group at all
  • Expecting delete-by-removing-a-line → use az resource delete
  • Chasing a clean what-if → it will not happen once curated exists
  • Pasting a real key into chat or git → automatic fail, rotate it

🚀 Before you finish for the day

Tear down every resource you created today.

Forgotten resources bill for every hour they exist, used or not.

az resource delete --resource-group rg-hyf-students \
  --name sthyf<yourname> \
  --resource-type Microsoft.Storage/storageAccounts

Then record it in docs/portal_confirm.md. That line is Task 3 step 4.

🧠 Live Quiz

One mixed round, 12 questions

Join the Live Quiz

Chapters 1 through 5, plus the gotchas page.

Scenario questions, not definitions: most of them describe something going wrong and ask what you would check first.

Nothing here is graded. It tells us both which two topics to spend the last block on.

🙋 Questions

And what is coming next

Q&A

What is the one thing you are least confident about before you open the assignment?

Bring anything on:

  • Reading a what-if diff
  • Additive deploys and teardown
  • Modules and nested resources
  • Secrets and @secure()
  • The CI workflow

See you next week

The habits from today carry straight into the capstone: infrastructure in git, a preview before every apply, and no secrets in the repo.

slide #1 · http://127.0.0.1:8765/week_14__presentation.html#1

slide #2 · http://127.0.0.1:8765/week_14__presentation.html#2

slide #3 · http://127.0.0.1:8765/week_14__presentation.html#3

slide #4 · http://127.0.0.1:8765/week_14__presentation.html#4

slide #5 · http://127.0.0.1:8765/week_14__presentation.html#5

slide #6 · http://127.0.0.1:8765/week_14__presentation.html#6

slide #7 · http://127.0.0.1:8765/week_14__presentation.html#7

slide #8 · http://127.0.0.1:8765/week_14__presentation.html#8

slide #9 · http://127.0.0.1:8765/week_14__presentation.html#9

slide #10 · http://127.0.0.1:8765/week_14__presentation.html#10

slide #11 · http://127.0.0.1:8765/week_14__presentation.html#11

slide #12 · http://127.0.0.1:8765/week_14__presentation.html#12

slide #13 · http://127.0.0.1:8765/week_14__presentation.html#13

slide #14 · http://127.0.0.1:8765/week_14__presentation.html#14

slide #15 · http://127.0.0.1:8765/week_14__presentation.html#15

slide #16 · http://127.0.0.1:8765/week_14__presentation.html#16

slide #17 · http://127.0.0.1:8765/week_14__presentation.html#17

slide #18 · http://127.0.0.1:8765/week_14__presentation.html#18

slide #19 · http://127.0.0.1:8765/week_14__presentation.html#19

slide #20 · http://127.0.0.1:8765/week_14__presentation.html#20

slide #21 · http://127.0.0.1:8765/week_14__presentation.html#21

slide #22 · http://127.0.0.1:8765/week_14__presentation.html#22

slide #23 · http://127.0.0.1:8765/week_14__presentation.html#23

slide #24 · http://127.0.0.1:8765/week_14__presentation.html#24

slide #25 · http://127.0.0.1:8765/week_14__presentation.html#25

slide #26 · http://127.0.0.1:8765/week_14__presentation.html#26

slide #27 · http://127.0.0.1:8765/week_14__presentation.html#27

slide #28 · http://127.0.0.1:8765/week_14__presentation.html#28

slide #29 · http://127.0.0.1:8765/week_14__presentation.html#29

slide #30 · http://127.0.0.1:8765/week_14__presentation.html#30

slide #31 · http://127.0.0.1:8765/week_14__presentation.html#31

slide #32 · http://127.0.0.1:8765/week_14__presentation.html#32

slide #33 · http://127.0.0.1:8765/week_14__presentation.html#33

slide #34 · http://127.0.0.1:8765/week_14__presentation.html#34

slide #35 · http://127.0.0.1:8765/week_14__presentation.html#35

slide #36 · http://127.0.0.1:8765/week_14__presentation.html#36

slide #37 · http://127.0.0.1:8765/week_14__presentation.html#37

slide #38 · http://127.0.0.1:8765/week_14__presentation.html#38

slide #39 · http://127.0.0.1:8765/week_14__presentation.html#39

slide #40 · http://127.0.0.1:8765/week_14__presentation.html#40

slide #41 · http://127.0.0.1:8765/week_14__presentation.html#41

slide #42 · http://127.0.0.1:8765/week_14__presentation.html#42

slide #43 · http://127.0.0.1:8765/week_14__presentation.html#43

slide #44 · http://127.0.0.1:8765/week_14__presentation.html#44

slide #45 · http://127.0.0.1:8765/week_14__presentation.html#45

slide #46 · http://127.0.0.1:8765/week_14__presentation.html#46

slide #47 · http://127.0.0.1:8765/week_14__presentation.html#47

slide #48 · http://127.0.0.1:8765/week_14__presentation.html#48

slide #49 · http://127.0.0.1:8765/week_14__presentation.html#49

slide #50 · http://127.0.0.1:8765/week_14__presentation.html#50

slide #51 · http://127.0.0.1:8765/week_14__presentation.html#51

slide #52 · http://127.0.0.1:8765/week_14__presentation.html#52

slide #53 · http://127.0.0.1:8765/week_14__presentation.html#53

slide #54 · http://127.0.0.1:8765/week_14__presentation.html#54

slide #55 · http://127.0.0.1:8765/week_14__presentation.html#55

slide #56 · http://127.0.0.1:8765/week_14__presentation.html#56

slide #57 · http://127.0.0.1:8765/week_14__presentation.html#57

slide #58 · http://127.0.0.1:8765/week_14__presentation.html#58

slide #59 · http://127.0.0.1:8765/week_14__presentation.html#59

slide #60 · http://127.0.0.1:8765/week_14__presentation.html#60

slide #61 · http://127.0.0.1:8765/week_14__presentation.html#61

slide #62 · http://127.0.0.1:8765/week_14__presentation.html#62

slide #63 · http://127.0.0.1:8765/week_14__presentation.html#63

slide #64 · http://127.0.0.1:8765/week_14__presentation.html#64

slide #65 · http://127.0.0.1:8765/week_14__presentation.html#65

slide #66 · http://127.0.0.1:8765/week_14__presentation.html#66

slide #67 · http://127.0.0.1:8765/week_14__presentation.html#67

slide #68 · http://127.0.0.1:8765/week_14__presentation.html#68

slide #69 · http://127.0.0.1:8765/week_14__presentation.html#69

slide #70 · http://127.0.0.1:8765/week_14__presentation.html#70

slide #71 · http://127.0.0.1:8765/week_14__presentation.html#71

slide #72 · http://127.0.0.1:8765/week_14__presentation.html#72

slide #73 · http://127.0.0.1:8765/week_14__presentation.html#73

slide #74 · http://127.0.0.1:8765/week_14__presentation.html#74

slide #75 · http://127.0.0.1:8765/week_14__presentation.html#75

slide #76 · http://127.0.0.1:8765/week_14__presentation.html#76

slide #77 · http://127.0.0.1:8765/week_14__presentation.html#77

slide #78 · http://127.0.0.1:8765/week_14__presentation.html#78

slide #79 · http://127.0.0.1:8765/week_14__presentation.html#79

slide #80 · http://127.0.0.1:8765/week_14__presentation.html#80

slide #81 · http://127.0.0.1:8765/week_14__presentation.html#81

slide #82 · http://127.0.0.1:8765/week_14__presentation.html#82

slide #83 · http://127.0.0.1:8765/week_14__presentation.html#83

slide #84 · http://127.0.0.1:8765/week_14__presentation.html#84

slide #85 · http://127.0.0.1:8765/week_14__presentation.html#85

slide #86 · http://127.0.0.1:8765/week_14__presentation.html#86

slide #87 · http://127.0.0.1:8765/week_14__presentation.html#87

slide #88 · http://127.0.0.1:8765/week_14__presentation.html#88

slide #89 · http://127.0.0.1:8765/week_14__presentation.html#89

slide #90 · http://127.0.0.1:8765/week_14__presentation.html#90

slide #91 · http://127.0.0.1:8765/week_14__presentation.html#91

slide #92 · http://127.0.0.1:8765/week_14__presentation.html#92

slide #93 · http://127.0.0.1:8765/week_14__presentation.html#93

slide #94 · http://127.0.0.1:8765/week_14__presentation.html#94

slide #95 · http://127.0.0.1:8765/week_14__presentation.html#95

slide #96 · http://127.0.0.1:8765/week_14__presentation.html#96

slide #97 · http://127.0.0.1:8765/week_14__presentation.html#97

slide #98 · http://127.0.0.1:8765/week_14__presentation.html#98

slide #99 · http://127.0.0.1:8765/week_14__presentation.html#99

slide #100 · http://127.0.0.1:8765/week_14__presentation.html#100

slide #101 · http://127.0.0.1:8765/week_14__presentation.html#101

slide #102 · http://127.0.0.1:8765/week_14__presentation.html#102

slide #103 · http://127.0.0.1:8765/week_14__presentation.html#103

slide #104 · http://127.0.0.1:8765/week_14__presentation.html#104

slide #105 · http://127.0.0.1:8765/week_14__presentation.html#105

slide #106 · http://127.0.0.1:8765/week_14__presentation.html#106

slide #107 · http://127.0.0.1:8765/week_14__presentation.html#107

slide #108 · http://127.0.0.1:8765/week_14__presentation.html#108

slide #109 · http://127.0.0.1:8765/week_14__presentation.html#109

slide #110 · http://127.0.0.1:8765/week_14__presentation.html#110