Three claims this stage will make you believe
Read these first — they are the lens for everything in S1–S3
Every stage so far has taught you what to build. This one teaches you how the thing gets built, every time, the same way, by a machine, with a receipt. The enemy has a name.
If it's not code, it doesn't exist
A resource you created by clicking is a resource nobody can review, nobody can diff, and nobody can rebuild. It exists in the console and nowhere else. When the person who made it leaves, it becomes load-bearing folklore.
Declarative beats imperative
Don't write the steps. Write the destination. A desired-state tool computes the steps itself, notices when reality has diverged, and can converge again. You already trust this instinct — it's what a Kubernetes manifest is.
CI/CD makes change safe, not just fast
Pipelines are not a speed feature. They are a risk feature: automated tests, a plan you must read, a human gate, a staged rollout, and an automatic way back. The goal is boring, frequent, reversible deployments.
The villain: the snowflake and the drift it hides
Name the failure before you learn the fix
Snowflake server A resource that has been hand-tweaked so many times, by so many people, over so long, that nobody knows how to recreate it — and everybody is afraid to touch it. It is unique, fragile, and undocumented. Like a snowflake: one of a kind, and it melts if you breathe on it.
Config drift The gap between what your definition says the infrastructure should be and what the infrastructure actually is. Drift is created by every out-of-band change: a console click, an emergency kubectl edit, a support engineer's "quick fix." Drift is how a managed resource quietly becomes a snowflake.
Drift is not a hypothetical. It is the ordinary consequence of a 3 a.m. incident. Someone widens a firewall rule to restore service, the incident closes, and nobody puts the rule back — or into the code. Six weeks later a routine deploy silently reverts the fix, or a compliance scan finds an open port that "isn't in our Terraform." Both outcomes came from the same root: two sources of truth.
The progression: click-ops → IaC → pipeline-driven
Each rung removes one specific class of failure. Nothing more, nothing less.
A team on Rung 1 that still applies from laptops has removed irreproducibility but kept the human-error surface. A team on Rung 2 with no tests has automated the delivery of bugs. The rungs are cumulative and the order matters.
The map
Three sessions. Click a card to jump.
What you'll be able to do at the end
The success criteria for this stage, stated as verbs
- Write declarative IaC and explain state, drift, and plan/apply to someone who has never seen them.
- Choose native IaC vs Terraform/OpenTofu deliberately, and articulate the licence split without hand-waving.
- Design a CI/CD pipeline with build, test, and gate stages, and pick a deployment strategy on evidence.
- Wire an SLO-gated automatic rollback that uses the SLIs and error budgets from C7.
- Translate a CloudFormation/CDK idea into Bicep, and into Cloud Build + Terraform.
- Rebuild your imperative Vertex AI deploy script as declarative, reproducible IaC with a model-version canary.
Field notes — what changed recently
Three things have changed recently.
It's §20 only, not 19–20
In AWS guidance's AWS architecture guidance, §19 is "Data Transfer Costs and Optimization" — a cost section with no IaC content. All of the Infrastructure-as-Code and CI/CD material is in §20, "Infrastructure as Code and CI/CD" (§20.1 IaC intro through §20.14 case studies). S1 is anchored there.
§15 confirmed — exact title
GCP guidance's a leading GCP architecture reference, §15: "Agile, DevOps, SRE, SDLC, and Other Important GCP Topics." Covers Agile/SDLC, DevOps with GCP, SRE, release management, plus Well-Architected pillars and the PCA case studies.
No IaC section — pulled three patterns instead
Confirmed: the patterns catalog's the cloud application patterns catalog has no IaC or CI/CD section. I pulled Application Package (immutable build artifact), External Configuration (build once, deploy many), and its Dev/prod parity framing as light scaffolding for immutable infrastructure in S1. Everything else in S1 comes from AWS §20 plus first principles.
§15 as planned
Mastering Azure, §15: "DevOps and CI/CD" — Azure DevOps Services, CI pipelines, CD pipelines, IaC with ARM templates and Bicep, DevTest Labs, Azure Pipelines for deployments, GitHub Actions with Azure, and Azure Monitor / Application Insights for DevOps.
Freshness ledger: where the materials are already wrong
Certification guides age badly in this domain. Six things to unlearn before you start.
| What a source says | What is true as of mid-2026 | Where it bites |
|---|---|---|
| GCP's native IaC tool is Deployment Manager | Dead. Cloud Deployment Manager reached end of support on 31 March 2026. Successor is Infrastructure Manager, which is managed Terraform, not a new language. | The GCP source teaches DM as current, and lists it under "Toil Reduction." Answering "Deployment Manager" on a 2026 exam or in a design review is wrong. |
| Use Cloud Source Repositories for Git on GCP | End of sale 17 June 2024. If your org hadn't enabled the API before that date, you cannot use it at all. New work goes to GitHub/GitLab or Secure Source Manager. | The GCP source's SDLC table lists CSR under both "Plan" and "Develop." That path doesn't exist for a new project. |
| Terraform is open source (MPL 2.0) | Terraform has shipped under the Business Source License 1.1 since v1.6 (Aug 2023) — source-available, not OSI open source. OpenTofu is the MPL-2.0 fork, under the Linux Foundation, in the CNCF since April 2025. IBM acquired HashiCorp in 2025. | Every source here calls Terraform "open source" without qualification. That sentence is now a procurement-review problem, not a footnote. Full treatment in S1. |
| Terraform's S3 backend needs a DynamoDB table for locking | No longer. Terraform 1.10 added use_lockfile (S3 conditional writes); 1.11 made it GA and marked dynamodb_table deprecated. |
Nearly every tutorial you'll find still provisions a lock table. It works, but it's a deprecated moving part you can delete. |
| AWS CodeCommit is being retired | Reversed. AWS closed it to new customers in July 2024, then returned it to full General Availability on 24 Nov 2025 after customer pushback. | A good lesson in itself: vendor roadmaps reverse. Don't architect around a rumour, and don't trust a 2024 blog post about a 2026 decision. |
Deploy Bicep with the azure/arm-deploy action |
Superseded by azure/bicep-deploy (first-party Deployment Stacks support); arm-deploy carries a deprecation notice. In Azure Pipelines the new first-party task is BicepDeploy@0. |
The Azure source's walkthrough uses a downloaded publish profile as a GitHub secret — a long-lived credential. S2 replaces it with OIDC federation. |
What this stage assumes you already have
Stages 1–9 are the raw material. IaC codifies them; pipelines deploy onto them.
- C1
- Resource hierarchies. IaC provisions into them — an account/subscription/project is a variable in your config, not a thing you click.
- C2
- Identity. A pipeline runs as something: a deployment service account, or better, a short-lived OIDC-federated identity. Secrets are injected at run time, never committed.
- Stages 3–5
- Compute, network, storage. These are the nouns your IaC declares. Not re-taught — referenced as the things being coded.
- C6
- App architecture. CI/CD deploys those microservices. Immutable infrastructure is the operational face of the stateless design you already learned.
- C7
- Reliability. SLIs, SLOs, error budgets. In S3 these stop being dashboards and become the automatic trigger that aborts a bad rollout.
- C9
- Migration. A landing zone is only repeatable if it's code. IaC is how a migration destination gets stood up twice identically.
Why this session exists
Session 1 · the problem, in plain words
You need the same inference stack in three environments — dev, staging, production. You build it by clicking. Six weeks later, staging works and production has a mysterious 4-second cold start, and no living human can tell you what is different between them.
That is not a story about carelessness. It is arithmetic. Standing up one GPU inference environment by hand is roughly 22 discrete console operations: a VPC and subnet, three firewall rules, a cluster, a CPU node pool, an L4 GPU node pool, an artifact repository, a service account, three IAM bindings, a weights bucket, a secret, a load balancer and its certificate, a dashboard, three alert policies, a DNS record. Each one is a form with four to eight fields.
Do that three times and you have performed 66 operations and made roughly 300 individual field choices, from memory, on three different days, possibly by three different people. The probability that all three environments came out identical is not low — it is zero. And when they differ, you cannot diff them, because there is nothing to diff.
The 22 operations above are your previous nine stages: the resource hierarchy from C1, identity from C2, compute/network/storage from Stages 3–5, the service topology from C6, the alert policies from C7. This session does not re-teach any of them. It teaches the one thing that turns them from a memory into an artifact.
Core concept 1 — Declarative desired state vs imperative scripts
Vendor-neutral · the idea underneath every tool in this stage
IaCInfrastructure as Code. Defining and provisioning infrastructure through machine-readable files that live in version control, instead of through manual console or CLI actions. The file is the source of truth; the running infrastructure is an effect of the file.
ImperativeYou specify how: an ordered sequence of operations. "Create the bucket. Then create the service account. Then bind the role. Then push the image."
DeclarativeYou specify what: the end state you want. "A bucket named X exists, versioned, in region Y." The tool works out which operations are needed to get there from wherever things currently are.
1Intuition
Imperative is turn-by-turn directions. Declarative is a destination address.
Directions — "left at the lights, third right, park behind the blue van" — work perfectly once, from one starting point. Change any assumption and they break silently: the van has moved, so you park somewhere wrong and never notice. Worse, if you are already halfway there, following the directions from the top takes you somewhere absurd.
An address works from anywhere. It survives roadworks. And critically — you can hand it to someone else and they will end up in the same place, which is exactly what "reproducible" means.
You already have both in your own toolkit. Your Vertex AI deploy script — build image, push image, create endpoint — is directions. Your Kubernetes and KServe manifests are addresses. You wrote replicas: 3, not "start two more pods"; the control loop worked out the rest. Terraform is that same instinct, applied to cloud resources instead of pods.
2Mechanism
A declarative tool runs a reconciliation loop: it reads what you asked for, reads what exists, computes the difference, and executes only the difference. The loop is the whole trick.
The artifact is the mechanism — a desired-state blockresource "google_container_node_pool" "gpu" {
name = "l4-inference"
node_count = 2 # the WHAT. Never "add one node".
node_config {
machine_type = "g2-standard-8"
guest_accelerator { type = "nvidia-l4", count = 1 }
}
}
Nothing in that block says create. It says exists, with these properties. Run it against an empty project and it creates. Run it against a pool that already has two nodes and it does nothing. Change 2 to 4 and it adds two. Same file, three different behaviours, because the file describes an end state rather than an action.
3Trade-offs, limits, and where it breaks
- Declarative cannot express time. "Create the cluster, wait for the operator to become healthy, then apply the CRDs" is a sequence, not a state. Tools bolt on escape hatches —
provisioner,local-exec,null_resource, CloudFormation custom resources — and every one of them is a small imperative script hiding inside your declarative config. They are not idempotent, they are not planned, and they are the usual cause of "it worked the first time." - Ordering is inferred, not written. The tool builds a dependency graph from references. If resource B needs A but never references A, the tool may create them in parallel and B fails. You then reach for
depends_on, which is a manual patch to an inferred graph — use it, but treat each one as a smell. - Idempotency is a property of the provider, not a gift. The tool promises to compute the right delta; the underlying API has to behave when it applies it. Some APIs genuinely are one-shot — uploading a model artifact, running a database migration — and no declarative wrapper makes them replayable.
- Coverage lags the cloud. A service launched last week may have no resource type. You end up with a hybrid: mostly declarative, plus a thin imperative edge. That is acceptable. Pretending the edge does not exist is not.
- Replace is the sharp edge. Some properties are immutable in the API. Changing them means destroy-then-create, and the plan will tell you — in small text, next to a resource holding your data. This is why nobody applies without reading the plan.
Core concept 2 — State: the tool's memory
Vendor-neutral · the concept people skip and then get burned by
StateThe tool's record of the resources it manages: a mapping from your name for a thing (google_container_node_pool.gpu) to the cloud's identifier for it (projects/p/…/nodePools/l4-inference), plus the property values as of the last run.
BackendWhere that state is stored. Local means a file on your laptop — fine for a tutorial, fatal for a team. Remote means shared, versioned, encrypted, and lockable storage: an S3 bucket, an Azure Storage container, a GCS bucket, or a managed service.
1Intuition
State is the receipt. You told the shop what you wanted; the receipt records what you actually walked out with and its serial numbers. Without the receipt, you cannot return anything, because you cannot prove which of the identical items on the shelf is yours.
Concretely: you write "a bucket named acme-weights." The cloud creates it and hands back an ID. Next run, the tool needs to know whether that bucket is the one it made (so a name change means rename) or someone else's (so a name change means create a new one and leave theirs alone). Only the receipt can tell the difference.
2Mechanism
Once you have state, you have three pictures of your infrastructure, not two — and every operation in IaC is a comparison between two of them. Internalise this triangle and drift, plan, and import all stop being mysterious.
terraform plan sometimes shows changes you never made. Refresh pulled in drift, and the plan is reporting the total gap between config and reality — not just your edit.3Trade-offs, limits, and where it breaks
- State contains secrets in plaintext. A generated database password, a private key, a service account key — if the API returns it, it lands in state. State is therefore a secret. Encrypt the bucket, restrict read access as tightly as you restrict production, and never commit it to Git. OpenTofu additionally ships client-side state encryption (v1.7), which Terraform's open CLI does not have.
- State is a shared mutable file, so it needs locking. Two concurrent applies against one state file corrupt it. For years the S3 backend needed a separate DynamoDB table for locks. Terraform 1.10 added
use_lockfileusing S3 conditional writes, and 1.11 made it GA and deprecated thedynamodb_tableargument. Most tutorials you will find still provision the lock table — you can delete it. - State size is blast radius. One state file for the whole company means every apply risks everything and every plan takes ten minutes. Split by lifecycle and ownership: network in one, the GKE cluster in another, per-team workloads in their own. Cross-state reads via data sources or remote state outputs.
- Losing state is recoverable but grim. The resources still exist; the tool has simply forgotten it owns them. Recovery means
importing each one back. Versioned buckets make this a rollback instead of an archaeology project — turn on versioning before you need it. - Managed state is a genuine advantage of native tools. CloudFormation, ARM and Infrastructure Manager hold state for you: no bucket to bootstrap, no lock table, no chicken-and-egg. You trade control and portability for that. This is a real point in the native-vs-Terraform decision, not a detail.
Core concept 3 — Drift, plan/apply, and idempotency
Vendor-neutral · the daily working loop
1Intuition
Drift is an open window; the tool is a thermostat. You set 21°C — that is your config. The thermostat holds it. Someone opens a window. The room is now 17°C: reality has drifted from desired state. A good thermostat notices and acts. A bad one keeps insisting the setting is 21 while everyone freezes.
plan is asking the thermostat what it is about to do, before it does it. "I am going to run the heating for 40 minutes." You get to say "actually, close the window instead."
Idempotency is the promise that pressing the button twice does not make the room 42°C. Apply the same config ten times and you get one bucket, not ten.
2Mechanism
A plan is a diff with four verdicts per resource, and the symbols are worth memorising because every tool borrows them:
+ create # resource is in config, not in reality
~ update in-place # same resource, mutable field changed
-/+ replace # immutable field changed → destroy THEN create
- destroy # in state, removed from config
Plan: 2 to add, 1 to change, 0 to destroy.
The line to fear is -/+ replace. It appears when you change a property the cloud API cannot mutate — a subnet CIDR, an instance boot disk image, a database engine version on some services. The plan is telling you, in the same neutral tone it uses for everything else, that it is about to delete a thing and build a new one. On a stateless inference pod that is fine. On the bucket holding your model weights it is a Tuesday you will remember.
IdempotencyAn operation you can repeat any number of times with the same end result as doing it once. apply is idempotent. curl -X POST /endpoints is not — run it twice, get two endpoints.
3Trade-offs, limits, and where it breaks
- Not all drift is bad. If a cluster autoscaler changed
node_countfrom 2 to 7 under load, reverting that is actively harmful — your next apply would scale production down mid-traffic. The fix is to tell the tool that field is not yours:lifecycle { ignore_changes = [node_count] }. Deciding which fields the platform owns and which you own is a real design decision, not a config detail. - Detection is not continuous by default. CloudFormation drift detection is an on-demand or scheduled check, not a live watch. Terraform only sees drift when someone runs a plan. If nobody plans for six weeks, you have six weeks of invisible divergence. Mature teams run a scheduled plan on a cron and alert on any non-empty diff.
- Remediation is a choice with two right answers. Re-apply to revert the change (the config was right, the human was wrong), or update the config to adopt it (the human was right, the config was stale). Choosing "revert" reflexively is how you undo somebody's 3 a.m. incident fix at 9 a.m. on a Monday.
- Prevention beats detection. The durable fix is IAM: production write permissions belong to the pipeline's identity, and humans get read-only plus a break-glass role that fires an alert when assumed. This is C2 material doing operational work.
- Plans go stale. A plan computed at 09:00 and applied at 14:00 describes a world that may no longer exist. Good pipelines save the plan as an artifact and apply that exact plan, refusing if reality moved. Ignore this and you get the AWS outage in the Reality Check below — literally a stale plan overwriting a newer one.
Core concept 4 — Modules, and the CI/CD pipeline shape
Vendor-neutral · reuse, then delivery
ModuleA reusable, parameterised bundle of resources with inputs and outputs — a function, for infrastructure. You write "GPU inference environment" once with variables for size and region, then call it three times. The AWS analogues are nested stacks (composition) and StackSets (the same stack across many accounts and regions); Bicep calls them modules too.
CIContinuous Integration. Every commit is automatically merged into a shared branch, built, and tested. Its purpose is to find integration breakage in minutes rather than at the end of a quarter.
CD — two meaningsContinuous Delivery: every passing build is automatically made deployable and pushed to staging; a human clicks to release to production. Continuous Deployment: no human click — a passing build goes to production by itself. Most regulated teams do delivery. Saying "CD" without saying which one causes real arguments.
ArtifactThe immutable output of the build stage — a container image, a signed archive, a synthesised template. Built once, then promoted unchanged through every environment. Rebuilding per environment means production runs something staging never tested.
Stage / gateA stage is a phase of the pipeline (build, test, deploy-staging, deploy-prod). A gate is a condition between stages: tests green, plan reviewed, approver signed off, error budget not exhausted.
The shape below is universal. AWS, Azure and GCP each sell you the same seven boxes under different names, and S2 and S3 will simply relabel them.
01 · Commit — the only trigger
A push or a merged pull request starts everything. Nothing else does. The moment a human can trigger a production deploy by any other route, the pipeline has stopped being a control.
Two rules make this real: protected branches (nobody pushes straight to main) and required reviews. For IaC specifically, this is also where the plan gets posted as a PR comment so the reviewer approves an outcome, not a script.
02 · Build — produce the artifact once
Compile, containerise, or synthesise. For an inference service this is the Docker image with your vLLM server and its pinned dependencies. For IaC it is cdk synth, bicep build, or a saved terraform plan file.
The build must be hermetic — the same inputs give the same output, with no reliance on whatever happened to be installed on the runner. Pinned base images, locked dependency files, no latest.
03 · Test — fast feedback first
Ordered cheapest-and-fastest to slowest: lint and format, static analysis, unit tests, then integration tests. For IaC the equivalents are real and often skipped: validate, fmt, a security policy scan (Checkov, tfsec, CloudFormation Guard), and a plan against a scratch environment.
A pipeline whose first ten minutes are integration tests trains people to stop watching it.
04 · Publish — the immutable artifact
Push the image to a registry with an immutable tag — a digest or a commit SHA, never latest. Scan for vulnerabilities here, and sign it if you have a supply-chain requirement.
From this point on, nothing rebuilds. Every later stage refers to this exact digest. That single rule is what makes "we tested this" a true statement rather than a hopeful one.
05 · Deploy to staging — automatically
No approval needed. Staging exists to be broken. Deploy the artifact, run smoke tests and contract tests against the real deployed thing, and let it soak long enough for slow failures — memory growth, connection-pool exhaustion — to surface.
Staging is only useful to the degree it resembles production. This is the Cloud Patterns dev/prod parity idea: equivalent, separately provisioned, differing only by configuration injected at run time.
06 · Gate — the deliberate pause
The gate is what separates continuous delivery from continuous deployment. It can be a human approval, or it can be automated: tests green, plan reviewed, no open sev-1, change-freeze window respected, error budget not exhausted (C7 becoming a control here).
Manual gates cost lead time and should be reserved for changes that genuinely warrant judgement. A gate that everyone rubber-stamps within four seconds is theatre with a latency cost.
07 · Deploy to production — gradually
Never all at once. Choose blue-green, canary, or rolling based on the trade-offs below, shift traffic in steps, and watch a metric between steps. The rollout is not "run the deploy command"; it is "run the deploy command and then decide, repeatedly, whether to continue."
Every step must have a defined abort condition decided before the deploy, not improvised at 2 a.m.
08 · Observe — close the loop
Deployment is not done when the pipeline goes green; it is done when the SLIs have held for the bake period. Wire deploy markers into your dashboards so you can correlate a latency step-change with the exact release that caused it.
Feed the four DORA signals back: deployment frequency, lead time, change failure rate, failed-deployment recovery time. A pipeline you do not measure will slowly get worse and nobody will be able to prove it.
GitOpsAn operating model where Git is the single source of truth for the desired state of a system, and an agent running inside the target environment continuously pulls from Git and reconciles reality to match. It is the reconciliation loop from Concept 1, with Git as the input and no human in the apply path.
The pipeline reaches into the environment
CI holds credentials for production and calls its APIs from outside. Simple, works for anything, and it means your build system holds production write access — an attractive target, and a hard one to scope. Drift is invisible between runs, because nothing is watching.
The environment reaches out to Git
An in-cluster agent (Argo CD, Flux, Config Sync) polls the repo and applies changes itself. No outbound credentials to hold, and because the agent loops continuously, drift is corrected within minutes rather than discovered eventually. The cost: it fits Kubernetes-shaped resources far better than everything else.
You already have the mental model for pull-based GitOps: it is exactly what your KServe manifests do inside a cluster, extended to the cluster's own configuration. AWS ships a CloudFormation Template Sync Controller for Flux that applies the same idea to CloudFormation stacks, and CloudFormation Git sync does it natively.
Core concept 5 — The three deployment strategies
Vendor-neutral · this is the part that connects to C7
1Intuition
You are replacing the engines on a plane that is currently flying with passengers on board.
- Rolling — swap one engine at a time. Cheap, no second plane needed, but for a while you are flying on a mix of old and new engines, and going back means swapping them all again, slowly.
- Blue-green — build an entire second plane, move everyone across at once. Expensive (two planes) but reverting is instant: move everyone back.
- Canary — move ten passengers to the new plane and watch them for twenty minutes before moving anyone else. Slowest, and the only one that finds problems that only appear with real passengers.
2Mechanism
trafficSplit:
- version: gemma-2b-v3 # incumbent
weight: 90
- version: gemma-2b-v4 # canary
weight: 10
abortOn: { sli: p95_latency_ms, threshold: 850, window: 15m }
3Trade-offs, limits, and where it breaks
| Dimension | Rolling | Blue-green | Canary |
|---|---|---|---|
| Peak capacity cost | ≈1.1–1.25× | 2× during overlap | ≈1.05–1.2× |
| Rollback speed | Slow — roll forward again | Seconds — flip the router | Fast, and only the slice was exposed |
| Blast radius if v2 is bad | Grows with each batch | 100% instantly | Bounded by the slice |
| Version overlap | Yes — must be compatible | Brief, or none | Yes, for a long window |
| Needs a live metric + threshold | Optional | Optional | Mandatory — otherwise it is just a slow deploy |
| Database schema changes | Hard — backward-compatible only | Hard — one DB, two colours | Hardest — long overlap window |
| Best for | Stateless services, default choice | Big-bang cutovers, fast revert required | High-risk changes; new model versions |
- Canary without a threshold is theatre. If nobody has agreed the abort condition in advance, the rollout becomes a group discussion at 2 a.m. and the change goes out anyway.
- Canary can be blind by construction. A 1% slice is too small to move a global error rate. You must measure the canary's own SLI, not the blended one. S3 has a calculator that makes this uncomfortably concrete.
- Blue-green doubles cost, and on GPU that is not a rounding error. Two full L4 fleets for an hour is a real invoice. Canary is usually the right answer for accelerated workloads for exactly this reason.
Core concept 6 — Immutable infrastructure and secret injection
Vendor-neutral · two short ideas that make the rest work
Never patch a running thing — replace it
A server is never upgraded in place. You build a new image, launch new instances from it, shift traffic, and destroy the old ones. Cattle, not pets.
This is what makes rollback trivial: the previous version still exists as an artifact, so going back is a deploy, not a repair. It also kills drift at the source — you cannot hand-tweak a thing you never log into.
the patterns catalog's the cloud application patterns catalog frames the same idea as the Application Package: one immutable build artifact deployed unchanged into every environment. If deployment recompiles anything, what runs in production is not what was tested.
One artifact, many environments
If the artifact is immutable but environments differ, the difference must live outside the artifact — injected at run time as environment variables or mounted secrets. Same pattern source calls this External Configuration.
Secrets never live in code, in the image, or in state. The pipeline authenticates as a workload identity — increasingly via OIDC federation, where the CI system presents a short-lived, cryptographically verifiable token instead of a stored long-lived key — and pulls the secret at deploy time from Secrets Manager, Key Vault, or Secret Manager.
This is C2 doing operational work. The single highest-value change most teams make to a pipeline is deleting a long-lived cloud credential from their CI secret store.
Decision: native IaC or Terraform/OpenTofu?
Follow "no ↓" until a "yes" exits right. The bottom-left box is the default.
Guard clauses · choosing an IaC tool for a new project
Answer honestly about the project you actually have, not the multi-cloud future you might have.
no ↓
no ↓
no ↓
no ↓
no ↓
The four dialects, side by side
Same ideas everywhere. Learn the row, then the column is just vocabulary.
| Concept | AWS native | Azure native | GCP native | Terraform / OpenTofu |
|---|---|---|---|---|
| IaC language | CloudFormation (YAML/JSON); CDK in TypeScript/Python/Java/Go, which synthesises to CloudFormation | ARM templates (JSON); Bicep, a DSL that transpiles to ARM | Terraform HCL is the recommended path. Deployment Manager reached end of support 31 Mar 2026 | HCL, one language across every provider |
| Unit of deployment | Stack; nested stacks to compose; StackSets across accounts/regions | Deployment; Deployment Stack tracks a managed set of resources | Deployment (Infra Manager); Config Connector applies K8s-style manifests | Root module + child modules; workspaces per environment |
| Where state lives | Server-side, managed by CloudFormation. Nothing to bootstrap | Server-side in Azure Resource Manager. Deployment Stacks add explicit ownership tracking | Server-side in Infra Manager (which stores the Terraform state for you) | You own it. Remote backend + locking. Power and responsibility |
| Dry run | create-change-set | what-if | terraform plan (via Infra Manager preview) | terraform plan / tofu plan |
| Drift detection | Built-in stack drift detection, on demand or scheduled | Deployment Stacks flag out-of-sync; Azure Policy for compliance drift | Plan-based; Config Connector reconciles continuously | Plan-based refresh. Run it on a cron and alert on non-empty diffs |
| Adopt existing resources | IaC generator scans and generates templates; cdk migrate | Export template from portal; bicep decompile | terraform import; the old DM Convert tool targeted Terraform | import blocks and -generate-config-out |
| CI service | CodeBuild | Azure Pipelines or GitHub Actions | Cloud Build | Tool-agnostic — runs anywhere, including all three |
| CD / release service | CodePipeline orchestrating CodeDeploy | Azure Pipelines stagesEnvironments with approvals | Cloud Deploy — delivery pipelines, targets, canary, auto-rollback | Not a CD tool. Pair with the above, or Argo CD/Flux/Spinnaker |
| Artifact registry | Amazon ECR; CodeArtifact | Azure Container Registry; Azure Artifacts | Artifact Registry | n/a — Terraform consumes registries, does not host them |
| Git source | GitHub/GitLab, or CodeCommit (back to full GA Nov 2025) | Azure Repos or GitHub | GitHub/GitLab or Secure Source Manager. Cloud Source Repositories closed to new customers Jun 2024 | Any Git host |
| Licence | Proprietary service; CDK is Apache 2.0 | Proprietary service; Bicep is MIT | Proprietary service | Terraform: BUSL 1.1 (source-available). OpenTofu: MPL 2.0 (OSI open source) |
| Lock-in profile | Deep AWS coupling; day-one coverage | Deep Azure coupling; day-one coupling | Google chose the portable tool as its own recommendation | Portable syntax. Resource definitions are still cloud-specific — you port the skill, not the config |
aws_instance does not become a google_compute_instance. What ports is the language, the workflow, the module patterns, and your team's expertise.AWS: CloudFormation, CDK, and the Code* pipeline
AWS §20 · every service named with what it IS and which layer it sits in
AWS CloudFormation — the declarative IaC service (provisioning layer)
What it is: AWS's native declarative IaC service. You write a template in YAML or JSON describing resources; CloudFormation creates and manages them as a stack. State is held server-side — there is no state file for you to lose.
The vocabulary
- Template — the file. Sections:
Parameters(inputs),Mappings(lookup tables, typically per region),Conditions,Resources(the only required section),Outputs(values other stacks consume). - Stack — a deployed instance of a template. Delete the stack and every resource in it goes, which makes teardown genuinely one action.
- Change set — CloudFormation's
plan. Shows what will be created, modified, deleted, and critically whether anything requires replacement. The material's example is exactly right: a change set warning that an RDS update means replacement, and therefore downtime, is the difference between a Tuesday and an incident. - Nested stacks — modularity. A parent template references child templates via
AWS::CloudFormation::Stack. Networking, security, and application layers become separately maintainable units. - StackSets — one template deployed and maintained across many accounts and regions in a single operation. This is the landing-zone tool from C9.
- Stack policies — a guard on specific resources during updates. Stop a routine change accidentally replacing the production database.
- Drift detection — compares live resource properties against the template, reporting each as in-sync, modified, or deleted. On demand or scheduled, not continuous.
- Rollback — on a failed create or update, CloudFormation reverts the whole stack to its last known good state automatically.
Two things newer than the material
- IaC generator — scans an account for resources created outside IaC and generates a template plus a managed stack for them. This is the supported escape route out of click-ops, and it is the answer to "we already have 200 hand-built resources."
- Git sync — connect a stack to a Git repository and CloudFormation deploys template changes on commit, no pipeline required. Native GitOps for stacks.
AWS CDK — Cloud Development Kit (an abstraction above CloudFormation)
What it is: a framework for defining infrastructure in a real programming language — TypeScript, Python, Java, Go, C# — which synthesises down to a CloudFormation template. CDK is not a separate provisioning engine. The engine is still CloudFormation; CDK is a template generator with good ergonomics.
- Construct — the basic building block, a cloud component. L1 constructs map one-to-one to CloudFormation resources. L2 add sensible defaults and helper methods (an L2 S3 bucket encrypts itself and gives you
grantRead). L3, or patterns, assemble whole architectures — a load-balanced Fargate service in a handful of lines. - Stack — the deployment unit, becoming one CloudFormation stack. App — the root, containing one or more stacks.
cdk synth— generate the template.cdk deploy— synth then deploy. CDK Pipelines — a construct that builds a self-mutating CodePipeline for your CDK app.cdk migrate— turn an existing template, deployed stack, or IaC-generator output into a CDK app.
The honest trade-off. CDK gives you loops, conditionals, type checking, unit tests, and IDE completion — genuine advantages over 900 lines of YAML. In return you accept a build step between your source and your infrastructure, a synthesised template you must still read, and the fact that a general-purpose language lets you write infrastructure code nobody can follow. Loops and abstraction are exactly the features that make a diff hard to review, and reviewability was the point.
Where CDK sits on the declarative/imperative line: you write imperative code that generates a declarative artifact. The apply is still fully declarative. This is a useful distinction to be able to state cleanly in an interview.
CodePipeline, CodeBuild, CodeDeploy — the delivery layer
- AWS CodePipeline — the orchestrator. Defines the stages and their order, and calls other services to do the work. Handles source triggers, artifact passing between stages, and manual approval actions.
- AWS CodeBuild — the build service. Runs your build and test commands in an isolated managed container from a
buildspec.yml. This is where compilation, tests, container builds, and IaC validation run. It can also host managed self-hosted runners for GitHub Actions, which is a useful hybrid: GitHub Actions as the workflow language, CodeBuild as the compute inside your VPC. - AWS CodeDeploy — the deployment service. Puts the artifact onto EC2 instances, on-premises servers, Lambda, or ECS. Two strategies, in AWS's own vocabulary: in-place (AWS's name for rolling — update the existing instances in batches) and blue/green (provision a replacement fleet, shift traffic, keep the old one available for rollback).
- Lifecycle hooks — CodeDeploy's extension points around each deployment step:
BeforeInstall,AfterInstall,ApplicationStart,ValidateService.ValidateServiceis the one that matters — it is where your health check runs, and where a failure triggers automatic rollback. - Deployment groups — the set of targets a deployment applies to, so different environments can use different strategies.
- Automatic rollback — configured per deployment group, triggered on deployment failure or on a CloudWatch alarm. That alarm is where your C7 SLIs plug in.
- AWS CodeCommit — managed Git. AWS closed it to new customers in July 2024, then restored it to full GA on 24 November 2025. It is a viable choice again, though most teams here will use GitHub.
Naming discipline that pays off: CodePipeline orchestrates, CodeBuild builds, CodeDeploy deploys. Exam questions and design reviews both reward being precise about which of the three you mean.
Terraform vs OpenTofu — the licence split, and why an architect must know it
The timeline. Terraform was MPL 2.0 — standard open source — for nine years. In August 2023 HashiCorp relicensed it to the Business Source License 1.1, effective from v1.6. BUSL is source-available, not open source: you can read the code and run it free inside your own organisation, but you may not offer it in a service that competes with the vendor's commercial products. Each source file reverts to MPL four years after publication.
The fork. A coalition — Gruntwork, Spacelift, env0, Harness, Scalr, Cloud Posse and others — forked the last MPL version and created OpenTofu, now governed by the Linux Foundation and accepted into the CNCF in April 2025. In April 2024 HashiCorp sent a cease-and-desist alleging BUSL code had been incorporated; OpenTofu denied it, saying the code came from an MPL-licensed version. IBM acquired HashiCorp in 2025, which did not change the licence but did raise governance questions.
Where they stand now. OpenTofu is a drop-in for most workflows — same HCL, same provider protocol, same state model; migration is largely swapping the terraform binary for tofu. It has shipped features Terraform's open CLI lacks: client-side state encryption (1.7), early variable evaluation including backend config (1.8), provider for_each and the -exclude flag (1.9), OCI registry support (1.10), and ephemeral resources / write-only attributes (1.11).
What you should actually say when asked
- Most internal teams: the licence changes nothing day to day. Either tool is fine.
- If you sell a product that embeds it: BUSL is a real constraint. Get a commercial agreement, or use OpenTofu.
- If procurement requires an OSI-approved licence: only OpenTofu qualifies. This surfaces in legal review far more often than in engineering.
- If you want vendor-neutral governance and a predictable roadmap: OpenTofu is a Linux Foundation project with a multi-vendor committee; Terraform's roadmap is set by IBM.
The reason this belongs in an architecture curriculum rather than a legal one: the tool underneath your entire provisioning layer changed its licence overnight once already. "Which licence, and who governs the roadmap" is now a genuine architectural input, alongside features and cost.
Reality check
One worked example on paper, then three things that actually happened
Worked example: trace a plan/apply after somebody hand-edits a resource
This is the drift story, step by step. Managed resource: a security group allowing HTTPS from the internet to your inference load balancer. It is defined in Terraform, applied last week, everything is fine.
T0 · All three pictures agree
Config says port 443 from 0.0.0.0/0. State records the same, with the real security group ID. Reality matches. A plan right now returns "No changes." This is the only moment the triangle is closed.
CONFIG ingress = [443/tcp from 0.0.0.0/0]
STATE sg-0a1b2c3d → ingress [443/tcp from 0.0.0.0/0]
REALITY sg-0a1b2c3d → ingress [443/tcp from 0.0.0.0/0]
No changes. Your infrastructure matches the configuration.
T1 · 03:14 — an incident, and a console click
Inference requests are failing. An on-call engineer diagnoses a health check being blocked, opens the console, and adds port 8080 from 10.0.0.0/8. Service recovers. Incident closed at 04:02. Everyone goes back to bed. Nobody touches the Terraform.
Note what just happened structurally: reality moved, and neither config nor state moved with it. Two sources of truth now exist. The infrastructure is correct and the code is wrong, and nothing in the system knows.
CONFIG ingress = [443/tcp from 0.0.0.0/0]
STATE sg-0a1b2c3d → ingress [443/tcp from 0.0.0.0/0]
REALITY sg-0a1b2c3d → ingress [443/tcp from 0.0.0.0/0,
8080/tcp from 10.0.0.0/8] ← DRIFT
T2 · Tuesday 09:30 — refresh discovers it
A colleague opens an unrelated PR adding a tag to the same security group. CI runs terraform plan. Step 2 of the reconciliation loop — refresh — calls the AWS API and reads the real security group.
This is the exact moment drift becomes visible, and it is visible to a person who had nothing to do with it, in a PR about something else entirely. That is the normal way drift is discovered, and it is why the plan output must be read rather than skimmed.
T3 · The plan, and the trap inside it
The plan compares config against reality — the bottom edge of the triangle. It therefore contains two unrelated things fused into one diff: the colleague's intended tag, and a silent removal of the incident fix.
~ resource "aws_security_group" "inference_lb" {
+ tags = { "cost-center" = "ml-platform" }
- ingress {
- from_port = 8080
- cidr_blocks = ["10.0.0.0/8"]
- }
}
Plan: 0 to add, 1 to change, 0 to destroy.
"1 to change" is doing a lot of work there. A reviewer skimming the summary line approves a tag. A reviewer reading the diff sees that applying this PR will re-break production at 09:34 on a Tuesday, for reasons that have nothing to do with the PR.
T4 · The decision — two right answers, one wrong reflex
The wrong reflex is "IaC is the source of truth, so apply and let it revert." That undoes a fix that was made for a reason nobody has yet examined.
The fix was correct — the health check genuinely needs 8080. Add the rule to the config, so the plan goes clean. Reality was right; the code was stale.
The fix was a blunt instrument — 8080 open to the whole /8 was overkill, and the real fix is a narrower source range. Write the correct rule and let apply converge. Neither the old code nor the console change was right.
Either way, the resolution is a commit. The one unacceptable outcome is leaving the drift in place, because then every future plan carries this landmine and reviewers learn to ignore red lines in diffs.
T5 · The durable fix is not technical
Detecting drift after the fact is useful. Preventing it is better, and it is an IAM and process problem, not a Terraform one:
- Humans get read-only in production. Write access belongs to the pipeline identity. A break-glass role exists, and assuming it fires an alert.
- Scheduled drift detection. A nightly plan across all environments; any non-empty diff opens a ticket automatically. Six weeks of invisible drift becomes twelve hours of visible drift.
- An incident-fix rule. Emergency console changes are legitimate — service comes first. The rule is that the follow-up PR is part of the incident, not a nice-to-have after it. If the postmortem does not have a merged commit attached, the incident is not closed.
Notice the CloudFormation equivalent is identical in shape: drift detection reports in-sync, modified or deleted per resource, and you either update the template to adopt the change or re-apply to revert it. Same triangle, different vocabulary.
Counting what IaC actually removes from a three-environment setup
| Activity | Click-ops | IaC (module + 3 tfvars) | What changed |
|---|---|---|---|
| Initial build, 3 envs | 66 console operations, ~300 field entries | 1 module + 3 variable files3 × apply | Human decisions drop from ~300 to ~15 (the values that genuinely differ) |
| Change instance size in all 3 | 3 × find the resource, edit, confirm. No record of who or why | Edit 1 line, open 1 PR, 3 applies | One reviewable diff replaces three unreviewed edits |
| Prove dev matches prod | Not possible. Compare screenshots, hope | diff dev.tfvars prod.tfvars | An unanswerable question becomes a one-line command |
| Rebuild after a region loss | 22 operations from memory, under pressure, at 3 a.m. | Change the region variable, apply | C9's landing zone stops being a document and becomes a command |
| Add a fourth environment | +22 operations, +100 field entries | +1 variable file (~15 lines) | Marginal cost of an environment collapses. Ephemeral per-PR environments become viable |
| Tear it all down | 22 deletions per env, in dependency order, hoping you found everything. Orphans bill silently | terraform destroy | The most underrated benefit: cheap teardown makes experimentation cheap |
| Audit "who changed the firewall" | Trawl CloudTrail for an API call | git log -p — the diff, the material, the review, the reason | Intent is recorded, not just the action |
Three things that actually happened
A stale reconciliation plan took down US-EAST-1 for ~15 hours
DynamoDB manages hundreds of thousands of DNS records automatically, split into two components: a DNS Planner that generates plans, and redundant DNS Enactors that apply them. On 19–20 October 2025 a latent race condition struck: one Enactor became slow after retries and was still applying an old plan; a second, faster Enactor applied newer plans and then ran cleanup, deleting the older ones. The slow Enactor then overwrote current records with its stale plan, and cleanup deleted them — leaving an empty DNS record for dynamodb.us-east-1.amazonaws.com that the automation could not repair.
Because DynamoDB backs EC2, Lambda and IAM internals, the failure cascaded across 140+ services; independent measurement put 20–30% of internet-facing services in disruption. AWS disabled the Planner and Enactor automation worldwide pending fixes.
Why it belongs in this session: this is the reconciliation loop from Concept 1, at planetary scale, failing in exactly the way the loop can fail — a plan computed against one reality, applied against a different one. AWS's own remediations read like this session's checklist: validate plan freshness before applying, add version checks and checksums, and never let cleanup delete a plan whose status has not been re-verified. It is also the strongest possible argument for the pipeline pattern of saving the plan as an artifact and refusing to apply it if reality has moved.
No staged rollout: 8.5 million machines, in about an hour
A malformed Rapid Response Content update passed a buggy Content Validator and hit an out-of-bounds read in a kernel-mode driver. Roughly 8.5 million Windows hosts blue-screened, grounding airlines and pushing hospitals to paper. The technical bug is not the lesson.
The lesson is in CrowdStrike's own Preliminary Post Incident Report, under "how do we prevent this from happening again": implement a staggered deployment strategy in which updates are gradually deployed to larger portions of the sensor base, starting with a canary deployment, plus monitoring during rollout to guide the phases, and customer control over when updates land. Their sensor releases already went through internal dogfooding and early adopters — content updates did not.
Why it belongs here: every remediation on that list is something you learned three sections ago. This is the clearest available demonstration that deployment strategy is not a nicety for large teams — it is the difference between a bug affecting a canary slice and a bug affecting 8.5 million machines. Note also which two things failed together: the test that should have caught it and the rollout that should have contained it. Defence in depth applies to pipelines too.
Throughput is up. So is instability — and that is a pipeline problem
The 2025 DORA report found AI adoption improves throughput by an estimated 2–18% while correlating with higher change failure rates, more rework, and longer recovery. DORA added a fifth metric, rework rate, and reframed mean-time-to-restore as failed deployment recovery time. The distribution is sobering: only 16.2% of organisations deploy on demand, 23.9% deploy less than monthly, only 9.4% have lead times under an hour, and only 8.5% hit the 0–2% change-failure benchmark while 39.5% are above 16%.
Why it belongs here, and to you specifically: the mechanism is volume. AI generates changes faster than review and deployment infrastructure can absorb them, and the bottleneck moves downstream into testing and release. The 2025 framing — throughput and stability as separate categories — is the same argument this session makes: deployment frequency without change failure rate is a vanity metric. If you are shipping model and adapter changes at AI speed, the gates and canaries in this stage are not bureaucracy, they are the thing keeping the second number down.
Deprecated, then un-deprecated fourteen months later
In July 2024 AWS closed new customer access to CodeCommit as part of a broader trim; guidance pointed at GitHub and GitLab, and vendors ran migration webinars. Then on 24 November 2025 AWS returned CodeCommit to full General Availability, saying customer feedback was clear that an AWS-managed repository was essential — particularly for regulated teams valuing its IAM integration, VPC endpoints and CloudTrail logging — and apologising to customers who had spent effort migrating away.
Why it belongs here: partly as a correction (sources written in 2024–25 have this backwards), but mostly as an architectural lesson. Vendor roadmaps reverse. The teams least hurt by both announcements were the ones whose repository choice was a variable in their IaC rather than a hard assumption threaded through every pipeline. Portability is not only about clouds.
Apply it — your context, and one lab
Session 1 · from your existing scripts and manifests to declarative IaC
You already have all three postures in your own work
Your K8s / KServe manifests
replicas: 3 is a desired state. A controller runs the reconciliation loop from Concept 1 continuously — kill a pod and it comes back, because the manifest is the truth and the cluster is an effect of it.
This is your bridge. Terraform is that loop pointed at cloud APIs instead of the API server, with one difference worth naming: Kubernetes reconciles continuously, Terraform reconciles when you run it. That difference is precisely why drift detection is a feature in one and not the other.
Your Modal deployment
Modal's Python decorators — @app.function(gpu="A10G", image=…) — are a genuine IaC dialect: the config lives in version-controlled code, and Modal reconciles its side to match. Same philosophy as CDK: a real language producing a declaration.
What it does not give you is a readable plan before it acts, or a drift report. Worth noticing which properties of IaC you already have from Modal, and which you have been getting by without.
Your Vertex AI deploy script
Build image → push → create endpoint → deploy model. Turn-by-turn directions. It works, and it is the single best thing in your portfolio to rewrite this stage, because every weakness in imperative deployment automation is visible in it.
Critique your Vertex script against declarative IaC
| Property | Your script today | Declarative equivalent |
|---|---|---|
| Run it twice | Two endpoints, or a crash on "already exists." Not idempotent. | Second run is a no-op. The config describes one endpoint, so one endpoint exists. |
| Preview the change | None. You find out what it does by letting it do it. | plan shows adds, changes and — critically — replacements, before anything happens. |
| Someone edits the endpoint in the console | The script cannot tell. Nothing records what it built. | Refresh surfaces it as drift on the next plan. |
| It fails at step 3 of 5 | Half-built state. Re-running may compound it. You clean up by hand. | State records what succeeded; the next apply resumes from the true gap. |
| Tear it down | Write a second script, and remember every resource. Orphans bill silently. | destroy. One command, dependency-ordered, complete. |
| Review it | A reviewer reads procedure and simulates it mentally. | A reviewer reads a diff of intent: two lines changed, here is the resulting plan. |
| Stand up a second region | Parameterise the script, hope nothing was hardcoded. | New variable file. The module already generalised. |
resource "google_vertex_ai_endpoint" "inference" {
name = "gemma-inference"
display_name = "gemma-inference"
location = var.region
labels = { env = var.env, owner = "ml-platform" }
}
# Durable plumbing: declarative, planned, drift-detected, destroyable.
# Model version rollout + traffic split: stays with the SDK / Cloud Deploy — see S3.
- Quota is not a resource. Accelerator quota is an account property, not something IaC creates. Your apply will fail with a quota error that looks like a bug. Request L4/A100 quota before you write the config, and document it as a prerequisite in the module README.
- Node pool changes are frequently
replace, notupdate. Changing accelerator type or machine type on a GPU node pool usually means destroy-and-recreate. On a pool holding warm model weights that is a very expensive line in a plan you skimmed. - Scale-to-zero is the cost control that matters.
min_node_count = 0on the GPU pool, with a separate always-on CPU pool for system workloads. An idle L4 node costs real money for producing nothing; an idle e2-medium does not. - Image pull time is your cold start. A vLLM image with CUDA is multi-gigabyte. Bake weights into the image or pre-warm them onto a disk, and declare that choice in IaC so every environment makes it identically — otherwise staging is fast and production is not, and nobody can see why.
- Model weights are not infrastructure. Terraform declares the bucket. It should not manage 40 GB of weights. Keep the artifact and the artifact store in different tools.
Optional lab — make drift, see it, then fix it (~40 minutes)
The point of this lab is not to provision anything impressive. It is to produce drift on purpose and watch the plan catch it, so the triangle stops being a diagram. Everything here is free-tier or fractions of a cent.
Before you start — cost guardrails
- Set a budget alert first. AWS Budgets, $1 threshold, email notification. Two minutes, and it turns an unpleasant surprise into an email.
- Nothing in this lab provisions compute. S3 (free tier: 5 GB), a security group (free), and state objects measured in kilobytes. No EC2, no NAT gateway — the two things that quietly bill.
- Use OpenTofu if you want the open-source licence; every command below works identically with
tofuin place ofterraform.
1 · Bootstrap the backend, the modern way
Create a versioned, encrypted S3 bucket by hand — once, this is the chicken-and-egg exception — then point the backend at it. No DynamoDB table. Locking is native now.
aws s3api create-bucket --bucket tf-lab-<your-initials>-state --region us-east-1
aws s3api put-bucket-versioning --bucket tf-lab-<your-initials>-state \
--versioning-configuration Status=Enabled
aws s3api put-bucket-encryption --bucket tf-lab-<your-initials>-state \
--server-side-encryption-configuration \
'{"Rules":[{"ApplyServerSideEncryptionByDefault":{"SSEAlgorithm":"AES256"}}]}'
# backend.tf
terraform {
required_version = ">= 1.11"
backend "s3" {
bucket = "tf-lab-<your-initials>-state"
key = "lab/terraform.tfstate"
region = "us-east-1"
encrypt = true
use_lockfile = true # S3-native locking. 1.11+. No lock table.
}
}
2 · Declare two resources
# main.tf
provider "aws" { region = "us-east-1" }
resource "aws_s3_bucket" "weights" {
bucket = "tf-lab-<your-initials>-weights"
tags = { env = "lab", purpose = "drift-demo" }
}
resource "aws_security_group" "inference" {
name = "tf-lab-inference"
description = "Drift demo - not attached to anything"
ingress { from_port = 443, to_port = 443, protocol = "tcp", cidr_blocks = ["0.0.0.0/0"] }
}
terraform init
terraform plan # READ IT. Two creates. Note the + symbols.
terraform apply
3 · Look at what you just made
terraform state list— the receipt: your two addresses.terraform show— every recorded property, including ones you never wrote (the cloud filled in defaults). Those defaults are now yours to manage.- Open the state object in S3 and read the JSON. It is not magic. Note how casually it would leak a secret if one of these resources had produced one.
terraform planagain → No changes. The triangle is closed.
4 · Cause drift on purpose — this is the actual exercise
aws ec2 authorize-security-group-ingress \
--group-name tf-lab-inference \
--protocol tcp --port 8080 --cidr 10.0.0.0/8
aws s3api put-bucket-tagging --bucket tf-lab-<your-initials>-weights \
--tagging 'TagSet=[{Key=env,Value=lab},{Key=purpose,Value=drift-demo},{Key=hotfix,Value=true}]'
You are now the 3 a.m. engineer. Two out-of-band changes, both plausible, neither in code.
5 · Catch it
terraform plan
Read this plan properly. You should see the ingress rule marked for removal and the hotfix tag marked for removal. Neither change was requested by you. That is drift arriving through the right-hand edge of the triangle — and this is the exact experience a colleague has when your incident fix shows up inside their unrelated PR.
6 · Practise both resolutions
- Adopt: add the 8080 ingress block to
main.tf, plan again, watch that line disappear from the diff. Code caught up to reality. - Revert: leave the tag out of the config and apply — Terraform removes it. Reality caught up to code.
- Now try
lifecycle { ignore_changes = [tags["hotfix"]] }and re-plan: you have declared that field as not-yours. This is how you coexist with an autoscaler.
7 · Stretch, if you have appetite
- Turn the two resources into a module with a
name_prefixvariable; call it twice with different prefixes. That is the three-environment pattern, in miniature. - Run
terraform plan -out=tfplan, then apply that file. Change something in the console in between and watch the apply refuse. This is the AWS-outage lesson as a two-command exercise.
terraform destroy — confirm the plan shows 2 to destroy and nothing survives. Then delete the state bucket manually (it was created outside IaC, so IaC will not remove it):
aws s3 rm s3://tf-lab-<your-initials>-state --recursive && aws s3api delete-bucket --bucket tf-lab-<your-initials>-state
Cheap, complete teardown is the property that makes everything else in this stage affordable. It is why you can afford a per-PR environment, and why you should never again leave an experiment running because deleting it looked like work.
Session 2 goes to Azure, where the interesting twist is that you have been writing IaC all along without knowing it — every action you have ever taken in the Azure portal was already an ARM template deployment. We will look at what Azure gives you for free by making that true, what it costs you, and why Deployment Stacks is the piece the reference texts have not caught up with.
Why this session exists
Session 2 · the problem, in plain words
Here is a fact from the Azure source that most readers skim past and that reframes the whole platform: every deployment you have ever done through the Azure portal was already an ARM template deployment. The portal is a form that builds a template. You can export it, before or after the fact.
That is a genuinely different architecture from AWS or GCP. On Azure there is exactly one control plane — Azure Resource Manager — and everything goes through it: the portal, the CLI, PowerShell, the SDKs, GitHub Actions, and Terraform. There is no back door. Which means Azure gets something for free that the others have to build: the platform always knows the current configuration of every resource, so there is no state file for you to own, lose, or leak.
But the free thing comes with a specific hole, and it is the hole this session is really about. Ask the question that state answers on AWS and GCP:
"Which resources does this template own?"
You deploy a template with five resources. Later you delete one from the template and redeploy. On a classic ARM deployment, the fifth resource stays alive. ARM deployed what you asked for; it was never told to remove anything. Repeat over two years and you accumulate a graveyard of orphans that no template mentions, nobody can attribute, and everybody keeps paying for. Meanwhile terraform destroy takes one command, because Terraform kept a receipt.
The modern answer is Deployment Stacks — a first-class Azure resource that tracks a managed set and defines what happens when something leaves the template. The Azure source predates it entirely. It is the most important gap between what you will read and what you should build.
Core concept 1 — ARM templates and Bicep
Azure §15 · the same declarative idea, in two syntaxes
ARMAzure Resource Manager. Azure's deployment and management control plane. It is the layer that receives every request, applies RBAC and policy, and dispatches to resource providers. "ARM template" means a JSON document ARM executes.
BicepA domain-specific language for authoring Azure infrastructure that transpiles to ARM JSON. Same capability, dramatically less syntax. MIT-licensed, maintained by Microsoft, and now the default recommendation for new work.
Resource groupAzure's deployment container and the usual scope for a template. Templates can also target subscription, management group, or tenant scope — which is how you deploy the resource groups themselves.
1Intuition
ARM JSON is the machine format. Bicep is the human format. The relationship is TypeScript to JavaScript: you write the pleasant one, a compiler emits the one the runtime actually consumes, and you can always read the output when you need to.
The difference matters more than it sounds, because ARM JSON is genuinely hostile to write by hand. Expressions live inside strings — "[concat(parameters('prefix'), '-vm')]" — so you are writing a programming language inside a data format with no syntax highlighting to help you. A modest VM deployment runs to a few hundred lines of JSON with quoting rules that punish typos.
Bicep removes the quoting, adds real types, and produces a file you can review in a pull request without losing the will to live. Same deployment, roughly a third of the lines.
2Mechanism
The template has the same skeleton in both syntaxes: parameters (inputs, typed, optionally with allowed values), variables (computed locals), resources (the declarations), outputs (values other templates or pipelines consume), plus modules in Bicep for composition.
The artifact is the mechanism — the same resource, both syntaxes// Bicep — dependency inferred from the symbolic reference
param location string = resourceGroup.location
resource plan 'Microsoft.Web/serverfarms@2023-01-01' = {
name: 'asp-inference'
location: location
sku: { name: 'F1' } // free tier
}
resource site 'Microsoft.Web/sites@2023-01-01' = {
name: 'app-inference-01'
location: location
properties: { serverFarmId: plan.id } // ← implicit dependsOn
}
Note plan.id. In Bicep, referring to another resource by its symbolic name creates the dependency automatically — the same graph inference Terraform does. The ARM JSON equivalent requires an explicit "dependsOn" array and a resourceId string expression, which is exactly the kind of hand-maintained bookkeeping that rots.
Dry run: what-if
Azure's equivalent of plan and change sets. It reports Create, Modify, Delete, Deploy, Ignore, NoChange per resource, with a property-level diff for modifications. It is available on the CLI (az deployment group what-if), in the azure/bicep-deploy GitHub Action, and in the BicepDeploy@0 Azure Pipelines task.
Resource and property changes are indicated with these symbols:
+ Create
~ Modify
- Delete
~ Microsoft.Web/sites/app-inference-01
~ properties.siteConfig.alwaysOn: false => true
Resource changes: 1 to modify.
Post this into the pull request. That single habit — reviewer approves the what-if output, not the Bicep diff — is the highest-value practice in this session, and it is what Microsoft's own reference implementation does.
3Trade-offs, limits, and where it breaks
what-ifis less reliable thanterraform plan, and you must know this. It works by asking the ARM API to evaluate the change, and coverage varies by resource provider. Some properties report as changing when they will not (noisy false positives on defaults the provider fills in); occasionally something genuinely changes and is not reported. Treat it as a strong signal, not a contract. Terraform's plan, backed by a state file it fully controls, is more precise here — a real point for Terraform on Azure.- Classic deployments never delete. Default mode is Incremental: resources in the template are created or updated, and anything not mentioned is left alone. Complete mode does delete unmentioned resources in the target resource group — which is a blunt and frankly dangerous instrument, because it acts on the whole group rather than on what your template owns. Deployment Stacks exist precisely to give you the sharp version of this.
- API versions are pinned per resource and they age.
@2023-01-01in the snippet above is part of the resource type. Templates written three years ago pin old API versions and silently miss newer properties. There is no global upgrade; you bump them resource by resource. - Bicep is Azure-only, by design. That is not a criticism — it is the trade. You get day-one coverage of every new Azure resource because Bicep targets the same ARM schema, and you get nothing at all for AWS or GCP.
- Loops and conditions exist but are constrained. Bicep has
forloops,ifconditions, and functions, but it is not a general-purpose language. Coming from CDK this feels limiting; coming from ARM JSON it feels like liberation. Both reactions are correct.
Core concept 2 — Deployment Stacks: Azure's answer to state
Not in the material · the most important thing in this session
Deployment stackA first-class Azure resource that owns a collection of resources as a single managed unit. It records which resources its template produced, so it can act when one leaves the template — and it can lock those resources against out-of-band modification.
actionOnUnmanageThe setting that answers "what happens when a resource is no longer in the template?" — delete (remove it) or detach (leave it running but stop tracking it). Set separately for resources, resource groups, and management groups.
Deny settingsA lock the stack applies to its own resources: denyDelete, denyWriteAndDelete, or none. It blocks changes even from users who have RBAC permission — which makes it a genuine anti-drift control rather than a suggestion.
1Intuition
A classic ARM deployment is a shopping trip: you hand over a list, you get those items, and nobody remembers the trip afterwards. Cross something off next week's list and last week's purchase does not vanish from your kitchen.
A deployment stack is a subscription box. The provider knows exactly what is in your box. Remove an item from your preferences and next month's box does not contain it — and, if you have asked them to, they will come and take the old one away. They will also stop you rummaging in the box and swapping things yourself.
That is state's job — knowing what you own — delivered as a platform feature instead of a file you have to store and protect.
2Mechanism
actionOnUnmanage setting.az stack group create \
--name inference-lab --resource-group rg-inference \
--template-file main.bicep --parameters main.bicepparam \
--action-on-unmanage deleteAll \
--deny-settings-mode denyWriteAndDelete
3Trade-offs, limits, and where it breaks
- Deny settings can lock out things you did not expect.
denyWriteAndDeleteblocks writes from everyone, including automation that legitimately needs to touch a resource — an autoscaler adjusting capacity, a certificate renewal job, an operator writing status. There is an exclusion list for principals and actions; you will need it, and you will discover which entries you need by breaking something first. Start withdenyDelete, which is safer and still prevents the worst outcome. detachis the humane default while adopting stacks. Pointing a stack withdeleteAllat an existing production resource group and getting the template subtly wrong is a bad afternoon. Adopt withdetach, verify the stack's managed-resources list matches your expectation, then tighten.- Stacks are scoped. Resource group, subscription, or management group. Cross-scope architectures need more than one stack and a deliberate decision about which owns what — the same "how do I split state" question Terraform users argue about, wearing a different hat.
- Still no plan-quality diff. Stacks fix ownership; they do not fix
what-iffidelity. You still get a good-but-imperfect preview. - Tooling has caught up recently and unevenly. The
azure/bicep-deployaction supports stacks first-class, as doesBicepDeploy@0in Azure Pipelines. Older material — including the material — predates all of it, so most tutorials you find will show plain deployments.
Core concept 3 — Azure Pipelines and GitHub Actions
Azure §15 · two products, one company, genuinely different shapes
Azure DevOps ServicesMicrosoft's SaaS suite covering the whole lifecycle: Azure Repos (Git, plus legacy TFVC), Azure Pipelines (CI/CD), Azure Boards (work items), Azure Artifacts (package feeds — NuGet, npm, Maven, Python, Universal), and Azure Test Plans. Distinct from the self-hosted Azure DevOps Server.
Pipeline anatomyStage → job → step. A stage is a phase (build, test, deploy-prod). A job is a group of steps that run together on one agent. A step is one task or script. Agents are Microsoft-hosted (clean VM per job) or self-hosted (your infrastructure, your network, your GPU).
TriggerWhat starts a pipeline: a push to a branch, a pull request, a schedule, the completion of another pipeline, or a manual run.
EnvironmentA named deployment target in Azure Pipelines (or GitHub) carrying approvals, checks, and deployment history. This is where the gate lives, and it is the object that turns continuous delivery into a governed process.
The organising insight: Azure Pipelines and GitHub Actions solve the same problem from opposite directions. Azure Pipelines was built for enterprise release management and grew CI. GitHub Actions was built for repository automation and grew deployment. That history shows in every trade-off below.
Release-management heritage
- Environments with rich checks — approvals, business hours windows, invoke-REST-API gates, query-Azure-Monitor-alerts gates, exclusive locks. The most mature gating model of the two by some distance.
- Deployment jobs with built-in
runOnce,rolling,canaryandblueGreenstrategies, pluspreDeploy/deploy/routeTraffic/postRouteTraffic/on: failurelifecycle hooks. - Service connections — a governed, RBAC-controlled credential object rather than a bag of repository secrets. Supports workload identity federation.
- First-party
BicepDeploy@0task — deploys.bicepand.bicepparamdirectly without pre-compiling to JSON, caches the Bicep CLI, runs validate and what-if, and supports Deployment Stacks including deny settings. - Multi-cloud by design — the material stresses this, correctly: Azure Pipelines deploys happily to AWS, GCP and on-premises. It is a general CI/CD product that happens to be sold by Microsoft.
Repository-automation heritage
- Workflows in
.github/workflows/*.yml, triggered by any repository event — push, PR, issue, comment, release, schedule, manual dispatch. Far broader event surface. - The marketplace is the decisive advantage. Thousands of prebuilt actions, including Microsoft's own
azure/loginandazure/bicep-deploy. Its flip side is a supply-chain surface: pin third-party actions to a commit SHA, not a tag. - Matrix builds — run the same job across many versions or configurations in parallel with a few lines.
- OIDC-native.
azure/loginwithpermissions: id-token: writefederates a short-lived token to a Microsoft Entra app registration. No stored secret at all. - Environments and required reviewers exist, and are good — but the checks vocabulary is thinner than Azure Pipelines'.
- It is where the developers already are. That is not a small factor; a pipeline nobody looks at is a pipeline nobody fixes.
The Azure section's GitHub Actions walkthrough downloads a publish profile from the portal and stores it as the repository secret AZURE_WEBAPP_PUBLISH_PROFILE. That is a long-lived credential in a shared secret store. It does not expire on any useful schedule, it is hard to scope, it does not rotate itself, and anyone who can run a workflow can use it.
Use OIDC workload identity federation instead. GitHub mints a short-lived token describing the workflow; Entra ID trusts that token for a specific repository, branch or environment, and issues credentials scoped by RBAC. Nothing long-lived is ever stored. It is roughly ten minutes of setup and it deletes an entire class of incident. Also note the material's actions/checkout@v2 and setup-node@v2 are several major versions behind.
permissions:
id-token: write # mint the OIDC token
contents: read
steps:
- uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }} # IDs, not secrets
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
Those three values are identifiers, not credentials — leaking them grants nothing without the federated trust relationship. Compare that with a leaked publish profile, which is a working key. This is C2's OIDC federation doing concrete work.
Where the deployment strategies live on Azure compute
| Target | What it is | Native strategy mechanism | Rollback |
|---|---|---|---|
| App Service | Managed PaaS web/API hosting | Deployment slots. Deploy to staging, warm it, then swap — this is blue-green as a first-class feature, plus percentage traffic routing for canary | Swap back. Seconds. |
| Azure Container Apps | Serverless containers on managed Kubernetes | Revisions with weighted traffic splitting — canary by percentage, natively | Shift weight to the previous revision |
| AKS | Managed Kubernetes | Kubernetes rolling updates by default; canary and blue-green via ingress or a service mesh. Your KServe manifests land here unchanged. | kubectl rollout undo, or shift ingress weights |
| Azure Functions | Event-driven serverless | Deployment slots, same swap model as App Service | Swap back |
| Virtual Machine Scale Sets | Managed identical VM fleets | Rolling upgrade policy with batch size, pause between batches, and automatic OS image upgrades | Roll forward to the previous image version |
Azure DevTest Labs is a service for managing development and test environments with cost controls built in: VM templates (formulas), post-creation artifacts, per-user and per-lab spend policies, and — the genuinely useful bit — automatic shutdown and startup schedules. For a GPU-backed dev environment that only needs to exist during working hours, auto-shutdown is not a nice-to-have; it is the difference between a plausible and an implausible monthly bill. Think of it as guardrails for environments that IaC does not otherwise give you.
Decision: Azure Pipelines, GitHub Actions, or Terraform inside either?
Follow "no ↓" until a "yes" exits right. The bottom-left box is the default.
Guard clauses · choosing a pipeline for an Azure workload
Note that the IaC language and the pipeline are independent choices — you can run Bicep from GitHub Actions or Terraform from Azure Pipelines. Decide them separately.
no ↓
no ↓
no ↓
what-if, and deny settings as a real anti-drift control. Terraform gives you a better plan and an ecosystem you may not need here.no ↓
no ↓
azure/bicep-deploy is first-party with stacks support, and OIDC means no stored credentials. Move to Azure Pipelines when a gate requirement genuinely exceeds what GitHub Environments can express — that is a real reason, and it does happen.
The four dialects — Azure column in focus
Same table shape as S1, read down the Azure column
| Capability | AWS native | Azure native | GCP native | Terraform / OpenTofu |
|---|---|---|---|---|
| Authoring language | CloudFormation YAML/JSON; CDK | Bicep (recommended); ARM JSON underneath | Terraform HCL is the recommendation | HCL |
| Ownership tracking | Stack (server-side) | Deployment Stack — actionOnUnmanage + deny settings | Infra Manager deployment (stores TF state for you) | State file you own |
| Dry run | Change set | what-if — good, not exact; provider coverage varies | terraform plan | plan — the most precise of the four |
| Drift prevention | Stack policies; IAM | Deny settings block writes even for permitted users — strongest of the four | Config Connector reconciles continuously; org policy | None natively — prevention is an IAM design problem |
| Multi-account / multi-region fan-out | StackSets | Stacks at management-group scope; Azure Policy deployIfNotExists | Infra Manager per project; org policy above | Workspaces, or a module called per target |
| CI | CodeBuild | Azure Pipelines or GitHub Actions | Cloud Build | Runs in any of them |
| Gates and approvals | CodePipeline manual approval actions | Environments + checks — approvals, time windows, REST-API gates, Monitor-alert gates, exclusive locks | Cloud Deploy approvals + deploy policies | Inherits whatever the pipeline provides |
| Blue-green primitive | CodeDeploy blue/green (EC2, ECS, Lambda) | App Service / Functions deployment slots — swap. Container Apps revisions for weighted canary | Cloud Run revisions; Cloud Deploy strategies | Describes the resources; does not orchestrate the rollout |
| Pipeline auth to the cloud | IAM role for the pipeline; OIDC from GitHub | Workload identity federation (OIDC), or a service connection. Not a publish profile | Workload Identity Federation; service accounts | Whatever the host pipeline supplies |
Reality check
One worked example on paper, then three current things the material cannot know
Worked example: the same change, traced through what-if and then through a stack
Scenario: an inference API on App Service. You are making two changes in one pull request — enabling alwaysOn to kill cold starts, and removing a Redis cache the team stopped using last quarter.
01 · Two changes, one PR
// main.bicep — diff
resource site 'Microsoft.Web/sites@2023-01-01' = {
properties: {
serverFarmId: plan.id
+ siteConfig: { alwaysOn: true }
}
}
- resource cache 'Microsoft.Cache/redis@2023-08-01' = { ... }
Both changes are correct and intentional. Watch what the platform does with each.
02 what-if reports one of them
az deployment group what-if -g rg-inference -f main.bicep
Resource and property changes are indicated with these symbols:
~ Modify
~ Microsoft.Web/sites/app-inference-01
~ properties.siteConfig.alwaysOn: false => true
Resource changes: 1 to modify.
The alwaysOn change is reported precisely, down to the property and its before/after values. This is what-if doing exactly what you want, and it is genuinely good output to put in a PR comment.
03 · The Redis cache is not mentioned. At all.
Read the output again: "1 to modify." Nothing about Redis. Not a delete, not a warning, not a note.
This is incremental mode behaving exactly as designed. The template no longer mentions the cache, so ARM has no instruction concerning it, so nothing happens to it. The cache keeps running. The cache keeps billing. Six months later somebody in a cost review asks what redis-inference-prod is for, and the honest answer is that nobody knows, and everyone is now slightly afraid to delete it.
Not through carelessness. Through a correct template change, a correct deployment, and a mechanism that has no concept of ownership. Every orphan in every Azure estate was created by someone doing the right thing.
04 · The same change, as a Deployment Stack
az stack group create --name inference --resource-group rg-inference \
--template-file main.bicep --action-on-unmanage deleteAll \
--deny-settings-mode denyWriteAndDelete
The following resources will be DELETED (no longer managed by the stack):
- Microsoft.Cache/redis/redis-inference-prod
Confirm? (y/n):
The stack knew it created the cache. The cache left the template. actionOnUnmanage: deleteAll therefore means delete — and, crucially, you are told and asked before it happens.
Had you set detach instead, the cache would survive but be removed from the stack's managed set — the "I will deal with this manually" option, recorded rather than accidental. Either way the ambiguity is gone: the platform has an opinion and states it.
05 · Now somebody tries to drift it
An engineer opens the portal, navigates to the App Service, and switches alwaysOn back off to save a little money. They have Contributor on the resource group — RBAC says yes.
Operation failed. The resource is protected by a deployment stack
with denySettingsMode = denyWriteAndDelete.
Stack: /subscriptions/.../deploymentStacks/inference
Compare this with the S1 drift trace. There, the change succeeded, went undetected for four days, and surfaced inside somebody else's unrelated pull request. Here the change is refused at the moment of attempt, with an error naming the stack that owns the resource and, implicitly, the repository they should open a PR against.
That is the difference between detecting drift and preventing it — and it is the single strongest argument for Azure-native IaC over Terraform on Azure. Terraform has no equivalent; on Terraform, prevention is an IAM design problem you solve yourself.
The catch, restated: that same lock will refuse a legitimate automated write. Configure the exclusion list deliberately, and start with denyDelete before reaching for denyWriteAndDelete.
Three current things
azure/arm-deploy is out; azure/bicep-deploy is in
Microsoft replaced the long-standing GitHub Action for ARM deployments. The azure/arm-deploy repository now carries a deprecation notice pointing at azure/bicep-deploy, which handles both ARM and Bicep, adds first-party Deployment Stacks support, and exposes validate, whatIf and create as explicit operations. Its type input switches between deployment and deploymentStack; for stacks you also set action-on-unmanage-resources and deny-settings-mode. Microsoft's guidance is that Deployment Stacks are the recommended approach going forward.
The Azure Pipelines side caught up more recently with the first-party BicepDeploy@0 task. Microsoft Learn now states plainly that it is the recommended option for new pipelines: it deploys .bicep and .bicepparam directly without pre-compiling to JSON, downloads and caches the Bicep CLI itself, works at resource-group, subscription, management-group and tenant scope, and supports stacks including deny settings and unmanaged-resource actions.
Why it matters to you: almost every tutorial older than about eighteen months shows you the deprecated path, usually with a publish profile attached. If you copy a 2024 workflow you will get long-lived credentials, a pre-compile step you do not need, and no ownership tracking.
Microsoft's own Bicep + GitHub Actions workflow is the pattern to copy
The Azure-Samples/bicep-github-actions repository is a reference implementation of exactly the shape this session argues for, and it is worth reading before you write your own:
- Work happens on a branch; a pull request opens against main.
- A PR workflow runs unit tests on the infrastructure code — formatting, internal consistency, and security checks on what the template would produce.
- The same workflow runs
what-ifand posts the preview, so the reviewer approves an outcome rather than reading Bicep and imagining one. - Only after review does the merge to main trigger the workflow that actually deploys.
Every element of the pipeline stepper from S1 is present, and nothing extra is. Note especially that the plan-in-the-PR step is treated as non-optional; that is the practice that turns IaC from a scripting convenience into a review process.
Observability wired into the pipeline, not bolted beside it
The section covers Azure Monitor and Application Insights well as tools — KQL queries, dashboards, alert rules, action groups — and offers genuinely useful cost advice that most teams learn the expensive way: enable adaptive sampling, set daily ingestion caps in Log Analytics, use Basic Logs for non-critical data, and filter noisy telemetry. Verbose logging at production request volumes produces bills people do not anticipate.
What it does not do is connect any of that to the release. The connection is the point of this stage: an Azure Monitor alert rule is exactly what an Azure Pipelines gate can query before promoting a stage, and an App Service slot swap is exactly what you undo when it fires. That is an SLO-gated rollback assembled from services the section already describes separately — and it is the C7 material becoming a control rather than a dashboard. S3 builds the fully wired version.
Apply it — your context, and one lab
Session 2 · working Azure literacy for a GCP/AWS-first engineer
Translating what you already know
| You already know | Azure equivalent | The difference that will trip you up |
|---|---|---|
| Your KServe / K8s manifests | Deploy unchanged to AKS | Nothing at the manifest layer. The Azure-specific parts are the GPU node pool, the ingress, and Entra Workload ID for pod-level identity. |
terraform plan | what-if | Fidelity. what-if is a good preview, not a contract. Do not build automation that assumes an empty diff means no change. |
terraform destroy | az stack group delete | Only if you deployed as a stack. A plain deployment has no teardown command — you delete the resource group and hope nothing else lived there. |
| Terraform state file | Nothing to manage — ARM holds it | Liberating until you want state's other benefits. No state list, no state mv, no import/export of your own bookkeeping. |
| Vertex AI endpoint + traffic split | Azure ML managed online endpoints with weighted deployments | Very close conceptually: one endpoint, multiple deployments, traffic percentages. Your S3 canary design ports over almost directly. |
| Cloud Run revisions | Container Apps revisions | Also very close — weighted traffic across revisions. The most natural Azure home for a containerised inference service that does not need a full cluster. |
| Modal's Python decorators | No direct analogue | Bicep is a DSL, not an embedded language. Azure's ergonomic win is conciseness and tooling, not being in your application language. |
- GPU SKUs are quota-gated and regionally scarce. NC- and ND-series capacity varies a lot by region, and a Bicep deployment for an unavailable SKU fails at apply, not at
what-if. Check availability and request quota before writing the template. - Deny settings and GPU autoscalers interact badly.
denyWriteAndDeleteon an AKS cluster will block the cluster autoscaler from adjusting the node pool. Either exclude the autoscaler's principal or scope the deny to the resources that genuinely should never change. - Spot / low-priority GPU nodes for training, standard for serving. Declare this split in the template — a spot node pool for batch and fine-tuning, an on-demand pool for inference — so every environment inherits the same cost posture rather than each one being decided ad hoc.
- DevTest Labs auto-shutdown is a real GPU cost control. A GPU dev box running nights and weekends for no reason is a meaningful monthly line item. Schedule it off.
- Deployment slots do not help a GPU service much. Slots are App Service; a GPU inference workload lives on AKS or Container Apps. Your blue-green story there is ingress weights or revision traffic, and blue-green on GPU means paying for two fleets — which is why canary usually wins.
Optional lab — Bicep, what-if in a PR, and a stack that deletes cleanly (~50 minutes)
Builds the S2 pattern end to end on free-tier resources only: an F1 App Service plan (free), a storage account (pennies), and a Deployment Stack (free). No GPU, no VMs, no gateways.
Before you start — cost guardrails
- Create a budget first. Azure Cost Management → Budgets → $1, alert at 80%. Two minutes.
- F1 App Service plan is genuinely free but limited to one per region per subscription. Use
--sku F1and nothing larger. - Everything lives in one resource group so cleanup has a backstop.
1 · Set up OIDC — do this instead of a publish profile
az ad app create --display-name gh-bicep-lab
# note the appId, then create a service principal and a federated credential
az ad sp create --id <appId>
az role assignment create --assignee <appId> --role Contributor \
--scope /subscriptions/<subId>/resourceGroups/rg-bicep-lab
# federated credential: trust THIS repo on THIS branch — no secret anywhere
az ad app federated-credential create --id <appId> --parameters '{
"name":"gh-main",
"issuer":"https://token.actions.githubusercontent.com",
"subject":"repo:<you>/<repo>:ref:refs/heads/main",
"audiences":["api://AzureADTokenExchange"]
}'
Store AZURE_CLIENT_ID, AZURE_TENANT_ID, AZURE_SUBSCRIPTION_ID as repository secrets. All three are identifiers, not credentials. That is the whole point.
2 · Write the Bicep
// main.bicep
param location string = resourceGroup.location
param prefix string
resource plan 'Microsoft.Web/serverfarms@2023-01-01' = {
name: 'asp-${prefix}'
location: location
sku: { name: 'F1' } // free tier — do not change
}
resource site 'Microsoft.Web/sites@2023-01-01' = {
name: 'app-${prefix}'
location: location
properties: { serverFarmId: plan.id, httpsOnly: true }
}
resource sa 'Microsoft.Storage/storageAccounts@2023-01-01' = {
name: 'st${prefix}'
location: location
sku: { name: 'Standard_LRS' }
kind: 'StorageV2'
}
output siteUrl string = 'https://${site.properties.defaultHostName}'
3 · The PR workflow — what-if as the review artifact
// .github/workflows/infra.yml
on:
pull_request: { paths: ['**.bicep'] }
push: { branches: [main] }
permissions: { id-token: write, contents: read, pull-requests: write }
jobs:
preview:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- uses: azure/bicep-deploy@v2
with:
type: deploymentStack
operation: validate # what-if / preview — changes nothing
name: bicep-lab
scope: resourceGroup
resource-group-name: rg-bicep-lab
template-file: ./main.bicep
parameters: '{"prefix":"labdemo01"}'
4 · The deploy job — stack, on main only
deploy:
if: github.ref == 'refs/heads/main'
environment: production # ← put a required reviewer on this
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: azure/login@v2
with: { client-id: ..., tenant-id: ..., subscription-id: ... }
- uses: azure/bicep-deploy@v2
with:
type: deploymentStack
operation: create
name: bicep-lab
scope: resourceGroup
resource-group-name: rg-bicep-lab
template-file: ./main.bicep
parameters: '{"prefix":"labdemo01"}'
action-on-unmanage-resources: delete
deny-settings-mode: denyDelete # start here, not denyWriteAndDelete
5 · Now actually exercise the two ideas
- Prove ownership tracking. Delete the storage account resource from
main.bicep, open a PR, and read thewhat-ifpreview. Merge, and watch the stack remove it. Then compare: run a plainaz deployment group createwith the same template and confirm a removed resource simply survives. - Prove drift prevention. Go to the portal and try to delete the App Service. It will be refused, and the error will name the stack. This is the moment the concept lands.
- See what
what-ifgets wrong. Add a property the provider defaults, re-run, and look for noise in the diff. Knowing where the preview is imprecise is more useful than assuming it is exact. - Stretch: add a required reviewer to the
productionenvironment and watch the deploy job pause. That pause is the gate from S1, in about four lines of YAML.
az stack group delete --name bicep-lab --resource-group rg-bicep-lab --action-on-unmanage deleteAll --yes
Then remove the container: az group delete --name rg-bicep-lab --yes --no-wait, and delete the app registration: az ad app delete --id <appId>.
Note carefully what just happened: the stack made teardown a single, complete, reliable command — exactly the property a plain ARM deployment does not give you, and exactly the property that made terraform destroy feel so good in S1. Same benefit, delivered by the platform rather than by a file you maintain.
Session 3 goes to GCP, the cloud that retired its own IaC language and told everyone to use Terraform — an unusual and instructive decision. It is also where the deployment strategies stop being a menu and become arithmetic: Google's SRE framing turns your C7 error budget into the trigger that aborts a rollout automatically, and there is a calculator to prove your instinct about canary sizing is wrong.
Why this session exists
Session 3 · the problem, in plain words
Google did something none of the other hyperscalers did: it retired its own infrastructure-as-code language and told customers to use a third-party tool instead. Cloud Deployment Manager reached end of support on 31 March 2026. Its replacement, Infrastructure Manager, does not introduce a new syntax — it runs Terraform for you.
That decision is worth pausing on, because it inverts the incentive you would expect. AWS and Microsoft both keep proprietary IaC languages that only describe their own cloud. Google looked at an ecosystem where the portable tool had already won and decided that competing with it was worth less than being excellent at running it.
The practical consequence for you is that the S1 Terraform material is the GCP material. There is no third dialect to learn. What is genuinely GCP-specific — and genuinely worth this session — is the layer above provisioning: how releases are engineered, and how Google's SRE discipline turns the reliability numbers from C7 into a mechanism that aborts a bad rollout without a human.
The GCP section lists Deployment Manager as "GCP's native IaC tool, using YAML or Jinja2 templates," and again under SRE toil reduction. As of 31 March 2026 it reached end of support — the service is gone and its APIs no longer function. Google's own migration guidance is explicit about the difference: Deployment Manager used YAML with Jinja or Python templates; Infra Manager leverages Terraform to create infrastructure deployments.
The section also routes source control through Cloud Source Repositories in both the "Plan" and "Develop" rows of its SDLC table. CSR reached end of sale on 17 June 2024: if your organisation had not already enabled the API, you cannot use it at all, and new projects outside an existing organisation cannot enable it. New work goes to GitHub, GitLab, or Google's Secure Source Manager.
Everything else in the section — Cloud Build, Artifact Registry, Cloud Deploy, the SRE material, the release strategies — is current and good. Two stale rows, not a stale section.
Core concept 1 — IaC on GCP: three paths, one language
GCP §15 (corrected) · what to actually use
Infra ManagerInfrastructure Manager. A managed Google Cloud service that executes your Terraform configurations and stores the state for you. You write standard HCL; Google runs plan and apply, keeps the state, and integrates with Cloud IAM and Cloud Build.
Config ConnectorA Kubernetes add-on that lets you manage Google Cloud resources as Kubernetes custom resources. A Cloud SQL instance becomes a YAML manifest you kubectl apply, and a controller reconciles it continuously — the same loop as any other Kubernetes object.
Config ControllerA hosted, Google-managed control plane bundling Config Connector, Policy Controller and Config Sync. Config Connector without you operating the cluster it runs in.
1Intuition
Think of it as three ways to run the same engine, differing only in who holds the keys and how often it runs.
- Terraform yourself — you own the binary, the state bucket, the CI wiring. Maximum control and maximum portability, because nothing about it is GCP-specific.
- Infra Manager — Google runs the same binary and holds the state. You give up some control and delete a whole category of chores: no bucket to bootstrap, no locking to configure, no state to encrypt and back up.
- Config Connector — a fundamentally different rhythm. Not "run a tool when I say" but "a controller watches, continuously, forever." Cloud resources become objects your existing GitOps agent reconciles alongside your workloads.
This third one should feel familiar. You already write declarative manifests that a controller reconciles — that is what your KServe deployments are. Config Connector extends that loop past the cluster boundary so a Cloud Storage bucket and an inference service are managed by the same agent, in the same repository, with the same review process.
2Mechanism
The difference that matters is reconciliation cadence, and it changes what drift even means.
| Path | Runs when | State lives | Drift behaviour | Best for |
|---|---|---|---|---|
| Terraform in CI | You trigger it | Your GCS bucket | Detected on next plan. Invisible until then. | Multi-cloud, existing Terraform platform, full control |
| Infra Manager | You trigger it | Google-managed | Same as Terraform — plan-based | GCP-only teams who want Terraform without the plumbing |
| Config Connector | Continuously | In the cluster, as object status | Corrected automatically, within minutes | Kubernetes-centric platforms already running GitOps |
apiVersion: storage.cnrm.cloud.google.com/v1beta1
kind: StorageBucket # a GCS bucket, as a K8s CRD
metadata:
name: gemma-weights-prod
spec:
location: US-CENTRAL1
uniformBucketLevelAccess: true
# kubectl apply this. A controller reconciles it. Forever.
Same file shape as your KServe InferenceService. Same kubectl apply. Same controller pattern. The only new thing is that the object on the other end lives outside the cluster.
3Trade-offs, limits, and where it breaks
- Infra Manager is Terraform, so it inherits Terraform's licence question. You are consuming it as a managed service rather than distributing it, so BUSL is not a practical constraint here — but note the asymmetry: if you later move that configuration off GCP to run yourself, the licence question arrives with it. This is one of the few places where "which binary" and "which cloud" are genuinely coupled.
- Config Connector fights anything else that writes. Two controllers with opinions about one resource is a loop that never settles. Pick one owner per resource and be strict about it. This is also why "Config Connector for everything" is usually wrong — the cluster itself has to be created by something else.
- Continuous reconciliation reverts your incident fix. The S1 drift story ends differently here: your 3 a.m. console change is undone within minutes, possibly mid-incident, by a controller nobody thought about. That is either the best or the worst property of this model depending on whether you knew it was running.
- Terraform on GCP has the same state chores as anywhere. A GCS backend with versioning and object-level locking, split by lifecycle, encrypted, access-restricted. Infra Manager exists to delete that list.
- Migration off Deployment Manager was a one-way door. Google shipped DM Convert to translate configurations to Terraform, with
terraform importfor adopting resources without redeploying. If you meet an estate still on DM in 2026, it is already broken, not about to break.
Core concept 2 — Cloud Build and Cloud Deploy
GCP §15 · the CI half and the CD half, kept properly separate
Cloud BuildGCP's serverless CI service. You define steps in a cloudbuild.yaml; each step runs as a container, and steps share a workspace. Triggered by pushes, pull requests, tags, Pub/Sub messages, or schedules. Its output is an artifact.
Artifact RegistryThe store for build outputs — container images, Maven, npm, Python, Go, OS packages. Handles versioning, vulnerability scanning, and access control. The successor to Container Registry.
Cloud DeployGCP's managed continuous delivery service. It does not build anything. It takes an existing artifact and promotes it through a defined sequence of environments, with approvals, verification, progressive rollout strategies, and rollback.
Why the separation matters: most teams conflate "my pipeline" into one YAML file that builds and deploys. GCP splits them deliberately, and the split is architecturally correct — the build produces an immutable artifact once, and the delivery pipeline moves that exact artifact through environments without rebuilding. It is the S1 "build once, promote many" rule enforced by product boundaries.
Every step is a container
This is Cloud Build's defining idea and it is genuinely elegant: a build step is just an image plus arguments. Need Node? Use a Node image. Need Terraform? Use a Terraform image. There is no plugin ecosystem to learn because the container ecosystem is the plugin ecosystem.
steps:
- name: 'gcr.io/cloud-builders/npm'
args: ['install']
- name: 'gcr.io/cloud-builders/npm'
args: ['test']
Worth knowing: private pools run builds inside your VPC with VPC Service Controls, which is how you build against private GKE clusters or internal package mirrors without exposing them. Repositories (2nd gen) connect Cloud Build directly to GitHub and GitLab, which is the current path now that Cloud Source Repositories is closed to new customers.
A pipeline is a resource, not a script
- Delivery pipeline — the ordered sequence of targets. Declared in YAML, and itself a Google Cloud resource you can manage with Terraform.
- Target — a deployment destination: a GKE cluster, a Cloud Run service, or a custom target. Each can carry
requireApproval: true. - Release — an immutable snapshot: this artifact, these manifests, this pipeline. Created once and promoted.
- Rollout — one release arriving at one target. Rollouts have phases, which is how canary percentages are expressed.
- Verify — a container Cloud Deploy runs after deploying to check the deployment actually works, before the phase is allowed to advance.
- Automation rules —
promote-releaseadvances between targets automatically;advance-rolloutadvances between canary phases automatically. Both went GA in 2024. This is what turns continuous delivery into continuous deployment. - Deploy policies — block rollouts inside a defined time window. Change freezes as configuration.
- Rollback — one action, from console, CLI or API. Plus automatic retry of failed rollouts and automatic rollback to the last successful one.
- Skaffold underneath renders manifests per target.
strategy:
canary:
runtimeConfig: { kubernetes: { gatewayServiceMesh: { ... } } }
canaryDeployment:
percentages: [5, 25, 50] # then 100% automatically
verify: true # run the verify container at each phase
# Each percentage is a PHASE. advance-rollout automation moves between them.
The section's rollback table is accurate and worth memorising because the mechanism differs per service: App Engine — revert to the previous version. Cloud Run — reassign traffic to the previous revision. GKE — kubectl rollout undo deployment. And its exam-shaped heuristics hold up: no-downtime release with fast rollback → blue-green on App Engine or Cloud Run; gradual rollout with monitoring → canary with Cloud Deploy; container deployment across environments → Cloud Deploy with targets.
Core concept 3 — SRE as a release mechanism, not a philosophy
GCP §15 · where C7 stops being a dashboard
1Intuition
You know the C7 definitions already: an SLI is a measured signal, an SLO is the target for it, an SLA is the contractual version with penalties, and the error budget is the unreliability the SLO permits — 99.9% availability over 30 days allows roughly 43 minutes of failure.
What this session adds is one move: stop treating the error budget as a report and start treating it as a spending account with a card attached.
Budget remaining? Ship. That is what it is for — an unspent error budget means you are over-investing in stability and under-investing in features. Budget exhausted? The pipeline stops accepting risky changes until reliability work restores it. No negotiation, no escalation, no judgement call at 4 p.m. on a Friday. It is the same rule for everyone, and it converts an argument about "are we moving too fast" into arithmetic.
Google's other two principles support this. Toil — manual, repetitive, automatable work with no lasting value — is capped at under 50% of an SRE's time, which is a budget for building automation rather than a hope. Blameless postmortems mean the goal is learning, not fault, because a team that fears blame hides the near-misses you most need to see.
2Mechanism
Here is the wiring. Every box is a service the GCP section already describes — the contribution of this stage is connecting them into a loop that closes without a human in it.
3Trade-offs, limits, and where it breaks
- The blended-SLI trap is the big one, and it is counter-intuitive. A small canary cannot move a global error rate enough to trip a global alert. You can run a completely broken model version at 1% and watch the SLO dashboard stay green for the entire bake window. The calculator below makes this concrete; internalise it before designing your first rollout.
- Bake windows fight statistics. A 5% canary on a low-traffic service may not see enough requests in fifteen minutes to distinguish a real regression from noise. Either lengthen the window, widen the slice, or accept that you are not measuring anything. Compute the request count before choosing the numbers.
- Automatic rollback needs a clean rollback path. Stateless services roll back trivially. Anything that has written a schema change, migrated data, or emitted events consumed by others does not. Expand/contract migrations are the prerequisite, not an optimisation.
- Quality regressions do not show up in latency or errors. This is the GenAI-specific failure. A new adapter that responds fast, returns HTTP 200 every time, and produces worse answers passes every conventional SLI. You need a quality SLI — an eval score on sampled traffic, a refusal rate, a response-length distribution — or the canary is checking that the server is up, not that the model is good.
- Error-budget policy is organisational, not technical. The hardest part is not the query; it is the prior agreement that an exhausted budget genuinely stops feature work. Without that agreement it is a dashboard everyone glances at and overrides.
Decision: Cloud Deploy, GitHub Actions, or a GitOps agent?
Follow "no ↓" until a "yes" exits right. The bottom-left box is the default.
Guard clauses · choosing a delivery mechanism on GCP
Cloud Build is the CI answer in almost every branch — this tree is about the delivery half.
no ↓
no ↓
no ↓
no ↓
cloudbuild.yaml. A delivery pipeline resource for a single target is ceremony without benefit — add it when the second environment appears.no ↓
The four dialects — release and delivery layer
The comparison from S1 and S2, now at the CD layer where GCP is strongest
| Capability | AWS native | Azure native | GCP native | Terraform / OpenTofu |
|---|---|---|---|---|
| CI service | CodeBuild | Azure Pipelines / GitHub Actions | Cloud Build — every step is a container | Not a CI tool; runs inside one |
| Release orchestration | CodePipeline | Azure Pipelines stages + Environments | Cloud Deploy — pipeline, target, release, rollout as first-class resources | n/a |
| Canary as configuration | CodeDeploy traffic shifting (Lambda, ECS) | Container Apps revision weights; Pipelines canary strategy | Percentages + phases in the pipeline YAML, with verify per phase | Declares resources, not rollouts |
| Automatic phase advance | Via CodePipeline stages + alarms | Via gates on Environments | advance-rollout and promote-release automation rules (GA 2024) | n/a |
| Post-deploy verification | CodeDeploy ValidateService lifecycle hook | Pipelines postRouteTraffic hook; Monitor gates | verify: true — a container run per phase | n/a |
| Automatic rollback trigger | CloudWatch alarm on the deployment group | Monitor alert gate; slot swap back | Failed verify, or an alerting policy; rollback to last successful rollout | n/a |
| Change-freeze windows | Custom, via pipeline conditions | Business-hours check on Environments | Deploy policies — a first-class resource | n/a |
| Continuous drift correction | Flux + CFN Template Sync Controller | Deny settings prevent rather than correct | Config Connector / Config Sync — reconciles continuously | Plan-based only |
| Artifact store | Amazon ECR | Azure Container Registry | Artifact Registry | Consumes, does not host |
| SLO tooling in the loop | CloudWatch alarms | Azure Monitor alert-query gates | Cloud Monitoring SLO objects — SLIs, targets and burn rates as native resources | Can provision the SLO objects themselves |
Reality check
Design a canary on paper, prove the blind spot with arithmetic, then three cited things
Worked example: designing a model-version canary and its abort threshold
Concrete scenario, using your numbers. A Gemma inference endpoint serving 10 million requests per month — about 3,858 per minute. C7 gave it a 99.9% availability SLO over 30 days, which is an error budget of 10,000 failed requests, or roughly 43 minutes of total failure. You are rolling out a new fine-tuned adapter.
Canary blind-spot calculator
Move the sliders. The question is whether your global SLO alert would ever notice the canary is broken. The standard fast-burn page fires at a 14.4× burn rate over one hour.
Try this sequence, in order. Start at the defaults — 1% traffic, 10% errors — and note the global burn rate is exactly 1.0×, precisely at budget, completely invisible. Now push the error rate to 100%: a totally broken canary at 1% still only reaches 10× burn, still under the page threshold. Now leave errors at 10% and widen the slice to 20%: the global alert finally fires — but you have exposed twenty times as many users to get there. That is the trade the calculator exists to make visible.
Canary size and detectability are in direct tension, and you cannot resolve it by watching global metrics. A slice small enough to be safe is too small to move a blended number. The only way out is to measure the canary version separately — label your metrics by version and alert on the canary's own error rate against the same threshold. Then a 1% canary at 10% errors reads as a 100× burn rate and aborts within a minute, having harmed 1% of traffic instead of all of it.
This is the single most useful thing in this session, and it is not in any of your four sources.
The resulting rollout design
| Phase | Traffic | Bake | Requests in window | Abort if (canary-scoped) |
|---|---|---|---|---|
| 1 · Smoke | 0% (deployed, no traffic) | — | Synthetic only | verify container fails — health, model loads, one known-good prompt returns sane output |
| 2 · Canary | 5% | 15 min | ≈2,900 | Canary error rate > 1%, or canary p95 latency > 850 ms, or eval score > 5% below incumbent |
| 3 · Expand | 25% | 30 min | ≈29,000 | Same thresholds, now with enough volume for the latency distribution to be trustworthy |
| 4 · Majority | 50% | 60 min | ≈116,000 | Same thresholds, plus a cost check — GPU utilisation per request against the incumbent |
| 5 · Full | 100% | 24 h watch | All | Standard SLO alerting resumes. Keep the previous revision deployable for the whole window. |
Three of the five abort conditions above are conventional — errors, latency, cost. The fourth is not, and for a model rollout it is the one that matters most: a quality SLI.
A new adapter can be fast, return HTTP 200 on every request, use less GPU than the incumbent, and be worse. Refusals climb. Answers get shorter and blander. A regression in one domain hides inside an aggregate that looks fine. Every conventional SLI passes and the canary promotes itself to 100% on schedule.
Practical options, cheapest first: run a fixed eval set through the canary during each phase and compare scores; track refusal rate and response-length distribution as cheap proxies; sample live traffic into an LLM-as-judge scorer; watch a downstream behavioural signal like thumbs-down rate or retry rate. Any of these beats not having one. A model canary without a quality signal is checking that the server is up.
Three cited things
Deployment Manager: end of support, 31 March 2026
Google's deprecation notice is unambiguous: support for Cloud Deployment Manager is discontinued, all related APIs and functions are no longer supported, and customers were told to migrate to Infrastructure Manager or an alternative before that date. Google's stated reasoning was that it had spent years modernising the Google Cloud deployment experience through Infrastructure Manager.
The comparison in Google's own migration document is the part worth quoting the shape of: Deployment Manager defined infrastructure declaratively in YAML with Jinja or Python templates; Infra Manager leverages Terraform, so you write Terraform configuration files which Infra Manager deploys and manages on Google Cloud. Google also shipped DM Convert to translate existing configurations, and pointed users at terraform import to adopt already-deployed resources without recreating them.
Why it matters beyond the correction: this is a hyperscaler concluding that owning the IaC language was not worth it. When you argue "native versus Terraform" in a design review, that is a data point on the Terraform side that costs you nothing to cite.
The automation that turns delivery into deployment went GA in 2024
Cloud Deploy's release notes track the progression precisely: canary strategy support reached general availability, parallel deployment to multiple targets reached GA, and delivery pipeline automation became generally available in March 2024. The two rules are the ones that matter for this session — promote-release automatically advances a release between targets, and advance-rollout automatically advances a canary between its percentage phases as each one succeeds. Every automation run produces an automationRun resource, so you get an audit trail of what advanced, when, and why.
Two further capabilities arrived alongside: automatic retry of failed rollouts and automatic rollback to the most recent successful rollout, and deploy policies that block rollouts during a specified time window.
Why it matters: this is the complete SLO-gated rollback loop available as configuration rather than as a bespoke system. Two years ago you would have written this yourself with Cloud Functions and alerting webhooks — the material's own suggestion. Now you declare it.
The 2025 report restructured itself around exactly this session's argument
DORA's 2025 research reorganised its metrics into throughput and stability as separate categories, added rework rate as a fifth metric — how often teams must ship unplanned fixes for user-facing defects — and redefined mean-time-to-restore as failed deployment recovery time. It also replaced the old low/medium/high/elite tiers with seven team archetypes, because a single performance axis stopped explaining the data.
The finding underneath the restructure: AI adoption raises throughput while correlating with higher instability — more change failures, more rework, longer recovery. One analysis of the same period found PRs merged per person up 98% while incidents per PR rose 242.7%. DORA's framing is that AI is an amplifier: it makes strong delivery systems faster and weak ones more chaotic.
Why it matters to this session specifically: the report's own conclusion is that reliability sits in its own category, assessed through SLOs and SLIs reflecting user experience. That is precisely the mechanism in the wiring diagram above. If you are shipping model changes at AI speed — and you are — the canary, the quality SLI, and the error-budget gate are not process overhead. They are the things keeping the second number down while the first one climbs.
Four properties that make a release trustworthy
- Hermetic builds. Same source in, same artifact out, regardless of which machine built it. Pin base images by digest, lock dependencies, never resolve
latestat build time. Without this, "we tested this artifact" is not a true statement. - Build once, promote many. One artifact travels dev → staging → prod unchanged. Cloud Deploy enforces this structurally: a release is an immutable snapshot, and promotion moves it rather than rebuilding it.
- Policy at the gate, not in a wiki. Binary Authorization can require that an image was built by your pipeline and passed its checks before GKE or Cloud Run will run it. That converts "we always scan images" from an aspiration into an admission-control decision.
- Toil budget as a real budget. Google's under-50% rule is what pays for all of the above. If nobody is allowed time to automate the release, the release stays manual, and every point in this stage stays theoretical.
Apply it — rebuild your Vertex deployment properly
Session 3 · the end goal of this stage, on your own workload
This is the thing the whole stage was pointing at. Your Vertex AI deploy script builds an image, pushes it, and creates an endpoint — imperative, non-idempotent, unreviewable, with no teardown. Here is the honest target architecture, including the part that should not be Terraform.
The durable plumbing
Artifact Registry repository, the Vertex AI endpoint itself, service accounts and IAM bindings, the weights bucket, the GKE cluster and node pools if you serve there, monitoring dashboards, alert policies, and the Cloud Deploy delivery pipeline resource.
Everything here is long-lived, changes rarely, benefits from plan review, and should be destroyable in one command. This is the layer your script never had.
The artifact
Build the serving container, run tests, push to Artifact Registry with an immutable digest tag, scan it. Triggered by a merge to main. Produces exactly one thing and then stops.
The model weights are not in this layer's git history — they live in the bucket Terraform declared, versioned separately. Keep artifacts and artifact stores in different tools.
The model-version rollout
Upload the model version, deploy it to the endpoint alongside the incumbent at 0% traffic, then shift the traffic split in steps while watching the canary-scoped SLIs.
This is genuinely imperative and that is correct. Model upload is a one-shot operation; a traffic split is a sequence of timed decisions. Do not contort it into a declarative resource — declare the endpoint, orchestrate the rollout.
resource "google_vertex_ai_endpoint" "gemma" {
name = "gemma-inference-${var.env}"
display_name = "gemma-inference-${var.env}"
location = var.region
labels = { env = var.env, managed_by = "terraform" }
}
resource "google_container_node_pool" "l4" {
cluster = google_container_cluster.serving.id
initial_node_count = 0
autoscaling { min_node_count = 0, max_node_count = 3 } # scale to zero
node_config {
machine_type = "g2-standard-8"
guest_accelerator { type = "nvidia-l4", count = 1 }
spot = var.env != "prod" # spot everywhere but prod
}
lifecycle { ignore_changes = [node_count] } # the autoscaler owns this
}
Read the last two lines of each block. min_node_count = 0 is the difference between an idle L4 costing money overnight and costing nothing. ignore_changes = [node_count] is the S1 drift lesson applied: the autoscaler owns that field, so Terraform must be told not to fight it. Both are one line, and both are the kind of thing an imperative script has no place to express.
- Two versions on one endpoint means two sets of replicas. A canary is not free on GPU — during the overlap you are paying for incumbent capacity plus canary capacity. Give the canary
min_replica_count = 1and let it autoscale; do not mirror production sizing for a 5% slice. - Model load time dominates your rollout clock. A multi-gigabyte model takes minutes to load before it serves a single request. Deploy at 0% traffic first, wait for readiness, then start shifting. Skipping the 0% phase means your first canary requests hit a cold replica and your latency SLI aborts a perfectly good rollout.
- Accelerator duty cycle is an SLI you actually have. Vertex exposes autoscaling targets on accelerator duty cycle, not just CPU. A new adapter that quietly needs 40% more GPU per token shows up here long before it shows up in the invoice.
- Keep the previous version deployed at 0% during the watch window. Rollback then costs one traffic-split update rather than a full redeploy and reload. On GPU that is the difference between seconds and ten minutes of degraded service.
- Prediction logging is a real cost line. Sampling at 10% into BigQuery is manageable; 100% scales with request volume and surprises people. Declare the sampling rate in IaC so it is a reviewed decision rather than a default.
Optional lab — Terraform a scale-to-zero endpoint, then canary a version (~60 minutes)
The most expensive lab in this stage, so read the guardrails first. It is still under a dollar if you follow them, and it is the one that most directly rebuilds the thing you already wrote imperatively.
Before you start — cost guardrails, and these are not optional
- Set a budget alert with a $5 threshold before you create anything. Billing → Budgets & alerts. GPU resources bill by the minute and mistakes here cost real money, unlike S1 and S2.
- Use CPU, not GPU, for the lab itself. The mechanics of endpoints, traffic splits and canaries are identical on
n1-standard-2and cost a fraction. Prove the pattern on CPU; apply it to L4 later with the numbers you already trust. - Set
min_replica_count = 1and delete the endpoint the same day. Vertex endpoints bill for provisioned replicas whether or not anything calls them. This is the single most common surprise-bill mechanism on Vertex. - If you insist on a GPU, request L4 quota first and expect the apply to fail with a quota error otherwise — that failure is a quota issue, not a bug in your config.
1 · Provision the plumbing declaratively
# backend + provider omitted — use a GCS bucket with versioning
resource "google_artifact_registry_repository" "models" {
location = var.region
repository_id = "inference-lab"
format = "DOCKER"
}
resource "google_vertex_ai_endpoint" "lab" {
name = "lab-endpoint"
display_name = "lab-endpoint"
location = var.region
labels = { env = "lab", delete_by = "today" }
}
terraform init && terraform plan # read it properly
terraform apply
2 · Deploy two model versions to one endpoint
Use the SDK — this is layer 3, and it is imperative on purpose. Deploy v1 at 100%, then deploy v2 at 0% traffic and wait for it to become ready before shifting anything.
gcloud ai endpoints deploy-model $ENDPOINT_ID --region=$REGION \
--model=$MODEL_V2 --display-name=v2-canary \
--machine-type=n1-standard-2 --min-replica-count=1 \
--traffic-split=0=100 # v2 deployed, receiving nothing
3 · Run the canary by hand, once, to feel the rhythm
# shift 5% to the canary
gcloud ai endpoints update $ENDPOINT_ID --region=$REGION \
--traffic-split=$V1_ID=95,$V2_ID=5
# ... send traffic, watch, wait the bake window ...
# then 25, then 50, then 100 — or roll back in one command
gcloud ai endpoints update $ENDPOINT_ID --region=$REGION \
--traffic-split=$V1_ID=100
That last command is the rollback. One call, seconds, no rebuild, no reload — because the incumbent was never undeployed. Notice how much cheaper that is than anything your original script could offer.
4 · Now make it measurable — this is the actual lesson
- In Cloud Monitoring, build a chart of prediction error rate filtered by
deployed_model_id. Confirm you can see the two versions separately. - Deliberately deploy a broken v2 — a container that returns 500 on one in ten requests. Set the split to 5%.
- Watch the blended endpoint error rate. It will sit around 0.5%. Then look at the per-version chart: the canary is at 10%.
- That gap, on your own screen with your own traffic, is the calculator made real. Everything else in this lab is setup for this one observation.
5 · Stretch, if you have time
- Create a Cloud Monitoring SLO object on the endpoint and look at the burn-rate chart. GCP models this natively; it is worth seeing.
- Write the traffic-shift loop as a script that reads the canary-scoped SLI between steps and aborts on breach. That script is a hand-rolled Cloud Deploy automation rule — and writing it once is the best way to understand what the managed service is doing for you.
- Move the endpoint into a module with an
envvariable and instantiate it twice. Three environments, one definition — the S1 arithmetic, on your own workload.
Undeploy both models first, since Terraform does not own them: gcloud ai endpoints undeploy-model $ENDPOINT_ID --region=$REGION --deployed-model-id=$ID for each.
Then terraform destroy and confirm the plan shows every resource going. Finally check the billing page for anything still running — an undeployed model still occupies Model Registry, and a forgotten replica is the one thing here that bills overnight.
The asymmetry is the lesson: Terraform tore down its layer in one command; the SDK-managed layer needed manual cleanup. That is exactly the trade you accepted in the three-layer design, and it is why the boundary between the layers should be a deliberate decision rather than an accident.
Stage close — what you can now do
Check yourself against the end goals from Tab 0
You can explain, without notes
- Why declarative desired state beats imperative scripts, using the reconciliation loop and your own Vertex script as the counter-example.
- The config/state/reality triangle, and which comparison produces a plan, drift, and an import.
- Why Terraform is not open source, what OpenTofu is, and when the distinction changes a decision.
- The three deployment strategies with their cost, rollback-speed and blast-radius trade-offs.
- Why a 1% canary is invisible to a global SLO alert, with the arithmetic.
You can design and build
- A pipeline with build, test, gate and staged-rollout stages, and defend each gate.
- An SLO-gated automatic rollback wired from a canary-scoped SLI to a traffic-split reversion.
- The same intent in CloudFormation/CDK, in Bicep with a Deployment Stack, and in Terraform on GCP — and say why you would choose each.
- A three-layer split for a model deployment: Terraform for plumbing, CI for the artifact, an orchestrator for the version rollout.
- A rollout whose abort thresholds — including a quality SLI — were agreed before the deploy started.
The one habit worth carrying out of this stage above all others: put the plan in the pull request. Not the code — the plan. A reviewer who approves an outcome catches the -/+ replace on the weights bucket, the silently removed incident fix, and the resource that quietly became an orphan. A reviewer who approves a script catches typos.
Everything from Stages 1–9 is now expressible as code, reviewable as a diff, and deployable through a pipeline that can undo itself. The natural next question is governance at scale: policy as code, cost controls that enforce rather than report, and how an organisation keeps hundreds of these pipelines coherent without slowing any of them down.
Web grounding (verified July 2026): Deployment Manager deprecation — Google Cloud Cloud Source Repositories end of sale Cloud Deploy release notes Cloud Deploy pipeline automation GA The Future of AWS CodeCommit CrowdStrike Falcon Content Update Preliminary PIR AWS DynamoDB DNS race-condition postmortem coverage Bicep with Azure Pipelines (BicepDeploy@0) azure/bicep-deploy action Microsoft's Bicep + GitHub Actions reference implementation OpenTofu manifesto CloudFormation Template Sync Controller for Flux. DORA 2025 State of DevOps figures as reported across DORA and secondary analyses.
C10 of a Cloud for GenAI · Line D curriculum. Teaching material written from the sources above, in the original own words; no source text or figures reproduced.