Infrastructure as Code — Terraform / Bicep Basics
Build a versioned Azure Terraform module on paper, understand state and dependency graphs, and rehearse plan review, drift, import, refactoring and recovery without cloud writes.
Original publication date unrecordedUpdated
Lesson preparation & details
Level: intermediate
By the end, you should be able to
- Trace providers, resources, inputs, outputs and state through a complete Azure module
- Review a saved plan and distinguish preview, apply and recovery guarantees
- Compare Terraform, Bicep, ARM, AWS CDK and Pulumi without conflating their state models
- Resolve address renames, drift, import and dependency cycles with worked examples
Bring with you
Sources checked: . A source check does not change the publication or update date.
Editorial review: · What review means
In this article · 27 sections
Review and execution boundary
Session 69's reference is pinned to Terraform 1.9.8 and AzureRM 3.100.0. It is a historical compatibility target, not a recommendation to deploy those versions without support/security review. The provider's tagged documentation is the API contract used here. Upgrading to AzureRM 4.x requires a separate migration review; do not copy its argument names into this 3.x example.
Full-body review renewed 10 October 2026, rechecking the saved 7–8 October primary evidence and preserving the existing metadata unchanged. That metadata is not evidence of first publication. No Terraform/provider execution, backend initialization, cloud planning, apply, destroy, IAM writes, new service or resource purchase occurred. Complete HCL is supplied for study; syntax/static checks and pure models are not a provider validation or live plan. Even init can download executable providers and plan can contact APIs; neither is an offline safety primitive.
The learning goal is to write and explain a module for an Azure resource group, storage account and Key Vault, including the private network dependencies the old “private endpoint” claim omitted, and to understand the plan/apply/state lifecycle. Reading a solved lifecycle is a legitimate learning outcome; it is not a claim to have provisioned those resources.
Why this session exists
Infrastructure code turns a proposed resource change into something reviewable and repeatable. It does not make every cloud operation reversible or turn a Git repository into a data backup. Emergency portal operations can be legitimate when controlled, logged and reconciled afterwards; undocumented drift is the problem, not clicking itself.
Learn providers/resources/state/modules, variables and outputs; compare tool boundaries; and diagnose state divergence, secret exposure and circular dependencies. The reference uses explicit inputs and no secret values. A caller can inspect the full resource graph before any execution is authorized.
Prerequisites
Start with Azure identity, storage and networking. Understand control-plane access versus data-plane permission, and why a private endpoint also requires DNS and a reachable network path. Git is for reviewed source and dependency lock files, not state, plan binaries or credentials. Docker's desired-state analogy can help, but its image cache is not Terraform state.
The official state, provider and module references at the end govern the claims; the optional original preparation trail below is not required viewing. Reading times are estimates, not promises of a working cloud deployment within an hour.
Original resource trail and reading purpose
The original preparation links are optional discovery pointers, not required viewing or evidence for the module. Video titles and creator labels below are historical finding labels: identity, contents, praise, playback, runtime and external availability were not verified in this review. The old “25 min total” did not include watching the listed long courses; choose a topic, not a promised completion time.
- “Terraform in 100 Seconds” — historical Fireship label: optional vocabulary orientation; the title is not a verified runtime or quality rating.
- “Terraform Course — Beginner to Pro” — historical freeCodeCamp label: optional providers, state, modules and workspaces topics. The original suggested modules 2–6 and skipping AWS-specific material; those chapter labels and suitability for the pinned Azure example are unverified.
- “Understanding and Using Project BICEP - The NEW Azure Deployment Technology” — historical John Savill label: optional Azure-native deployment comparison, not proof that Bicep is always thinner or better.
- Terraform introduction: introductory/use-case discovery pointer; this review relies on the specific lifecycle references below rather than accepting every overview claim.
- Bicep overview: primary reference for the Azure/ARM state boundary; the saved passage supports that boundary, not every current benefit claim.
- Terraform state: primary reference for identity bindings and their one-to-one rule. Reading it again when operating state is useful; no monthly schedule is required.
Use the existing intuition → visual → reference exercise → failure analysis → recall sequence as a study route. The original 90-minute session and numbered phase times are adjustable planning cues, not evidence of a timed cloud run, universal course completion, or an eternally maintained 130-session series. Stop at the reference boundary unless execution is independently authorized.
(a) Intuition · 5 min
The configuration is a blueprint, the cloud is the building, and state is the address book binding drawing labels to actual buildings. Changing a drawing does nothing until a tool or operator acts. Changing the building out of band can make the drawing and reality disagree. Returning code to an older commit does not restore deleted database rows.
A plan normally compares configuration, stored identities and refreshed remote attributes. An apply attempts the selected changes and records results. Provider/API failures can leave a partially changed system; this is not a transaction that rolls everything back automatically.
The four IaC primitives to internalise
- Provider: a plugin implementing resource/data-source behavior for an API. A default configuration may serve many resources; aliases can represent additional accounts/regions. It is not literally one provider block per resource or universal target.
- Resource: a managed instance described by type, local name and arguments. Its Terraform address differs from its remote resource ID. A data source reads information; it is not a managed resource creation.
- State: persisted bindings and attributes, plus metadata needed for management. The ordinary local state is
terraform.tfstate;.terraform/normally holds working-directory/plugin/backend metadata. A remote backend changes storage/coordination, not the need for identity mappings. - Module: all Terraform configuration files in a directory, called with inputs and returning outputs. A root directory is itself a module; the reference also supplies a real reusable child module, rather than calling a single script “a module.”
Variables are typed input contracts. Outputs expose selected values to a caller. Resource references form dependency edges; file names do not impose execution order.
The plan / apply loop that keeps you sane
Inspect additions, updates, removals and replacements. A replacement includes both create and destroy actions; replacement ordering does not make data loss harmless. Stop on an unexpected destroy, account/region, address change or data-access expansion. Review the actual saved plan and inputs, not merely a green PR or a count of five resources.
terraform apply without a saved plan creates a new plan. Applying a reviewed saved plan preserves its proposed actions, but stale state can invalidate it and cloud reality, policy, permissions or provider behavior can still cause failure. The plan is evidence of intent, not a guarantee of successful execution or proof of least privilege.
A quick history so the tools' shape makes sense
A few dated milestones explain the available approaches. AWS announced CloudFormation on 25 February 2011. Terraform was open-sourced in 2014; the OpenTofu project's manifesto records the 10 August 2023 switch from MPL to BUSL as its motivating license change (the project's account, not legal advice). AWS announced CDK general availability for TypeScript and Python on 11 July 2019; Java and C# were still preview in that announcement. Microsoft's 12 November 2020 Ignite recap describes Project Bicep's Azure-focused DSL; that is evidence of its 2020 introduction, not proof of every subsequent release's capabilities. These dates do not establish market share or claim these were the first infrastructure tools ever.
The useful evolution is from provider-native templates to multi-provider resource management and programming-language frontends. Bicep compiles to ARM templates and relies on Azure Resource Manager rather than a user-managed Terraform-style state file. AWS CDK synthesizes CloudFormation; it is not the same product as CDK for Terraform. Pulumi uses an engine and state/checkpoints even though its input is a general-purpose language.
A concrete lifecycle change matters more than an unsupported market-share timeline: HashiCorp deprecated CDKTF on 10 December 2025 and no longer supports or maintains it, according to its official notice. Do not recommend it for a new 2026 project simply because an old article said it was growing. OpenTofu is a separate Terraform-derived tool; verify its current licensing, backend/state and provider compatibility before migration rather than promising permanent drop-in equivalence. HCL is a declarative language with expressions, collections and functions, not merely “nicer JSON” or “not a real language.”
The plan / apply cycle
Figure text equivalent — labels and relationships
- Participant “Reviewer”.
- Participant “Terraform CLI”.
- Participant “Protected backend”.
- Participant “Provider API”.
- 1. solid: “Reviewer” to “Terraform CLI” — init with reviewed backend and lock file.
- 2. solid: “Terraform CLI” to “Protected backend” — initialize backend access.
- 3. solid: “Reviewer” to “Terraform CLI” — plan -out=reviewed.tfplan.
- 4. solid: “Terraform CLI” to “Protected backend” — acquire supported lock / read bindings.
- 5. solid: “Terraform CLI” to “Provider API” — refresh and read data.
- 6. dotted: “Provider API” to “Terraform CLI” — observed attributes.
- 7. dotted: “Terraform CLI” to “Reviewer” — proposed actions and protected saved plan.
- 8. solid: “Reviewer” to “Terraform CLI” — separately authorize exact saved plan.
- 9. solid: “Terraform CLI” to “Provider API” — execute dependency-ordered actions.
- 10. dotted: “Provider API” to “Terraform CLI” — successes or failures.
- 11. solid: “Terraform CLI” to “Protected backend” — persist resulting state / release lock.
This summarizes phases, not an exact network trace. Locks are not held across the whole human-review interval between separate plan/apply commands. Terraform reacquires the appropriate lock and checks state at execution. It can persist intermediate results; a provider failure is not evidence that nothing changed.
The IaC tool decision matrix
| Tool | Execution/state boundary | Useful fit and tradeoff |
|---|---|---|
| Terraform | Multi-provider graph plus state/backend and provider plugins | Teams needing a common workflow across Azure and other APIs; take on state security, locking, provider upgrades and module governance. |
| Bicep | Azure declarative language compiled to ARM JSON; Azure handles deployment state | Azure-focused teams wanting native resource/API integration; manage deployment scopes, API versions, what-if limitations and data separately. |
| ARM templates / CloudFormation | Provider-native deployment systems, not raw imperative API calls | Direct control over native templates, including generated ones; verbosity is a cost, not proof nobody authors them. |
| AWS CDK | General-purpose code synthesizes CloudFormation | AWS-focused teams comfortable with code/test/toolchain dependencies; synthesis is not an AWS deployment test. |
| Pulumi | General-purpose code plus Pulumi engine/provider state | Developer-heavy teams needing language reuse; runtime dependencies, secrets and backend governance still matter. |
| CDKTF | Terraform configuration generation | Historical code may need maintenance/migration; official deprecation makes it a poor default for a new project. |
Worked choice: an Azure-only team with native ARM policy expertise may choose Bicep; a team managing Azure plus external DNS/SaaS APIs may prefer Terraform. Tool uniformity does not eliminate service-specific semantics. Choose using required provider coverage, team competence, state operations, testing and support—not a universal “best tool” rule.
The files you'll write daily
versions.tf constrains CLI/provider versions; .terraform.lock.hcl records selected provider versions/checksums and should be reviewed and committed. A version constraint alone is not a content-integrity lock. main.tf, variables.tf and outputs.tf are conventions; Terraform merges configuration in the module directory without treating these names as mandatory stages. backend.tf declares state storage; *.tfvars supplies nonsecret values; child modules live in separate directories.
Pin remote modules independently: the provider lock file does not lock remote module versions. Local module content is part of the reviewed source commit. Provider constraints such as ~> 3.100 permit a wider range than exact = 3.100.0; understand the syntax rather than calling every constraint a pin.
State — the most misunderstood object
State is not a disposable cache. Losing it loses Terraform's authoritative mapping from addresses to remote objects. A normal plan with missing bindings may propose new resources despite existing cloud objects, producing duplicates or name conflicts. Reconstructing state requires a verified backup/version or deliberate imports with appropriate configuration; listing cloud resources alone does not establish the intended bindings.
Kubernetes also has persisted state—API objects, names, UIDs, ownership and observed status—not “no state.” Terraform manages heterogeneous APIs and records its own address-to-ID bindings; Kubernetes controls a more uniform object API. Imports and refactoring are deliberate ways of establishing/changing the binding, so creation is not the only moment it can become known.
A supported backend lock coordinates cooperating Terraform writers. Local backends can use operating-system locking; the problem for teams is separate local copies and coordination, not that local state has literally no locking. Remote backends vary. Azure Blob state uses native locking/consistency mechanisms; current S3 documentation supports opt-in lockfiles and marks DynamoDB locking deprecated. That newer S3 feature is not a claim about this lesson's pinned CLI. Never disable locking or force-unlock merely to make a pipeline green.
State and saved plans can contain ordinary sensitive values, even if no output prints them. Encryption at rest does not restrict an authorized backend reader. Restrict read/write access, protect backups/versions, audit access and keep artifacts out of logs and public repositories. sensitive = true redacts normal display, not persisted content. Current Terraform documentation describes ephemeral values from 1.10 and provider-supported write-only arguments from 1.11; neither feature exists in the 1.9.8 reference, and neither is a universal flag that sanitizes every state value.
State boundaries and environment isolation
A single state simplifies direct references and one graph, but increases lock contention and possible blast radius. Separate environment states make dev/staging/prod changes independently reviewable. Separate long-lived network/data from frequently changing applications when ownership and change frequency justify the extra coordination. There is no universal resource-count or two-minute threshold.
Separate state is not authorization isolation. A dev runner with production credentials can still affect production, and different states can accidentally manage the same remote object. Use separate backend keys plus correctly scoped identities, subscriptions/resource groups and pipeline approvals. Terraform CLI workspaces are alternate state instances, not security boundaries or independent credential configurations.
For cross-component references, prefer explicit published nonsecret outputs/contracts with an enforced orchestration order. terraform_remote_state access can require access to the entire state snapshot, not just the harmless output you wanted. A pipeline can enforce ordering; saying cross-state ordering can only be done manually is wrong. Split state deliberately, not one object per state.
(c) Hands-on · 25 min
This is a complete root and child module reference for study, not an instruction to run cloud commands. It creates a resource group, storage account, Key Vault and private networking if separately authorized and successfully applied. It creates no IAM assignments, secret values, compute, database or public endpoint. Private endpoints and DNS are real resource types and may incur charges; they were not created.
The backend is deliberately pre-existing and separately administered. Creating the state store inside the state it is meant to hold creates a bootstrap/recovery dependency. Its owner must supply a protected storage account/container, supported locking, retention/versioning, network access and a least-privileged identity. For Azure AD authentication, data-plane state access such as a suitably scoped Storage Blob Data Contributor grant is separate from subscription management permissions. Do not fetch account keys or grant yourself permissions as a tutorial shortcut.
Use this directory layout; each HCL fence identifies one complete file. The network/database stretch later is a design exercise, not missing files required by this program.
reference/
versions.tf
backend.tf
variables.tf
main.tf
outputs.tf
dev.tfvars
modules/
platform/
versions.tf
variables.tf
main.tf
outputs.tfversions.tf (root): provider registration is disabled so a plan/apply does not opportunistically register resource providers. An administrator must have enabled required providers through the organization's approved setup. Authentication is external; no tokens, passwords or subscription IDs are embedded.
terraform {
required_version = "= 1.9.8"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "= 3.100.0"
}
}
}
provider "azurerm" {
skip_provider_registration = true
features {
key_vault {
purge_soft_delete_on_destroy = false
recover_soft_deleted_key_vaults = true
}
}
}backend.tf (root): no credentials belong in this file. Backend configuration is initialized separately from provider configuration.
terraform {
backend "azurerm" {}
}variables.tf (root): names are proposed inputs, not guarantees that globally unique cloud names are available. The prefix is deliberately constrained so the storage name remains within its 3–24 lowercase alphanumeric limit.
variable "prefix" {
type = string
validation {
condition = can(regex("^[a-z][a-z0-9]{2,11}$", var.prefix))
error_message = "Use 3-12 lowercase alphanumeric characters starting with a letter."
}
}
variable "environment" {
type = string
validation {
condition = contains(["dev", "staging", "prod"], var.environment)
error_message = "Choose dev, staging or prod."
}
}
variable "location" {
type = string
default = "eastus"
}main.tf (root): a real module call passes typed inputs and establishes the child namespace in state addresses.
module "platform" {
source = "./modules/platform"
prefix = var.prefix
environment = var.environment
location = var.location
tags = {
purpose = "reference-only"
environment = var.environment
}
}outputs.tf (root): publish nonsecret identifiers, not connection strings or storage keys. Even these IDs may be sensitive organizational metadata and should not be published automatically.
output "resource_group_id" {
value = module.platform.resource_group_id
}
output "storage_account_name" {
value = module.platform.storage_account_name
}
output "key_vault_uri" {
value = module.platform.key_vault_uri
}dev.tfvars (root): illustrative names; substitute approved nonsecret names after checking availability and environment ownership. This file does not provision anything by itself.
prefix = "s069example"
environment = "dev"
location = "eastus"modules/platform/versions.tf: the child declares its provider requirement; it inherits the root's configured provider rather than secretly choosing an account.
terraform {
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "= 3.100.0"
}
}
}modules/platform/variables.tf: retain validation at the reusable module boundary, not only the example caller.
variable "prefix" {
type = string
validation {
condition = can(regex("^[a-z][a-z0-9]{2,11}$", var.prefix))
error_message = "Use 3-12 lowercase alphanumeric characters starting with a letter."
}
}
variable "environment" {
type = string
validation {
condition = contains(["dev", "staging", "prod"], var.environment)
error_message = "Choose dev, staging or prod."
}
}
variable "location" {
type = string
}
variable "tags" {
type = map(string)
}modules/platform/main.tf: the complete private-network resource graph. The explicit AzureRM 3.100.0 subnet argument is intentionally not the newer 4.x spelling. Disabling endpoint network policies is a conservative reference choice, not a claim that modern Azure cannot support NSGs/UDRs on private endpoints. This program adds no workload subnet/VM and no VPN; consumers still need an existing authorized route to this VNet.
data "azurerm_client_config" "current" {}
locals {
stem = "${var.prefix}-${var.environment}"
endpoints = {
blob = {
resource_id = azurerm_storage_account.data.id
zone = "privatelink.blob.core.windows.net"
}
vault = {
resource_id = azurerm_key_vault.main.id
zone = "privatelink.vaultcore.azure.net"
}
}
}
resource "azurerm_resource_group" "main" {
name = "rg-${local.stem}"
location = var.location
tags = var.tags
}
resource "azurerm_storage_account" "data" {
name = "sa${var.prefix}${var.environment}"
resource_group_name = azurerm_resource_group.main.name
location = azurerm_resource_group.main.location
account_tier = "Standard"
account_replication_type = "LRS"
account_kind = "StorageV2"
min_tls_version = "TLS1_2"
allow_nested_items_to_be_public = false
public_network_access_enabled = false
tags = var.tags
}
resource "azurerm_key_vault" "main" {
name = "kv-${local.stem}"
resource_group_name = azurerm_resource_group.main.name
location = azurerm_resource_group.main.location
tenant_id = data.azurerm_client_config.current.tenant_id
sku_name = "standard"
enable_rbac_authorization = true
public_network_access_enabled = false
purge_protection_enabled = true
soft_delete_retention_days = 7
tags = var.tags
}
resource "azurerm_virtual_network" "main" {
name = "vnet-${local.stem}"
resource_group_name = azurerm_resource_group.main.name
location = azurerm_resource_group.main.location
address_space = ["10.69.0.0/16"]
tags = var.tags
}
resource "azurerm_subnet" "endpoints" {
name = "private-endpoints"
resource_group_name = azurerm_resource_group.main.name
virtual_network_name = azurerm_virtual_network.main.name
address_prefixes = ["10.69.1.0/24"]
private_endpoint_network_policies_enabled = false
}
resource "azurerm_private_dns_zone" "service" {
for_each = local.endpoints
name = each.value.zone
resource_group_name = azurerm_resource_group.main.name
tags = var.tags
}
resource "azurerm_private_dns_zone_virtual_network_link" "service" {
for_each = local.endpoints
name = "${each.key}-link"
resource_group_name = azurerm_resource_group.main.name
private_dns_zone_name = azurerm_private_dns_zone.service[each.key].name
virtual_network_id = azurerm_virtual_network.main.id
registration_enabled = false
tags = var.tags
}
resource "azurerm_private_endpoint" "service" {
for_each = local.endpoints
name = "pe-${local.stem}-${each.key}"
resource_group_name = azurerm_resource_group.main.name
location = azurerm_resource_group.main.location
subnet_id = azurerm_subnet.endpoints.id
tags = var.tags
private_service_connection {
name = "${each.key}-connection"
private_connection_resource_id = each.value.resource_id
subresource_names = [each.key]
is_manual_connection = false
}
private_dns_zone_group {
name = "default"
private_dns_zone_ids = [azurerm_private_dns_zone.service[each.key].id]
}
}modules/platform/outputs.tf: outputs cross the child/root boundary explicitly.
output "resource_group_id" {
value = azurerm_resource_group.main.id
}
output "storage_account_name" {
value = azurerm_storage_account.data.name
}
output "key_vault_uri" {
value = azurerm_key_vault.main.vault_uri
}What each block does
The root provides one configured AzureRM provider. The child reads the existing caller's tenant ID; this does not grant the caller a role. Resource-group references establish region/name dependencies. Storage and vault IDs feed the endpoint map, while the map's keys blob and vault are known during planning; unknown future IDs as values do not make the for_each keys unknown.
The storage account blocks public network access and anonymous nested items, but the private endpoint supplies an actual data path. A blob endpoint covers the Blob service, not File/Queue/Table endpoints. The vault uses RBAC authorization and a vault endpoint; it grants nobody Secrets Officer. Private DNS zones and VNet links connect the normal service hostnames to private resolution for linked clients. A client outside that routing/DNS boundary does not gain access merely because the endpoint exists. Authorization is still required over the private path.
Storage keys can still be present in provider state: disabling public access and avoiding secret outputs does not sanitize state. The example does not manage containers/blobs or Key Vault secrets. Data-plane consumers need approved roles and networking separately. Public access disabled is not “zero exposure”: control-plane access, private callers and credential leakage remain threat surfaces.
There are 11 managed resource instances in the declared fresh graph: resource group, storage, vault, VNet and subnet (five), plus two DNS zones, two VNet links and two endpoints (six). The client-config data source is not an added resource. Endpoint NICs or cloud-internal objects are not separately declared Terraform resources here. Eleven is a static inventory expectation, not an observed plan; existing state, imports, errors and provider behavior determine the actual plan. The old script's five-resource count did not certify successful provisioning. Its random_string.suffix was intended to reduce naming collisions, not guarantee global uniqueness. This reference instead makes naming an explicit validated input; the operator still checks availability. No random-provider dependency is required by these files.
Plan, apply and lifecycle review
Before any authorized execution, establish who owns the subscription, resource providers, backend, identity, network ranges, names, costs and recovery decisions. Fixed 10.69.0.0/16 is a teaching range; overlapping routes across environments must be redesigned. Purge protection is deliberately enabled: it cannot simply be turned off after creation, and deletion/name reuse have retention consequences. This is a reason not to deploy the example casually.
A lifecycle transcript for an approved environment would be:
# Reference only: commands below were NOT executed.
# backend-approved.hcl contains only approved nonsecret backend settings.
terraform init -backend-config=backend-approved.hcl
terraform fmt -check -recursive
terraform validate
terraform plan -var-file=dev.tfvars -out=reviewed.tfplan
terraform show reviewed.tfplan
# Separate human/environment authorization of exact source, lockfile and plan:
terraform apply reviewed.tfplan
terraform state list
terraform plan -var-file=dev.tfvars -detailed-exitcodeFor completeness, this is the shape of backend-approved.hcl, a separately supplied input file for an existing approved backend. The names are illustrative placeholders, not newly provisioned resources or proof that access exists. Its environment key must agree with the selected variable file; never reuse a production key for the dev exercise. Credentials come from the supported external authentication flow.
resource_group_name = "APPROVED-STATE-RESOURCE-GROUP"
storage_account_name = "approvedstateaccount"
container_name = "approved-state-container"
key = "s069/dev.tfstate"
use_azuread_auth = trueInitialization resolves providers/backend and writes local metadata; preserve and review the generated lock file rather than fabricating checksums. A repeatability run can use -lockfile=readonly once that file exists. Never put access tokens or account keys in -backend-config: backend settings may be saved in local metadata and plan files.
validate checks internal configuration after initialization, not permissions, region capacity or live service correctness. plan normally reads remote objects and does not execute its proposed infrastructure changes, but providers/data sources are executable code; untrusted configuration is not made safe by running plan. -detailed-exitcode distinguishes no change (0), error (1) and proposed change (2); a CI wrapper must not treat 2 as a generic failure or 1 as an empty diff.
After a successful apply, review the state's expected addresses, not raw secrets. Check an approved second plan for unexpected changes; verify actual resource identities, endpoint approvals, DNS, allowed and denied access and application behavior separately. Tag-only input changes often update supported resources in place; read the plan rather than assuming every provider/API treats all tags identically.
Cleanup is a reviewed destroy plan for this exact environment, with data-retention approval and a backup/restore check. Do not use destroy -auto-approve or delete the backend resource group as an epilogue. Keep backend state/versions until managed resources are reconciled and records retained. Purge protection can intentionally retain a soft-deleted vault; deleting infrastructure is not proof that every recoverable object, billable dependency or secret copy disappeared. The original automatic bootstrap/cleanup script is replaced by this explicit lifecycle, not relabelled executed.
Partial apply is not transaction rollback
Suppose the resource group and storage account succeed, then vault creation fails. Their successful API calls are not undone merely because the overall apply returned an error. Preserve the resulting state and diagnostic record, determine which objects actually exist under the intended identity, and inspect a new plan after correcting the cause. Do not replay an old approval against a different proposed action set. A failure while saving state needs the documented state-recovery procedure before another writer proceeds; guessing that the backend has every completed result is unsafe.
The apply error contract explicitly says a partial apply is not automatically rolled back; the state-storage contract separately describes failed backend persistence and protected recovery. These passages were checked on 10 October 2026; newer action flags elsewhere on the mutable apply page are not part of this 1.9.8 reference.
This differs from a local database transaction's all-or-nothing contract. Terraform orders dependent operations, not one atomic transaction across Azure APIs, the backend and application data. A code rollback changes desired configuration; it does not restore deleted blobs or reverse external side effects. A lock coordinates writers to that state while held; it neither locks the whole cloud nor spans the human review interval. These are distinct recovery boundaries.
Recovery exercise without destructive cloud actions
Consider an existing binding from module.platform.azurerm_storage_account.data to a real storage-account ID. If only the local resource name becomes archive, configuration identity has changed even if the cloud name has not. A reviewed moved block in the child module records the refactor:
moved {
from = azurerm_storage_account.data
to = azurerm_storage_account.archive
}This is a refactor fragment, not an extra file to add to the unchanged reference: rename the resource block and all references together. The block should be retained for consumers upgrading from older module versions. Without the move, a plan can propose destroying the old address and creating the new one, possibly encountering a global-name conflict. A CLI state mv is another controlled mapping operation, but a moved block makes intent reviewable alongside configuration. Neither is a database backup or a safe way to rename a remote storage account's immutable name.
If an existing object is genuinely unmanaged, add the matching resource configuration and use an import block, for example:
import {
to = module.platform.azurerm_storage_account.data
id = "/subscriptions/APPROVED-SUBSCRIPTION/resourceGroups/APPROVED-RG/providers/Microsoft.Storage/storageAccounts/APPROVED-NAME"
}This is an adoption fragment with placeholders, not runnable real identifiers. Terraform 1.9 supports configuration-driven imports; inspect the plan before applying. Import binds an existing object; it does not create a deleted one. Generated configuration, where supported, still needs review for defaults, dependencies and unwanted changes. Bind one remote object to one Terraform address, not competing states.
For a removed tag, choose whether code or the authorized manual change is correct, update the desired configuration if necessary, then inspect a normal plan. A refresh-only plan/apply accepts observed state changes; it does not restore the previous infrastructure. If a vault or resource group was deleted, stop and evaluate provider/service recovery or backups; state rm followed by import cannot resurrect absent data. state rm forgets management, not deletes the object, and can make the next normal plan propose creation. It is not undo/redo.
Here is a safe local mapping model that deliberately has no cloud client, file writes or Terraform subprocess. It demonstrates address loss, explicit moves and one-to-one adoption—not actual provider diff semantics.
def mapping_actions(desired_addresses, bindings, moves=None):
current = dict(bindings)
for old, new in (moves or {}).items():
if old not in current or new in current:
raise ValueError("move requires existing source and unused destination")
current[new] = current.pop(old)
desired = set(desired_addresses)
return {
"create": sorted(desired - current.keys()),
"forget_or_destroy_review": sorted(current.keys() - desired),
"bound": sorted(desired & current.keys()),
}
def adopt(bindings, address, remote_id, existing_ids):
if address in bindings or remote_id in bindings.values():
raise ValueError("binding must be one-to-one")
if remote_id not in existing_ids:
raise ValueError("import cannot resurrect a missing object")
return {**bindings, address: remote_id}
old = "module.platform.azurerm_storage_account.data"
new = "module.platform.azurerm_storage_account.archive"
bindings = {old: "fixture-storage-id"}
assert mapping_actions([new], bindings)["create"] == [new]
assert mapping_actions([new], bindings, {old: new})["create"] == []
assert mapping_actions([new], bindings, {old: new})["bound"] == [new]
assert adopt({}, old, "fixture-storage-id", {"fixture-storage-id"}) == bindingsWorked negative cases: moving to an already bound address fails; importing an ID absent from existing_ids fails; importing the same ID twice fails. Losing bindings produces a create recommendation even when a separate inventory lists the remote object. The real Terraform plan also considers attribute changes and provider lifecycle rules that this model intentionally does not simulate.
(d) Production reality · 15 min
Concurrent change, hypothetical: one pipeline holds the backend lock and another waits/fails. Do not force-unlock while the first is still alive. Inspect the run owner, lock ID, backend and completion status. After a genuine abandoned lock, only an authorized owner should unlock under the backend's recovery procedure. Locking cannot stop a portal change or another state with the same object ID. Back up state before recovery and reconcile actual resources; restoring an older state blob does not reverse completed API calls.
Leaked state/plan, hypothetical: a public artifact exposes a database password despite sensitive. Restrict copies immediately, rotate/revoke affected credentials, audit access, then address history/backups under the incident process. A private Git repository is also usually the wrong distribution boundary for state. Encryption and .gitignore do not undo a leak. No secret was needed to run the pure model above.
Refactor replacement, hypothetical: renaming a module from db to database changes addresses. Review an explicit move, then require the plan to show intended address migration without unintended replacements. If an actual database has already been destroyed, code rollback and state mv cannot recover its data; restore procedures and their RPO/RTO determine the outcome. The old article's “Fortune-500/24-hour/four-hours-lost” anecdote had no source and is not presented as a verified incident.
Common footguns to internalise
Index identity: count uses numeric indices. Given names = ["alpha", "beta", "gamma"], removing alpha makes index 0 mean beta and index 1 mean gamma; index 2 disappears. Whether retained indices update or replace depends on changed arguments and the provider schema. “Every element always mass-destroys” is false. for_each with stable keys keeps beta/gamma identities while alpha is removed. Changing a key or an immutable field still matters; keys must be known and cannot be sensitive identifiers.
Dependency cycles: if network creation needs a database endpoint and database creation needs a subnet produced by that same network module, no valid topological order exists. Move independently creatable VNet/subnet resources to a base module; pass its subnet ID to the database; create database-dependent DNS/rules afterwards. A data source reading the not-yet-created object does not magically break the cycle. Add depends_on only for real hidden dependencies; ordinary output references already create the needed edges, and a whole-module dependency can overconstrain planning.
Wrong environment: inspect backend key, subscription/tenant, provider aliases, state addresses, variable file, approval context and workspace. workspace show alone is not sufficient. Two independent states with the same production credentials are not two isolated environments.
Unsupported assurance: a no-change plan says configuration and the provider's observed model agree at that moment. It does not prove application health, every data object, recovery readiness, security or absence of drift in unmanaged fields.
Where this shows up in the rest of the plan
Terraform can provision host-cloud services; Kubernetes/Helm reconcile workloads on top. Observability resources, policy assignments and delivery pipelines each have their own APIs and access requirements. Multi-region deployments use parameterized modules and deliberate state/identity/routing boundaries—not necessarily one CLI workspace per region. See Kubernetes configuration and recovery for the controller boundary; use the broader plan for delivery and observability rather than obsolete session numbers.
(e) Recall + stretch · 10 min
1. What does plan do, and is it read-only?
It normally refreshes managed objects, compares configuration/state and proposes changes without carrying out those proposed resource actions. It can access a backend, execute provider/data-source code, contact services and write local plan artifacts. Therefore it is not a safe sandbox for untrusted Terraform.
2. Why shared protected state, and what does locking not solve?
The team needs one authoritative address map, coordinated writers and recoverable versions. Use a backend with supported locking and restricted access. Local state can use OS locks but separate laptops can still diverge. Locks do not coordinate console edits or independent states managing the same object.
3. What happens on a resource rename?
An address change without a move can look like delete-plus-create. Use a reviewed moved block or carefully controlled state mv; inspect the resulting plan. A cloud object's immutable name change is a different operation. Neither operation restores lost data.
4. count or for_each?
Use stable natural keys when identity follows names rather than positions. Removing alpha from a count-indexed list reassigns later positions; replacement depends on provider attributes. Removing key alpha from a for_each map leaves other keys stable, absent other input changes.
5. Terraform or Bicep?
Compare API coverage, operations and team skills. Bicep is Azure-native and has no user-managed Terraform-style state; Terraform unifies multiple providers but requires state/backend governance. Bicep still needs deployment review and data recovery; neither is universally safer.
6. What does import recover?
It binds an existing remote object to a managed address. It cannot recreate a deleted object or recover data. Appropriate desired configuration and a reviewed plan are still required; configuration generation is not automatic approval.
7. Why is state in Git dangerous even with sensitive outputs?
Sensitive marks normal display; state and plan may persist the value. Repository readers, history and artifacts widen exposure. Restrict state/plan access, encrypt storage/backups, audit reads and rotate leaked credentials. Newer ephemeral/write-only features require separate version/provider support.
8. Wire network output to database input without a cycle.
The network module exposes output "subnet_id" { value = azurerm_subnet.db.id }. The root passes subnet_id = module.network.subnet_id to the database module, whose input feeds its resource configuration. That reference creates the relevant dependency edge; it does not necessarily serialize every unrelated resource in both modules. Put database-dependent DNS/rules in a later layer.
9. What makes production apply reviewable?
A trusted pinned toolchain, source/lockfile/input identity, protected backend and saved plan, least-privileged execution identity, serialized runs, environment authorization, audit trail and verified recovery plan. Do not blindly apply whatever plan a PR built or assume merging to main is approval for every destructive action. Controlled break-glass operations require audit and reconciliation too.
10. Stretch: dev, staging and prod with shared network/database modules.
Use modules/network and modules/database once in source, and separate envs/dev, envs/staging, envs/prod root directories. Each root supplies its own backend key, identity/account scope, nonoverlapping network ranges and approved variables. Pass each network's subnet output into that environment's database module. Dev may use a smaller service tier; prod may require HA and longer retention, but exact SKUs/replica features are service-specific and must be verified rather than invented as generic Basic/Standard/Premium values. Promote reviewed module versions through environments, testing migrations and recovery at each stage. Pipeline approvals and identity scope—not directory names—prevent dev from changing prod.
Explain-out-loud test
Explain where resources, inputs and outputs conventionally live, and why Terraform actually loads the module directory rather than demanding three special filenames. Draw configuration address → remote ID → observed attributes. Then explain moved-block refactoring versus import versus restoring deleted data. A correct answer never calls state rm/import undo/redo.
Corrected contracts and failure analysis
State is an identity database; a plan is a bounded proposal; apply is not a distributed transaction; import is not resurrection; separate state is not separate permission. Private access disabled is not a private endpoint, and a private endpoint is not a role grant. The complete reference makes those distinctions visible rather than hiding them behind a successful command screenshot.
Boundary exercise with solution
A database password is marked sensitive and the plan artifact is uploaded publicly. Has redaction protected it? No. The persisted artifact may contain the value. Restrict it, rotate/revoke the exposed credential, audit access and remediate copies. Do not claim a secret-free plan just because the console displayed “sensitive value.”
What comes next
Continue with S070 · CAP and PACELC, which separates distributed guarantees from deployment tooling. Return to the 6-month plan for delivery and distributed-systems design. This lesson supplies conceptual/module coverage and offline evidence; an actual cloud lifecycle requires separate authorization, supported versions, cost review, identity/network readiness and independently recorded execution results.
Source-backed review notes
Exact primary passages, retrieval dates, raw hashes and the limits of the tagged provider documentation are retained in batch-4 evidence.
- Terraform plan, locking, AzureRM backend, S3 backend and workspaces: preview, coordination and environment boundaries.
- Sensitive values, dependency lock file, refactoring and import: persisted values, version resolution and identity changes.
- count, for_each and depends_on: keys and dependency semantics.
- AzureRM v3.100.0 source/docs: storage, Key Vault, subnet, private endpoint and DNS-link arguments. Old provider notes are not blanket statements about current Azure platform capabilities.
- Dated primary accounts: CloudFormation launch, CDK GA, Bicep 2020 recap and OpenTofu manifesto. The manifesto is the fork project's position, not independent legal advice.
- Bicep, AWS CDK v2, Pulumi state and CDKTF deprecation: distinct engines and current support boundaries, reviewed 8 October 2026.
Pause / Recall / Apply
Can you explain it without the page?
Close the example. Reconstruct the core idea, then change one assumption. Mark complete when you’re ready; you can always undo it.
Stored in this browser only. No account, no sync. Clearing browser data removes your record.