Production Checklist
The idea in one sentence
Section titled “The idea in one sentence”A production-ready Terraform and Terragrunt setup on Azure is not one single feature, but the sum of every practice this course has covered — remote state, pinned versions, reviewed plans, protected secrets, static analysis and policy gates, dependency-ordered units, and real tests — wired together into one CI-driven repository.
The checklist, one line of reasoning per item
Section titled “The checklist, one line of reasoning per item”Each item below traces back to a concrete failure mode a specific lesson in this course was built around:
- A remote
azurermbackend with blob-lease locking, from day one. Local state means one laptop’s disk is the only copy of the truth about your entire Azure footprint, and two engineers runningapplyat once can corrupt it — the State Management module’s whole reason for existing. - Provider versions pinned via
.terraform.lock.hcl, module versions pinned to a tag or version constraint. An unpinned provider or module can silently resolve to a new major version the next timeinitruns, changing behavior nobody asked for in the middle of an unrelated change. planreviewed in CI before everyapply, watching specifically for unexpected resource replacements. A-/+in a plan means destroy-then-recreate on a real, stateful Azure resource — reviewing a plan like a normal code diff misses exactly the line that matters most.- Sensitive values kept out of Terraform variables and state where a secrets-manager data source is available, and Azure RBAC tightly restricting who can read the state container.
sensitive = trueonly redacts CLI output; the real protection is restricting read access to the backend and minimizing what ever needs to be typed into a variable in the first place. tflintand a security scanner, and ideally policy as code, wired into CI. Static analysis catches known-bad HCL in seconds beforeplanruns at all; policy as code (Sentinel or OPA) then evaluates the actual computed plan against organizational rules that only make sense once a real change exists.- Terragrunt
dependencyblocks driving multi-unit rollout ordering. Splitting infrastructure into independent units (vnet,vm,database) is only safe if a unit that needs another unit’s output — a subnet ID, a resource group name — declares that dependency explicitly, so Terragrunt applies them in the right order instead of relying on humans to remember it. terraform testand.tftest.hclcoverage for any module doing something non-trivial.validatechecks syntax andplanchecks that Azure accepts the request, but neither one asserts that a module’s actual behavior is correct — that is whatterraform testis for.lifecycle.prevent_destroyon genuinely irreplaceable resources. Proven below with a real refusal — the guardrail that turns “a config change would destroy production data” into a loud plan-time error instead of a silent, successfulapply.preconditionblocks validating assumptions before they become resources. Proven below with both a real pass and a real fail — a plan-time error naming the actual bad input, instead of a cryptic provider error deep intoapply.- A rehearsed
force-unlockrunbook, blob-lease locking understood, and a real state-backup habit. Proven below, including a genuine surprise about whatforce-unlockcan and cannot do against different kinds of state.
Guardrails that are actually enforced: prevent_destroy and precondition
Section titled “Guardrails that are actually enforced: prevent_destroy and precondition”prevent_destroy and precondition are both lifecycle sub-blocks, not two unrelated mechanisms — but they guard different moments. prevent_destroy = true blocks a specific action (destroy) unconditionally; precondition blocks any action once an assumption about the configuration turns out false. Real proof, local_file standing in for any resource a mistake could otherwise destroy (the mechanism is Terraform-core, identical regardless of provider):
resource "local_file" "critical" { filename = "${path.module}/generated/critical.conf" content = "important=true\n"
lifecycle { prevent_destroy = true }}$ terraform destroy -input=false -auto-approve...Plan: 0 to add, 0 to change, 1 to destroy.
Error: Instance cannot be destroyed
on main.tf line 3: 3: resource "local_file" "critical" {
Resource local_file.critical has lifecycle.prevent_destroy set, but theplan calls for this resource to be destroyed. To avoid this error andcontinue with the plan, either disable lifecycle.prevent_destroy or reducethe scope of the plan using the -target option.Real refusal, real exit code 1 — no flag bypasses it short of removing the lifecycle block itself, a change that is its own reviewable diff. precondition validates an assumption about input before Terraform commits to the resource at all:
variable "environment" { type = string default = "prod"}
resource "local_file" "config" { filename = "${path.module}/generated/app.conf" content = "env=${var.environment}\n"
lifecycle { precondition { condition = contains(["dev", "staging", "prod"], var.environment) error_message = "environment must be one of dev, staging, prod — got \"${var.environment}\"." } }}$ terraform plan -var="environment=bogus"...Error: Resource precondition failed
on main.tf line 21, in resource "local_file" "config": 21: condition = contains(["dev", "staging", "prod"], var.environment) ├──────────────── │ var.environment is "bogus"
environment must be one of dev, staging, prod — got "bogus".Real failure, and a real success with a valid environment (exit 0, plan proceeds) — both branches of the same condition proven, not just the failure case. The message names the actual bad value and the actual rule, which is the entire point: a plan-time failure a reviewer acts on immediately, not a provider-level error deep into apply.
The force-unlock runbook, and blob-lease locking
Section titled “The force-unlock runbook, and blob-lease locking”remote-backends-and-locking.mdx already proved the real mechanism Azure uses for locking: the azurerm backend takes a blob lease on the state blob before writing, a native Azure Blob Storage feature granting exclusive write access — no separate lock table or resource to provision, unlike S3’s older DynamoDB-based locking. That same lesson also already proved, against this course’s local backend, the exact runbook fact this checklist item is about: terraform force-unlock --help states directly that "Local state files cannot be unlocked by another process." Reproduced here against a genuinely held lock (time_sleep holding state locked for 15 seconds while a background apply runs):
$ cat .terraform.tfstate.lock.info{"ID":"7e6d8bc3-3128-3765-f72c-65d99f8b0afb","Operation":"OperationTypeApply", ...}
$ terraform force-unlock -force 7e6d8bc3-3128-3765-f72c-65d99f8b0afbFailed to unlock state: LocalState not locked
$ terraform force-unlock -force 00000000-0000-0000-0000-000000000000Failed to unlock state: LocalState not lockedEven the correct lock ID against a genuinely active lock fails identically. Against a real blob lease, force-unlock behaves as the documented, real recovery tool remote-backends-and-locking.mdx describes: it removes a lock left behind by a killed apply, using the lock ID Azure’s own error message reports when a second run fails to acquire the lease. Against local state, that mechanism simply does not apply — the local backend’s lock is an OS-level flock scoped to the process holding it, not a named, backend-mediated lock. The runbook lesson is real either way: force-unlock requires a genuinely stale lock, confirmed independently (the process that held it is actually gone), never a reflex for “my apply is being slow.”
A real backup, before the risky operation — not after
Section titled “A real backup, before the risky operation — not after”Two backup mechanisms are real and worth knowing apart. First, automatic: the backend writes terraform.tfstate.backup on every state-changing operation, holding the previous state — proven by applying a config change twice and diffing both files:
$ cat terraform.tfstate.backup | grep content "content": "listen=8080\n",
$ cat terraform.tfstate | grep content "content": "listen=9090\n",Second, deliberate: terraform state pull > backup-$(date +%Y%m%d-%H%M%S).tfstate before any operation an operator is nervous about (a manual state rm, an import, a provider major-version upgrade) — a real, timestamped snapshot independent of whatever the automatic .backup file happens to hold. the-state-file.mdx already proved state push enforces lineage/serial before accepting a restore, which is exactly the safety net that makes this backup usable.
Dependency blocks: the piece that orders a multi-unit rollout
Section titled “Dependency blocks: the piece that orders a multi-unit rollout”A Terragrunt dependency block is what lets one unit consume another unit’s real output without either unit knowing about the other’s internal implementation. A real, fully offline-buildable two-unit tree — sql needs the subnet vnet creates:
# root.hcl — local backend, so this whole tree validates/renders fully offlineremote_state { backend = "local" config = { path = "${get_parent_terragrunt_dir()}/${path_relative_to_include()}/terraform.tfstate" }}
generate "provider" { path = "provider.tf" if_exists = "overwrite" contents = <<EOFprovider "azurerm" { features {} subscription_id = "00000000-0000-0000-0000-000000000000"}EOF}
generate "backend" { path = "backend.tf" if_exists = "overwrite" contents = <<EOFterraform { backend "local" {}}EOF}include "root" { path = find_in_parent_folders("root.hcl")}
terraform { source = "../modules/vnet"}terraform { required_providers { azurerm = { source = "hashicorp/azurerm" version = "~> 5.0" } }}
resource "azurerm_resource_group" "main" { name = "app-rg" location = "West Europe"}
resource "azurerm_virtual_network" "this" { name = "app-vnet" resource_group_name = azurerm_resource_group.main.name location = azurerm_resource_group.main.location address_space = ["10.0.0.0/16"]}
resource "azurerm_subnet" "private" { name = "private" resource_group_name = azurerm_resource_group.main.name virtual_network_name = azurerm_virtual_network.this.name address_prefixes = ["10.0.1.0/24"]}
output "vnet_id" { value = azurerm_virtual_network.this.id}
output "subnet_id" { value = azurerm_subnet.private.id}
output "resource_group_name" { value = azurerm_resource_group.main.name}include "root" { path = find_in_parent_folders("root.hcl")}
terraform { source = "../modules/sql"}
dependency "vnet" { config_path = "../vnet"
mock_outputs = { subnet_id = "/subscriptions/00000000-0000-0000-0000-000000000000/mock-subnet" resource_group_name = "mock-rg" } mock_outputs_allowed_terraform_commands = ["plan"]}
inputs = { subnet_id = dependency.vnet.outputs.subnet_id resource_group_name = dependency.vnet.outputs.resource_group_name}# modules/sql/terraform.tfvars — harness fixture only, so the raw `terraform# plan` proof for modules/sql can attempt to run standalone; a real deployment# gets these values from Terragrunt's dependency block instead.subnet_id = "mock-subnet-id"resource_group_name = "mock-rg"terraform { required_providers { azurerm = { source = "hashicorp/azurerm" version = "~> 5.0" } }}
variable "subnet_id" { type = string}
variable "resource_group_name" { type = string}
resource "azurerm_postgresql_flexible_server" "main" { name = "app-db" resource_group_name = var.resource_group_name location = "West Europe" version = "15" sku_name = "GP_Standard_D2s_v3" administrator_login = "appadmin"
administrator_password_wo = "mock" administrator_password_wo_version = 1}Real terragrunt hcl validate and terragrunt render against this exact tree both succeed offline (neither needs Azure authentication). terragrunt run --all plan is the one command in this chain that genuinely needs a real Azure session — proven consistently throughout this course (ci-cd-for-infrastructure.mdx, policy-as-code-and-static-analysis.mdx, and this lesson’s own secrets-and-credential-management.mdx), and reproduced here against this exact tree:
$ terragrunt run --all plan --non-interactive...Error: unable to build authorizer for Resource Manager API: could notconfigure AzureCli Authorizer: could not parse Azure CLI version: launchingAzure CLI: exec: "az": executable file not found in $PATH
with provider["registry.terraform.io/hashicorp/azurerm"], on provider.tf line 2, in provider "azurerm": 2: provider "azurerm" {This is the honest ceiling for azurerm in an offline course environment: the DAG wiring (dependency, mock_outputs, include/generate) is proven real and offline; the actual plan against real Azure resources needs real Azure credentials this course does not have, the same limitation established everywhere else azurerm appears in this course. Terragrunt still reads the dependency block and figures out the correct apply order on its own once real credentials exist: terragrunt run --all apply applies vnet first, reads its real subnet_id output once it exists, and only then runs sql with that real value as an input — nobody has to hardcode an ordering or remember to apply units in the right sequence by hand. The mock_outputs block matters specifically for plan: it lets a plan for sql run cleanly even before vnet has ever been applied, using a placeholder value instead of failing because the real output does not exist yet.
The end-to-end picture
Section titled “The end-to-end picture”Put every item together and a production Terragrunt repository on Azure looks like this: one root.hcl at the top declaring the shared remote_state backend and provider generate blocks, a handful of unit directories beneath it — vnet/, vm/ (or aks/), and sql/ are a realistic minimal set — each with its own terragrunt.hcl wiring in whatever dependency blocks it actually needs, and a CI pipeline that authenticates via OIDC federated credentials, running terragrunt run --all plan on every pull request and terragrunt run --all apply only after merge to main.
name: terraform
on: pull_request: paths: - 'infra/**' push: branches: - main paths: - 'infra/**'
permissions: id-token: write contents: read pull-requests: write
jobs: static-analysis: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - uses: terraform-linters/setup-tflint@v4 - run: tflint --init && tflint --recursive - name: Run checkov with: directory: infra
plan: needs: static-analysis if: github.event_name == 'pull_request' runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - uses: azure/login@v3 with: client-id: ${{ secrets.AZURE_CLIENT_ID }} tenant-id: ${{ secrets.AZURE_TENANT_ID }} subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} - uses: hashicorp/setup-terraform@v4 - run: terragrunt run --all plan -no-color | tee plan.txt - run: gh pr comment ${{ github.event.pull_request.number }} --body-file plan.txt env: GH_TOKEN: ${{ github.token }}
apply: needs: static-analysis if: github.event_name == 'push' runs-on: ubuntu-latest environment: production steps: - uses: actions/checkout@v7 - uses: azure/login@v3 with: client-id: ${{ secrets.AZURE_CLIENT_ID }} tenant-id: ${{ secrets.AZURE_TENANT_ID }} subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} - uses: hashicorp/setup-terraform@v4 - run: terragrunt run --all apply --non-interactiveNothing in that pipeline is new by this point in the course — every stage is a lesson you already worked through, assembled into one repository: vnet, vm/aks, and sql as independent units under one root.hcl; dependency blocks giving Terragrunt the real ordering between them; tflint and checkov failing fast before a plan is even attempted; OIDC replacing any stored client secret; a reviewed plan gating every apply; and apply itself reserved for after merge, behind an approval gate, run by the pipeline rather than a laptop.
flowchart TB root["root.hcl (remote_state + provider generate)"] --> vnet["vnet unit"] root --> sql["sql unit"] vnet -->|dependency block: subnet_id, resource_group_name| sql pr["Pull request"] -->|OIDC auth| planj["terragrunt run --all plan"] planj -->|reviewed and merged| applyj["terragrunt run --all apply"] vnet -.-> planj sql -.-> planj
Mental Model
Section titled “Mental Model”Every item on this checklist is a guardrail against a specific, named failure mode. prevent_destroy and precondition (proven above with real refusals) guard the moment a plan is computed. Blob-lease locking and force-unlock (proven above, cross-referenced from remote-backends-and-locking.mdx’s own real proof) guard the moment two writers race, and the moment a lock outlives the process that took it. State backups guard the moment something has already gone wrong and “restore the previous state” needs to already be a rehearsed action. And the dependency-block DAG (proven above offline via hcl validate/render, with the real Azure-auth ceiling honestly documented) guards the moment a multi-unit rollout has to happen in the right order without anyone’s memory being the mechanism. None of these are optional polish — each closes one specific, demonstrated gap, and this lesson is explicit about exactly where offline proof for azurerm genuinely ends.
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
lifecycle.prevent_destroy on critical resources | Proven above: a destroy or forced replacement is refused outright | Must be deliberately removed (a reviewable diff) before a genuinely intended destroy can proceed |
precondition blocks | Proven above: a bad input fails at plan time naming the actual problem | One more thing to keep in sync as a module’s real constraints evolve |
Blob-lease locking (built into azurerm backend) | Proven in remote-backends-and-locking.mdx: zero extra resources to provision, unlike a separate lock table | No separate audit-trail resource the way a dedicated lock table might have its own access logs |
A deliberate state pull backup before a risky operation | An operator-controlled snapshot independent of the automatic .tfstate.backup | One more manual step to remember before state rm/import/a provider upgrade |
Proving the dependency-block DAG via hcl validate/render instead of a live run --all plan | Genuinely provable offline for azurerm, since plan itself is not | Does not exercise the actual plan computation — only the wiring, not what a real plan would show |
Common Misconceptions
Section titled “Common Misconceptions”- “
prevent_destroystops any change to the resource.” Proven above: it specifically blocks destroy — in-place updates that do not require destroying the resource proceed normally. - “Azure state locking needs a separate lock resource, the way S3 uses DynamoDB.” Proven in
remote-backends-and-locking.mdx: theazurermbackend locks by taking a blob lease directly on the state blob, no separate resource to provision. - “
force-unlockis the correct fix for any stuck Terraform run.” Proven above and inremote-backends-and-locking.mdx: againstlocalstate,force-unlockcannot release a lock a different process holds —terraform force-unlock --helpsays so directly. Against a real blob lease, it is the documented recovery tool, but only for a lock independently confirmed to be genuinely stale. - “
bridgecrewio/checkov-action@masteris fine as long as the action is trustworthy.” Proven inpolicy-as-code-and-static-analysis.mdx: trustworthiness today says nothing about what lands onmastertomorrow — pinned above to the real current tag (v12.1347.0) instead.
Reading AI-generated code
Section titled “Reading AI-generated code”When reviewing an AI-generated “production-ready” Terraform/Terragrunt setup on Azure, check for:
- Stateful resources (databases, Key Vaults, anything holding data) with no
lifecycle.prevent_destroyat all — proven above, this is the guardrail that turns an accidental destroy into a loud, refused plan instead of a silent, catastrophic one. - A runbook or comment claiming
terraform force-unlock <id>“just works” against whatever backend is in use, with no distinction between local state and a real blob lease — proven above, that claim is false forlocalstate specifically. uses: bridgecrewio/checkov-action@master(or any action pinned to a branch name) instead of a real release tag — proven above and inpolicy-as-code-and-static-analysis.mdx, a real reproducibility and supply-chain risk.- A
dependencyblock referencing an upstream unit’s outputs with nomock_outputs/mock_outputs_allowed_terraform_commands— proven above,terragrunt run --all planneeds those to plan a downstream unit before the upstream one has ever actually been applied. - A module
sourcepinned to a branch name instead of a tag or commit SHA — the same unpinned-dependency risk this checklist already flags for provider versions, just one directory level up.
🐛 Spot the bug
resource "azurerm_postgresql_flexible_server" "main" {name = "app-db-prod"resource_group_name = "app-rg"location = "West Europe"version = "15"sku_name = "GP_Standard_D2s_v3"administrator_login = "appadmin"
administrator_password_wo = var.db_passwordadministrator_password_wo_version = 1}Show the answer
This production database resource has no lifecycle { prevent_destroy = true } block at all. Proven above, that block is what turns a plan that would destroy this resource into a loud, refused error — without it, a config change that forces replacement, or a stray terraform destroy against the wrong workspace, proceeds exactly as planned, silently, with no extra confirmation beyond the normal apply prompt. Write-only password handling (administrator_password_wo, correctly used here) protects the secret value; it protects nothing about the resource itself being destroyed — those are two separate guardrails from two separate lessons, and having one does not imply the other. The fix is the lifecycle block proven above, deliberately present on anything genuinely irreplaceable.