01

What "Infrastructure as Code" Actually Is

Your app needs a home in the cloud. This course is the story of how two folders build that home — and move the app in — with no one clicking around a control panel.

Start with the thing you already know

You built an app. It runs on your laptop. But a laptop isn't the internet — for real users to reach it, the app has to live on rented computers in the cloud.

Renting those computers, wiring them together, locking them down, and then placing your app on top is a lot of fiddly setup. This repo does all of it. It splits the job into two folders:

🏗️
infra/ — build the land

Creates the cloud machines, networks, databases, and locks. The empty-but-ready property.

📦
deployment/ — move the app in

Takes your packaged app and runs it on the land that infra/ built.

🧭
The one sentence to remember

infra/ builds where the app lives; deployment/ controls what runs there. The whole course is how those two halves meet.

The old way vs. the written-down way

You could build all this by hand: log into the Azure web portal, click through hundreds of menus, and hope you remember every setting next time. People did this for years. It's slow, and nobody can ever rebuild it exactly.

Instead, this repo writes the cloud down as files. That's the whole idea behind the phrase:

🖱️

Clicking (imperative)

You perform each step by hand, in order. Like giving turn-by-turn driving directions — and re-giving them every single time.

📐

Describing (declarative)

You describe the destination — "I want a network and a database" — and a tool figures out the steps. Like handing someone the address.

Infrastructure as Code (IaC) is the second card. The files in infra/ are written in a language called Bicep, which describes Azure resources.

A "resource" is just a thing you want to exist

Everything in Bicep is a resource: a network, a database, a lock. Here's a real one from infra/bicep/modules/dev/net.bicep — the private network the whole platform sits inside.

CODE
resource vnet 'Microsoft.Network/virtualNetworks@2024-05-01' = {
  name: '${prefix}-dev-vnet'
  location: location
  properties: {
    addressSpace: {
      addressPrefixes: [
        '10.x.x.x/16'
      ]
    }
  }
}
PLAIN ENGLISH

"I want a thing called vnet — a private network."

Name it vs-plat-dev-vnet (the ${prefix} fills in to vs-plat).

Put it in our cloud region (set elsewhere).

Reserve a big block of internal addresses, 10.x.x.x/16 — room for ~65,000 private addresses, like buying a whole zip code for the campus.

That's it. No "how" — just the "what."

💡
Why this matters for steering AI

When you ask an AI to "add a Redis cache" or "open this port," it should produce a resource block like this one — not a list of portal clicks. If it tells you to "go to the Azure dashboard and click…", it has missed how this project works. That's a smell you can now catch.

The magic word: idempotent

The best part of describing instead of clicking: you can run it again and again safely. Azure compares your files to what already exists and only changes the difference.

1
First run

Nothing exists yet → Azure creates the network, database, and everything else.

2
Run again, unchanged

Everything already matches → Azure does nothing. No duplicates, no damage.

3
Change one line, run again

Azure changes only that one thing to make reality match the file.

That property has a name: idempotent. It's why the files — not the running cloud — are the source of truth.

Check your understanding

A teammate changed the database size by clicking in the Azure portal. Next time someone runs the Bicep files, what happens — and what's the real lesson?

You want to make the app use more memory when it runs. Which folder are you most likely editing?

➡️
Next up

You know the idea. Now meet the cast — the actual files in infra/, who they are, and how one little script decides which to run.

02

The Cast & the Layout

The infra/bicep folder isn't one big file — it's a small city, split into neighborhoods. Meet them, and meet the script that decides which one to build.

Think of it as city zoning

A real city groups buildings into districts — industrial here, residential there — so things that belong together stay together. Azure does the same with a resource group. This repo gives each district its own folder under infra/bicep/modules/.

infra/bicep/ All the Azure blueprints
constants.bicep Shared address book: names, region, network ranges
manage.sh The deploy button — picks a blueprint and tells Azure to build it
modules/ One folder per neighborhood (resource group)
sub/Creates the empty neighborhoods themselves
build/A factory that bakes reusable machine images
runner/The CI build machines that compile your code
dev/The real environment: network, database, cache, app host

Meet the four neighborhoods

🪧
sub/ — the zoning office

Runs once, before everything. Creates the empty resource groups (dev-rg, runner-rg, build-rg…) that the others fill in.

🏭
build/ — the image factory

Bakes a ready-to-go Ubuntu machine image (Docker, .NET, the build tools pre-installed) so build machines boot up instant and identical.

🔧
runner/ — the workshop

Self-hosted CI machines (built from the factory image) that compile and package your app when you push code.

🏙️
dev/ — downtown

Where the app actually lives: the private network, the Kubernetes cluster, the database, the cache, the secret vault, the front door.

constants.bicep — the shared address book

Names and numbers (the region, the prefix, the network ranges) get reused across dozens of files. Hardcoding them everywhere is how typos creep in. So they live in one file and every blueprint reads from it.

CODE
@export()
var prefix = 'vs-plat'
@export()
var location = 'eastus2'
@export()
var aksNodesSubnetPrefix = '10.x.x.x/24'
PLAIN ENGLISH

@export() means "other files are allowed to borrow this value."

Every resource name starts with vs-plat, set in exactly one place.

Everything is built in the eastus2 region of Azure.

A reserved slice of the network for the app machines. Change it here, and every file that uses it updates at once.

💡
Single source of truth

This is the most reusable idea in all of software: define a value once, reference it everywhere. When you tell an AI "rename the project prefix," the right answer changes one line in constants.bicep — not 200 lines across 25 files.

manage.sh — one script, simple math

You don't run each .bicep file by hand. You run manage.sh with a short neighborhood/blueprint name, and it does the bookkeeping. The mapping is dead simple:

1

You type
./manage.sh dev/redis

2

It finds the file
modules/dev/redis.bicep

3

It targets group
vs-plat-dev-rg

4

It names the build
vs-plat-dev-redis

One special case: if the neighborhood is sub, there's no resource group to target yet (you're creating the groups), so it deploys at the whole-subscription level instead. A few handy flags change behavior without touching any blueprint:

-wWhat-if: show what would change, build nothing. Your preview button.
-dDry run: just print the Azure command it would run.
-xDelete the whole stack this blueprint created.

The neighborhoods, in their own words

If the four folders could text each other about who depends on whom, it'd sound like this:

Check your understanding

A teammate runs ./manage.sh dev/postgres. Which blueprint runs, and where?

You're nervous about a change to the live network. What do you run first to stay safe?

03

Wiring & Build Order

How the pieces connect into one private campus — and the quiet trick that lets Azure figure out what to build first, all by itself.

A gated campus, not a street of shops

Most of this infrastructure is deliberately private. Picture a gated corporate campus: there are internal hallways between buildings, but no doors that open onto the public street. Data moves freely inside; the outside internet can't wander in.

🧱
VNet — the perimeter fence

A virtual network that wraps everything in one private address space (10.x.x.x/16).

🚪
Subnets — rooms inside the fence

The network is divided into labeled slices: one for app machines, one for the front door, one for "endpoints." Keeps traffic organized and contained.

🛫
NAT gateway — the one-way exit

A NAT gateway lets machines call out (for updates) but gives the internet no way to call in.

🔌
Private endpoints — internal-only doors

The database, cache, and secret vault get a private endpoint — an address that exists only inside the fence. No public hostname at all.

🔒
Why go to all this trouble?

If the database has no public door, an attacker on the internet literally cannot reach it — there's nothing to attack. "Private by default" turns whole categories of breach into non-events.

The trick: pieces point at each other

Here's the elegant part. You never write "build the network first, then the subnet." Instead, the subnet simply mentions the network — and Azure infers the order from that mention.

CODE — dev/aks.bicep
resource aksNodesSubnet 'Microsoft.Network/virtualNetworks/subnets@2024-05-01' = {
  name: C.aksNodesSubnetName
  parent: vnet
  properties: {
    addressPrefix: C.aksNodesSubnetPrefix
    natGateway: {
      id: natGateway.id
    }
  }
}
PLAIN ENGLISH

Create a subnet for the app machines.

parent: vnet — it lives inside the network. So the network must exist first. Azure now knows the order.

Give it its slice of addresses (from the shared address book).

natGateway.id — wire this subnet to the one-way exit. Another mention, another dependency Azure tracks automatically.

Those two values — vnet and natGateway — are declared with the word existing, meaning "don't build these, just look them up." That's how separate blueprints reference each other's work.

💡
This is a dependency graph

Every .id reference is an arrow: "I need that thing first." Azure reads all the arrows and works out a safe build order on its own. It's the same idea behind a recipe that says "preheat the oven" before "bake" — you state the relationship, not a stopwatch.

Private DNS — the internal phone directory

One catch with private doors: the app still knows the database by its public-sounding name, like vs-plat-dev-psql.postgres.database.azure.com. DNS normally turns that name into a public address.

So the campus keeps its own private DNS zone: a phone book that quietly answers the same name with the private address instead. The app's code never changes — the name just resolves to the internal door.

1

App asks for
...postgres.database.azure.com

2

Private DNS answers with the internal address

3

Traffic stays inside the fence — never touches the internet

Watch the build order unfold

Because every piece points at the ones it needs, Azure can only build them in one valid order. Step through it — notice nothing is built before the thing it depends on:

🧱
VNet
🚪
Subnet
Redis
🔌
Private Endpoint
📒
Private DNS
Click "Next Step" to watch the dependency order play out

Check your understanding

Nowhere in the code does it say "build the VNet before the subnet." So how does Azure know to do that?

The app suddenly can't reach the database — the error is "could not resolve host," but the database is up. Based on the campus model, where do you look first?

04

Identity & Secrets

The most important security idea in the whole repo: the app proves who it is instead of carrying a password. Here's how that "passwordless" magic actually works.

Passwords in code are a time bomb

The naive way to let an app reach a database is to paste the password into a config file. The problem: that password now lives in your code, your backups, your chat history, and anyone who copies the repo gets the keys.

🔑

Carrying a master key

A shared password copied into the app. If it leaks once, it leaks forever — and rotating it means hunting down every copy.

🪪

Showing a badge

The app proves its identity to Azure and gets a fresh, short-lived pass each time. Nothing secret is ever stored. This is what this repo does.

💡
The vocabulary: "passwordless"

When the docs say the platform is passwordless, they mean the second card: the app authenticates with its identity, not a stored secret. This is the gold standard you should ask any AI to aim for when wiring up cloud services.

A managed identity is the app's badge

Azure can hand a resource an official, built-in identity — a managed identity. Think of it as a corporate badge with a photo: the building's systems recognize it, and you never had to invent or remember a password.

But there's a puzzle: the app runs inside Kubernetes, and Kubernetes has its own idea of identities. How do those two worlds trust each other? Through workload identity federation — a one-time "treaty" between them:

CODE — dev/workload-identity.bicep
resource fedCred '...userAssignedIdentities/federatedIdentityCredentials@2024-11-30' = {
  name: 'aks-workload'
  parent: appUami
  properties: {
    issuer: aks.properties.oidcIssuerProfile.issuerURL
    subject: 'system:serviceaccount:${C.workloadNamespace}:${C.workloadServiceAccount}'
    audiences: [
      'api://AzureADTokenExchange'
    ]
  }
}
PLAIN ENGLISH

Register a trust rule on the app's badge (appUami).

issuer: "I trust tokens signed by our Kubernetes cluster."

subject: "…but only for one specific account — the app-workload-sa service account in the default namespace."

audiences: "…and only when the token is meant for Azure token exchange." Three conditions; all must match.

After this treaty exists, a pod running as that exact account can swap its Kubernetes token for a real Azure pass — no password anywhere.

Watch the handshake happen

When the app needs the database, here's the conversation behind the scenes. Notice nobody ever says a password:

When a secret is unavoidable: the vault

Some things genuinely are secrets — the CI runner's login token, for example. Those never go in the repo. They go in a Key Vault (a locked safe), and access is granted by role — not by sharing a key.

CODE — dev/modules/raKvSecretsUser.bicep
// Key Vault Secrets User (4633458b-...)
resource raSecretsUser 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
  name: guid(kv.id, principalId, 'KeyVaultSecretsUser')
  scope: kv
  properties: {
    roleDefinitionId: subscriptionResourceId(...)
    principalId: principalId
    principalType: 'ServicePrincipal'
  }
}
PLAIN ENGLISH

Grant a role assignment — "this identity may do this thing here."

scope: kv — the permission applies only to this one vault, nothing else.

The role is "Key Vault Secrets User": read secrets, that is all. Can't delete them, can't change settings.

principalId — who gets it: the app (or runner) badge. Least privilege, written down.

📜
Public ids vs. real secrets

The repo deliberately commits some things that look sensitive but aren't — SSH public keys, OAuth client ids, identity client ids. Those are safe to share (they're like a username). Only the matching secrets go in the vault. Knowing the difference stops you from panicking — or worse, from leaking the real thing.

Check your understanding

Someone accidentally makes the whole repo public. How exposed is the database connection?

A new app pod gets "access denied" from Azure, but the database is healthy and reachable. The pod runs under a service account named web-sa. What's the likely cause?

05

Helm & Deployment

Bicep built the empty campus. Now the deployment/ folder moves the app in — one template, filled in differently for each environment.

From "build the land" to "move in"

Your app ships as a container: a sealed bundle that runs the same everywhere. Kubernetes runs containers inside pods, spread across the cluster that Bicep created.

But Kubernetes wants long, repetitive YAML files describing every pod, network rule, and setting. Writing those by hand for dev, then again for staging, then prod, is error-prone. That's the job of Helm.

✉️
Helm is a mail merge

A form letter has blanks — "Dear ___, your ___ is ready." Helm templates are the form letter; a values file is the list that fills the blanks. Same template, different output for each environment. Write the structure once; vary only what changes.

One template, four environments

The chart ships a base values.yaml (the defaults), then a thin override file per environment. Deploying to dev? Helm stacks values-dev.yaml on top of the defaults. Only the differences are written down:

CODE — values-dev.yaml
client:
  config:
    appEnv: "development"
    authAuthority: "https://auth.vs-platform.ai/realms/vs-platform"
workloadIdentityClientId: "<your-uami-client-id>"
ingress:
  host: "dev.vs-platform.ai"
course:
  enabled: true
PLAIN ENGLISH

For the dev environment only, override these blanks…

Tell the web app it's running in "development" mode and where to log users in.

The identity id from Module 4's Bicep — pasted here so the app wears the right badge.

This environment answers at dev.vs-platform.ai

…and turns on the optional course site. Staging and prod just swap these few values.

⚠️
Watch for drift

The connection strings in values.yaml must match the real names Bicep created (vs-plat-dev-psql, vsplatdevredis…). If someone renames a database in Bicep but not here, the app deploys fine and then can't connect. Two files, one truth to keep in sync.

The umbrella: three apps, one chart

This is an umbrella chart — it bundles everything the platform serves into one versioned package:

⚙️
API

The .NET backend. Gets the database + cache connection strings and the health checks Kubernetes uses to know it's alive.

🖥️
Client

The Vue web app, served by a tiny nginx. Its settings are injected at startup so one image works in every environment.

📚
Course (optional)

A static nginx site — like the page you're reading — turned on only where course.enabled is true. (Meta: this very file is meant to ship here.)

Each gets a Deployment (keep it running), a Service (a stable internal address), and they share an Ingress (the front-door routing rules).

Where infra and deployment shake hands

This is the moment the two halves of the course meet. Remember the app's badge from Module 4? Bicep created that identity and printed its id. Helm stamps that same id onto the Kubernetes account the pods run as:

CODE — templates/serviceaccount.yaml
kind: ServiceAccount
metadata:
  name: {{ .Values.serviceAccountName }}
  annotations:
    azure.workload.identity/client-id: {{ .Values.workloadIdentityClientId | quote }}
PLAIN ENGLISH

Create the Kubernetes account the app pods run as.

Its name (app-workload-sa) is exactly the subject the Bicep trust rule pinned in Module 4.

Stamp on the identity id that Bicep created. This annotation is the handshake: it links the running pod to its Azure badge, completing the passwordless path end to end.

🔗
Two repos, one contract

Bicep and Helm never call each other. They agree on shared names and ids — the service account name, the identity id, the App Gateway. Get those to line up and the campus and the app click together. That's the entire integration.

Trace one real request, end to end

Everything you've learned, in one journey: you open the site, and your click travels through the infrastructure Bicep built and the apps Helm deployed. Step through it:

🌐
Browser
🚪
App Gateway
🖥️
Client pod
⚙️
API pod
🗄️
DB + Cache
Click "Next Step" to follow your click through the whole stack

Check your understanding

You need a new "staging" environment that's identical to dev except for its web address and database. What's the right move?

A pod deploys and runs, but gets "access denied" reaching Azure. Both the Bicep identity and the Helm service account exist. Where's the most likely break?

06

Gotchas & the Big Picture

The traps that bite real people, and the single mental map that ties all six modules together.

The pre-flight checklist

Pilots don't trust memory before takeoff — they run a checklist, every time. These are the items this codebase's own docs warn about. Skim them now; they'll save you an afternoon later.

🏷️

Never use latest

Image tags must be pinned to a specific version. latest is a moving target — two deploys can silently run different code, and you can't roll back.

🔢

Bump the chart version

Published chart versions are immutable. Change anything in the chart? Bump version in Chart.yaml or your change won't ship.

Unique resource names

Two Azure resources with the same name (case-insensitive) crash the model at startup — not at build time. Catch it by validating, not just compiling.

👀

Preview before applying

Run manage.sh -w (what-if) on shared environments before you change them. Look at the diff first; surprises in prod are expensive.

📦

Mind the 1 MB course cap

All course/*.html files share one Kubernetes ConfigMap — hard-capped at 1 MB. One course fits; many will need splitting.

🚧

Don't trust a green build

Bicep compiling cleanly doesn't mean it deploys. Validate the model (what-if or a real run) — a clash or a missing dependency only shows up then.

Spot the bug

A teammate opens a pull request changing the API image. One line breaks a rule you just learned. Click the line you'd flag in review:

1 api:
2 image:
3 repository: vs-platform/vsplatform-api
4 tag: "latest"
5 pullPolicy: IfNotPresent

The whole journey, one map

Here's everything, in order. Each stage hands something to the next — the same dependency thinking from Module 3, scaled up to the entire platform:

1

sub/
create the resource groups

2

build/
bake the machine image

3

runner/
stand up CI machines

4

dev/
network, AKS, DB, identity

5

Helm
deploy the app onto it

Explore the architecture

Click any piece to see its job. Left side is what Bicep builds (infra/); right side is what Helm runs on top (deployment/):

infra/ — Azure, built by Bicep

🧱
Network
☸️
AKS cluster
🗄️
Data services
🪪
Identity + secrets
🚪
App Gateway

deployment/ — Kubernetes, run by Helm

⚙️
API
🖥️
Client
📚
Course site
✉️
Values files
Click any component to learn what it does
🔁
The loop closes

This page itself is a "Course site" on the right. It's served by the Helm chart it describes, on the AKS cluster the Bicep it describes built. You've just read the system that publishes this very page.

Final check — put it all together

Scenario

You drop a new iac.html into course/ and redeploy. The site still shows the old content — your file isn't there. Everything else deployed fine.

What's the most likely fix?

You add a new Azure resource and reuse a name that already exists elsewhere in the model. dotnet build passes. What happens, and what's the lesson?

In one sentence to a colleague: how do the two folders relate?

🎓
You made it

You can now read this repo's infrastructure and deployment, predict what a command will touch, spot a latest tag or a forgotten version bump, and explain the passwordless path. That's exactly the fluency you need to steer an AI through it — and to catch it when it's wrong.