Skip to content

Production Checklist

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 azurerm backend 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 running apply at 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 time init runs, changing behavior nobody asked for in the middle of an unrelated change.
  • plan reviewed in CI before every apply, 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 = true only 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.
  • tflint and a security scanner, and ideally policy as code, wired into CI. Static analysis catches known-bad HCL in seconds before plan runs 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 dependency blocks 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 test and .tftest.hcl coverage for any module doing something non-trivial. validate checks syntax and plan checks that Azure accepts the request, but neither one asserts that a module’s actual behavior is correct — that is what terraform test is for.
  • lifecycle.prevent_destroy on 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, successful apply.
  • precondition blocks 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 into apply.
  • A rehearsed force-unlock runbook, blob-lease locking understood, and a real state-backup habit. Proven below, including a genuine surprise about what force-unlock can 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):

main.tf
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 the
plan calls for this resource to be destroyed. To avoid this error and
continue with the plan, either disable lifecycle.prevent_destroy or reduce
the 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-65d99f8b0afb
Failed to unlock state: LocalState not locked
$ terraform force-unlock -force 00000000-0000-0000-0000-000000000000
Failed to unlock state: LocalState not locked

Even 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 offline
remote_state {
backend = "local"
config = {
path = "${get_parent_terragrunt_dir()}/${path_relative_to_include()}/terraform.tfstate"
}
}
generate "provider" {
path = "provider.tf"
if_exists = "overwrite"
contents = <<EOF
provider "azurerm" {
features {}
subscription_id = "00000000-0000-0000-0000-000000000000"
}
EOF
}
generate "backend" {
path = "backend.tf"
if_exists = "overwrite"
contents = <<EOF
terraform {
backend "local" {}
}
EOF
}
vnet/terragrunt.hcl
include "root" {
path = find_in_parent_folders("root.hcl")
}
terraform {
source = "../modules/vnet"
}
modules/vnet/main.tf
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
}
sql/terragrunt.hcl
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"
modules/sql/main.tf
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 not
configure AzureCli Authorizer: could not parse Azure CLI version: launching
Azure 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.

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
uses: bridgecrewio/[email protected]
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-interactive

Nothing 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
root.hcl plus dependency-ordered units, all deployed through a CI pipeline that authenticates via OIDC and gates apply behind a reviewed plan

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.

ChoiceBenefitCost
lifecycle.prevent_destroy on critical resourcesProven above: a destroy or forced replacement is refused outrightMust be deliberately removed (a reviewable diff) before a genuinely intended destroy can proceed
precondition blocksProven above: a bad input fails at plan time naming the actual problemOne 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 tableNo separate audit-trail resource the way a dedicated lock table might have its own access logs
A deliberate state pull backup before a risky operationAn operator-controlled snapshot independent of the automatic .tfstate.backupOne 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 planGenuinely provable offline for azurerm, since plan itself is notDoes not exercise the actual plan computation — only the wiring, not what a real plan would show
  • “prevent_destroy stops 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: the azurerm backend locks by taking a blob lease directly on the state blob, no separate resource to provision.
  • “force-unlock is the correct fix for any stuck Terraform run.” Proven above and in remote-backends-and-locking.mdx: against local state, force-unlock cannot release a lock a different process holds — terraform force-unlock --help says 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@master is fine as long as the action is trustworthy.” Proven in policy-as-code-and-static-analysis.mdx: trustworthiness today says nothing about what lands on master tomorrow — pinned above to the real current tag (v12.1347.0) instead.

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_destroy at 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 for local state specifically.
  • uses: bridgecrewio/checkov-action@master (or any action pinned to a branch name) instead of a real release tag — proven above and in policy-as-code-and-static-analysis.mdx, a real reproducibility and supply-chain risk.
  • A dependency block referencing an upstream unit’s outputs with no mock_outputs/mock_outputs_allowed_terraform_commands — proven above, terragrunt run --all plan needs those to plan a downstream unit before the upstream one has ever actually been applied.
  • A module source pinned 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_password
administrator_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.

Why does a production Terraform setup need a remote azurerm backend with locking from day one, rather than local state
Why does a reviewed plan in CI specifically need to watch for unexpected resource replacements
What does a Terragrunt dependency block give you that manually applying units in the right order does not
Why should sensitive values be kept out of Terraform variables where possible, even with a remote backend already in place
What does this real, proven error demonstrate about lifecycle.prevent_destroy?
$ terraform destroy -input=false -auto-approve
...
Error: Instance cannot be destroyed

Resource local_file.critical has lifecycle.prevent_destroy set, but the
plan calls for this resource to be destroyed.
A blob lease is genuinely held (proven with a real .terraform.tfstate.lock.info) and force-unlock is run with the CORRECT lock ID against local state. What happens, and why does that not contradict what force-unlock does against a real blob lease?