Terraform Lesson 10 of 89

Terraform Remote State at Scale: Backends, Locking, Splitting, and State Surgery

In a nutshell

Think of Terraform state as the ledger for your infrastructure: one authoritative book that records what exists, what its cloud ID is, and what settings it has. On a solo laptop that ledger is a notebook in your drawer — fine until a second person needs to write in it. Remote state at scale is about moving that ledger into a shared vault the whole team plus every CI robot can read and write safely: only one pen touches the page at a time (a lock), every version is photocopied before it changes (versioning), the vault is bolted shut (encryption + RBAC), and the single fat ledger is split into thin per-team volumes so a spill on page 900 doesn’t smear the whole book (isolation / blast radius).

Get this right and Terraform scales from one engineer to fifty without the 3 a.m. incident where two applies raced and half your resources silently vanished from tracking. Get it wrong and the ledger is a shared document with no locking — the fastest way ever invented to corrupt state. The mental model to hold the whole lesson: a shared, locked ledger that many people write to, one at a time, in small books.

Level: Advanced · Time: ~30 min

Prerequisites. You should already understand what state is and why Terraform keeps it — the terraform.tfstate file, its serial/lineage, and terraform state list/state show. If any of that is fuzzy, read Terraform state deep dive first, and Backends deep dive for how a backend is wired at init. Comfort with one cloud’s CLI (Azure, AWS, or GCP) and with the plan/apply loop is assumed.

After this lesson you can:

Remote state at scale: backends, locking, isolation

The diagram reads left → right: concurrent writers (CI and engineers) queue behind a single state lock, which serializes them onto one durable, encrypted, versioned backend; at scale that backend holds not one god-state but many small per-<stack>/<env>/<region> objects, and downstream stacks read the producer’s outputs as a public API — each numbered badge marks where a scaled setup typically breaks.

State is the part of Terraform that turns a clean codebase into a 3 a.m. incident. A monolithic terraform.tfstate in someone’s home directory works fine for one engineer and falls apart the moment a second person, a CI runner, and a main branch all want to apply at once. This is a practitioner’s guide to running remote state for real teams: backends with locking, per-environment keys without copy-paste, splitting a god-state into seams, sharing data across stacks, and the surgery you’ll eventually need when state and reality disagree.

1. Why local state breaks teams

Local state fails in three specific, predictable ways, and naming them tells you exactly what a remote backend has to solve.

The fix is not “put the file on a share.” It’s a backend that provides remote storage, locking, encryption, and versioning as one unit. Locking is the non-negotiable part: a remote backend without locking is just a more convenient way to corrupt state.

2. Configuring a remote backend with locking

Pick the backend that matches your cloud, but the requirements are identical: durable storage, a locking mechanism, server-side encryption, and object versioning. Here are the three that cover almost everyone.

Azure Storage (azurerm). Locking uses native blob leases, so there’s no extra table to provision. Create the storage account and container out-of-band (with CLI or a bootstrap stack), then point the backend at it.

az group create -n rg-tfstate -l eastus2

az storage account create \
  -n sttfstateprod001 -g rg-tfstate -l eastus2 \
  --sku Standard_ZRS \
  --min-tls-version TLS1_2 \
  --allow-blob-public-access false

az storage container create \
  --account-name sttfstateprod001 -n tfstate \
  --auth-mode login

# Enable blob versioning and soft delete for break-glass recovery
az storage account blob-service-properties update \
  --account-name sttfstateprod001 -g rg-tfstate \
  --enable-versioning true \
  --enable-delete-retention true --delete-retention-days 30
# backend.tf
terraform {
  backend "azurerm" {
    resource_group_name  = "rg-tfstate"
    storage_account_name = "sttfstateprod001"
    container_name       = "tfstate"
    key                  = "platform/hub-network.tfstate"
    use_azuread_auth     = true   # auth via Entra ID / OIDC, not account keys
  }
}

Prefer use_azuread_auth = true over storage account keys so access is governed by RBAC and your CI’s workload identity rather than a long-lived shared secret. The blob lease gives you locking for free.

AWS S3. Modern Terraform (1.10+) supports native S3 locking via the use_lockfile argument, which writes a .tflock object alongside the state. The older pattern used a DynamoDB table for the lock, and you’ll still see it in the wild.

# backend.tf  (S3 with native lockfile, Terraform >= 1.10)
terraform {
  backend "s3" {
    bucket       = "kloudvin-tfstate-prod"
    key          = "platform/hub-network.tfstate"
    region       = "us-east-1"
    encrypt      = true
    use_lockfile = true   # native S3 locking; no DynamoDB table required
  }
}

If you’re on an older version or already run the DynamoDB table, keep using it: provision a table with a primary key named exactly LockID (string) and reference it with dynamodb_table. Enable bucket versioning and a bucket policy that enforces encryption regardless of which path you choose.

HCP Terraform / Terraform Cloud. State, locking, encryption, and versioning are managed for you; you configure a cloud block instead of a backend block.

terraform {
  cloud {
    organization = "kloudvin"
    workspaces {
      name = "platform-hub-network-prod"
    }
  }
}
Backend Locking mechanism Versioning Notes
azurerm Native blob lease Blob versioning + soft delete No extra lock resource to manage
s3 use_lockfile (1.10+) or DynamoDB S3 bucket versioning Enforce SSE via bucket policy
HCP/TFC cloud Managed Managed (state history UI) No backend infra to run

3. Partial backend config and per-environment keys

You cannot use variables or interpolation inside a backend block; it’s read too early in Terraform’s lifecycle. The mechanism that lets you avoid duplicating a root module per environment is partial configuration: declare the backend type and the static parts in code, and supply the environment-specific values at init time.

# backend.tf  -- partial: type only, no environment specifics
terraform {
  backend "azurerm" {
    use_azuread_auth = true
  }
}
# envs/prod.azurerm.tfbackend
resource_group_name  = "rg-tfstate"
storage_account_name = "sttfstateprod001"
container_name       = "tfstate"
key                  = "platform/hub-network.tfstate"
terraform init -backend-config=envs/prod.azurerm.tfbackend

The same root module initializes against dev, staging, or prod purely by swapping the -backend-config file, with no code duplication. The discipline that makes this safe is a consistent key convention so two environments can never collide on one state object. A scheme like <stack>/<environment>/<region>.tfstate (for example platform/prod/eastus2.tfstate) is self-documenting and sortable in the storage browser.

Workspaces (terraform workspace) are a different tool. They store multiple states under one backend key with a env:/ prefix and are fine for short-lived or ephemeral variants. For long-lived production environments, distinct backend keys (and ideally distinct storage accounts/buckets, even distinct subscriptions) give you stronger blast-radius and RBAC isolation than workspaces do. Don’t use a single workspace-switched state to separate dev from prod.

4. Splitting a monolithic state file

A god-state is the inevitable end of a successful repo: networking, data, identity, and a dozen apps all in one file, where every plan takes minutes and every apply is terrifying. Split it.

Find the seams. Cut along lifecycle and ownership, not resource type. Things that are created, destroyed, and changed together stay together; things with independent change cadence and different owning teams become separate states. The classic decomposition is foundational and slow-moving at the bottom, fast-moving at the top: networking/DNS, then shared data (databases, key vaults), then per-application stacks. A good seam is one where the upper layer only needs a handful of IDs from the lower layer (covered in section 5).

Move resources without destroying them. Within a single state, terraform state mv renames addresses. To relocate resources into a different state file, point state mv at the destination state explicitly. Always pull a backup first.

# Back up both states before any surgery
terraform state pull > backup-source-$(date +%Y%m%d%H%M%S).tfstate

# Move a resource to a DIFFERENT state file (-state-out writes the destination)
terraform state mv \
  -state-out=../network-stack/terraform.tfstate \
  azurerm_virtual_network.hub \
  azurerm_virtual_network.hub

The cross-state state mv workflow is fiddly with remote backends because it operates on local files; the reliable pattern is to state pull both states to local files, move between them, and state push the results back, applying with the new code in each repo afterward.

A cleaner, declarative alternative for removing a resource from one state without destroying the real infrastructure (so you can import it into another) is the removed block, paired with an import block on the receiving side:

# In the SOURCE stack: drop it from state, keep the real resource
removed {
  from = azurerm_virtual_network.hub
  lifecycle {
    destroy = false   # forget it, do NOT destroy it
  }
}
# In the DESTINATION stack: adopt the existing resource into state
import {
  to = azurerm_virtual_network.hub
  id = "/subscriptions/<sub>/resourceGroups/rg-hub/providers/Microsoft.Network/virtualNetworks/vnet-hub"
}

resource "azurerm_virtual_network" "hub" {
  # configuration matching the live resource
}

This pair is safer than raw state mv across files because each side is plan-reviewable in its own PR. Run terraform plan on both stacks and confirm the source shows a forget (not a destroy) and the destination shows an import with no changes.

5. Cross-stack data sharing

Once state is split, the upper layer needs values from the lower layer. There are three ways to wire stacks together, in increasing order of decoupling.

1. terraform_remote_state data source. Read another stack’s outputs directly from its backend. It’s built in and zero-infrastructure, but it couples the consumer to the producer’s backend location and exposes every output (so never put secrets in remote-state outputs).

data "terraform_remote_state" "network" {
  backend = "azurerm"
  config = {
    resource_group_name  = "rg-tfstate"
    storage_account_name = "sttfstateprod001"
    container_name       = "tfstate"
    key                  = "platform/prod/network.tfstate"
    use_azuread_auth     = true
  }
}

resource "azurerm_subnet_network_security_group_association" "app" {
  subnet_id                 = data.terraform_remote_state.network.outputs.app_subnet_id
  network_security_group_id = azurerm_network_security_group.app.id
}

2. Provider data lookups. Skip Terraform state entirely and query the cloud API for the resource by name or tag. This fully decouples the stacks (the producer could even be ClickOps or a different tool), at the cost of a hard dependency on a stable naming/tagging convention.

data "azurerm_virtual_network" "hub" {
  name                = "vnet-hub"
  resource_group_name = "rg-hub"
}
# use data.azurerm_virtual_network.hub.id downstream

3. Published outputs via a registry/parameter store. Have the producer write its contract to a neutral store (Azure App Configuration, AWS SSM Parameter Store) and have consumers read from there. This is the most decoupled and the most operational overhead; it’s worth it at platform scale where you don’t want dozens of consumers reaching into your state file.

Approach Coupling Secrets-safe Best for
terraform_remote_state Tied to producer’s backend + outputs No (outputs are readable) Tightly related stacks in one org
Provider data lookup Tied to naming/tags only Yes (reads live API) Cross-team or mixed-tooling
Parameter store Tied to a published contract Yes (with RBAC on the store) Platform-scale, many consumers

Whatever you choose, treat a producer stack’s outputs as a public API, exactly like a module interface: removing or renaming an output is a breaking change for every downstream stack.

6. State surgery toolkit

Eventually state and reality diverge: a resource was created out-of-band, a provider was renamed, or a botched apply left orphans. These are the four operations that fix it. Back up state before every one of them.

terraform state pull > pre-surgery-$(date +%Y%m%d%H%M%S).tfstate

import — adopt existing infrastructure. Prefer the declarative import block (Terraform 1.5+) over the legacy terraform import CLI: it’s plan-reviewable and lives in code.

import {
  to = azurerm_resource_group.app
  id = "/subscriptions/<sub>/resourceGroups/rg-app"
}
terraform plan    # confirm "1 to import, 0 to change" before applying
terraform apply

state rm — stop tracking without destroying. Removes a resource from state while leaving the real thing alone. Use it to hand a resource off to another stack or to drop a stale entry.

terraform state rm azurerm_storage_account.legacy

state replace-provider — re-home a provider. When a provider’s source address changes (the canonical example is the HashiCorp-to-OpenTofu split, or a registry namespace move), rewrite every resource’s provider reference in state in one shot.

terraform state replace-provider \
  registry.terraform.io/-/azurerm \
  registry.terraform.io/hashicorp/azurerm

Recovering from corruption. If state is truncated or unparseable, do not run apply. Restore from a version: backends with versioning keep prior copies. On Azure, list and promote a previous blob version; on S3, restore a previous object version. Then verify with a no-op plan before touching anything.

# Azure: find recent versions of the state blob
az storage blob list \
  --account-name sttfstateprod001 -c tfstate \
  --prefix "platform/prod/network.tfstate" \
  --include v --auth-mode login -o table

If your lock is stuck (a CI job was killed mid-apply), clear it deliberately with the lock ID from the error message, never blindly:

terraform force-unlock <LOCK_ID>

force-unlock removes the lock without verifying the holder is actually gone. Confirm no apply is in flight first. Forcing a lock while a real apply is running is exactly how you create the corruption you’re trying to recover from.

7. Protecting state

State is a high-value, sensitive asset; treat the backend like a secrets store, because it is one.

8. Operational guardrails

The last mile is policy that keeps a team from hurting itself.

Going deeper

The sections above are the working playbook. This section is the layer underneath it — how the pieces actually behave, where they bite at scale, and the version caveats that separate a setup that survives an incident from one that becomes the incident.

Backend types, compared for real

The section-2 table covers the three most common backends; here is the fuller field guide including GCS, the DynamoDB-vs-lockfile split, and — the part that usually decides the choice — the auth model and operational cost.

Backend Locking Auth (preferred) History / restore Extra infra Best for
azurerm Blob lease (automatic) Entra ID / OIDC (use_azuread_auth) Blob versioning + soft delete None Azure shops
s3 + use_lockfile (1.10+) .tflock object via conditional write IAM role / OIDC Bucket versioning None — drop DynamoDB New AWS setups
s3 + DynamoDB (legacy) Conditional PutItem on LockID IAM role / OIDC Bucket versioning A DynamoDB table Existing AWS setups
gcs Native lock object (automatic) ADC / workload identity federation Object versioning None GCP shops
HCP Terraform / TFC (cloud) Managed, server-side terraform login / team tokens State history UI + one-click rollback None (SaaS) Teams wanting managed runs + RBAC
local None n/a Your “undo” is git, badly None One person, throwaway only

The GCS backend deserves a mention because it is the quietest of the three clouds: locking is native (it writes a lock object; no separate table like DynamoDB), and the state object is <prefix>/default.tfstate inside a versioned bucket.

# backend.tf  (Google Cloud Storage; native locking, no extra table)
terraform {
  backend "gcs" {
    bucket = "kloudvin-tfstate-prod"   # versioned; uniform bucket-level access
    prefix = "platform/prod/network"    # object → <prefix>/default.tfstate
  }
}

The decision is rarely about features — all four cloud paths give you locking, encryption, and versioning. It’s about auth and blast radius: pick the backend whose native identity model (Entra ID, IAM roles, workload identity federation) lets you grant CI write access without a long-lived key, and whose account/project boundary you can use to isolate prod from everything else.

How locking actually works — and why force-unlock is dangerous

“Locking” is not one mechanism; each backend implements it differently, and knowing which one you have tells you exactly what a stuck lock means.

In every case the lock is advisory and stateful: it’s a thing that exists (a lease, an object, a row) that a well-behaved Terraform checks before writing. force-unlock simply deletes that thing — the lease, the .tflock, the DynamoDB row — without checking whether the process that created it is still alive. That is the entire danger: if you force-unlock while a real apply is mid-flight, you have just authorized a second concurrent writer, which is precisely the race that corrupts state. The safe order is always: (1) confirm from CI logs / process state that the holder is genuinely dead, (2) then force-unlock <LOCK_ID> with the ID from the error, (3) plan to confirm no damage. Wrap -lock-timeout around routine runs so benign contention waits instead of failing:

terraform apply -lock-timeout=300s   # wait up to 5 min for the lock, then proceed

Partial backend config: the mechanics

Why can’t you interpolate inside a backend block? Because Terraform must fetch state before it evaluates your configuration — variables, locals, and data sources don’t exist yet at the moment it needs to know where state lives. So the backend block is parsed in an early, pre-variable phase, and anything dynamic in it is an error by design. Partial configuration is the escape hatch: put only the static, non-secret parts in the block and feed the rest at init.

-backend-config accepts either a file or an inline key=value, and they merge, with later values winning:

# File form (recommended): one .tfbackend per environment
terraform init -backend-config=envs/prod.azurerm.tfbackend

# Inline form (handy for the single dynamic value in CI)
terraform init \
  -backend-config=envs/prod.azurerm.tfbackend \
  -backend-config="key=platform/prod/eastus2.tfstate"   # overrides the file's key

Terraform records the resolved backend in .terraform/ after init. Two flags govern changing it later, and confusing them is a classic foot-gun:

Reach for -reconfigure when you only changed how you point at the same state; reach for -migrate-state when the state itself is relocating.

Migrating between backends

Backend migration is routine — local→remote when a project grows, DynamoDB→use_lockfile to shed the table, one bucket/subscription to another during a re-org. The interactive path handles most of it:

# 1. Edit backend.tf to the NEW backend, then:
terraform init -migrate-state    # prompts: "copy existing state to the new backend?" → yes
terraform plan                    # MUST report: No changes

For cross-account or cross-cloud moves where an in-place copy can’t reach both sides, do it by hand and verify at every step:

terraform state pull > migrate.tfstate     # from the OLD backend
# ...edit backend.tf to the NEW backend...
terraform init -reconfigure                # wire up the new (empty) backend, no copy
terraform state push migrate.tfstate       # push the pulled state into it
terraform plan                              # MUST report: No changes

The one rule that makes migration safe: lock everyone else out first, back up, and treat any plan result other than “No changes” as a failed migration — do not apply your way out of it.

Workspace vs directory isolation — the internals

terraform workspace and a directory-per-environment look interchangeable until you see what each does to the backend. Workspaces keep multiple states under one backend, distinguished by a path derived from your key — for the s3 backend, non-default workspaces live under an env:/<workspace>/ prefix; other backends weave the workspace name into the object path similarly. Crucially, all workspaces share one bucket/container, one bucket policy, one set of credentials, one RBAC boundary. Anyone who can write the dev workspace can reach the prod workspace’s state object.

That is fine for same-trust, ephemeral variants — a per-pull-request environment, a region fan-out of identical infra — where you want one owner and one credential. It is wrong for dev vs prod, where the entire point is that a dev mistake (or a dev credential leak) must not be able to touch prod. For that, use distinct backend keys and, ideally, distinct accounts/subscriptions so the blast-radius and RBAC boundary is real, not just a path prefix.

One more workspace gotcha: interpolating terraform.workspace into resource names or counts couples your configuration to which workspace is selected, and a wrong terraform workspace select silently plans against the wrong environment. Distinct directories make the target explicit in the path you cd into.

State splitting and blast-radius math

The case for splitting isn’t aesthetic; it’s operational cost you can estimate. A single plan reads and refreshes the entire state and holds the lock the whole time. A 2,000-resource god-state means a plan that takes minutes, refreshes every provider, and blocks every other team on one lock. Split that into networking / data / per-app stacks and each plan drops from O(all resources) to O(one stack’s resources) — faster, safer, and independently lockable so teams stop queueing behind each other.

The invariant that keeps splits sane is dependency direction: lower layers (networking, DNS, shared data) must never depend on upper layers (apps). Data flows up — apps read a handful of IDs from the network stack, never the reverse. If you find an app output feeding back into the network stack, that’s not a seam, it’s a cycle, and it will bite you at destroy time.

terraform_remote_state vs decoupled sharing — the coupling ledger

Section 5 ranks the three sharing mechanisms; here is the nuance that decides between them at scale. terraform_remote_state reads the producer’s entire state object — which means (a) the consumer needs read access to that state, not just to a value, and (b) it can see every output, so one carelessly-exported secret is now readable by every consumer. It also executes at plan time and creates an ordering dependency: if the producer and consumer run in different pipelines, a consumer that plans immediately after the producer applies may read a stale output. Order your pipelines (producer applies, then consumer), or decouple.

Provider data lookups and a parameter-store contract both trade a little friction for that decoupling: a lookup binds you only to a naming/tagging convention (and reads the live API, so no stale-output risk and no secret exposure), while a published contract in SSM/App Configuration binds you only to the published keys and can be RBAC-scoped per value. The mental model that keeps it all straight: a producer’s outputs are a versioned public API. Adding one is safe; renaming or removing one is a breaking change for every downstream stack, and you should treat it with the same care as breaking an HTTP endpoint.

Encryption at rest and access control — beyond the defaults

Server-side encryption is on by default, which is necessary but not sufficient, because the platform holds the key. For real key custody — the ability to rotate, audit, and revoke — use customer-managed keys (CMK): Azure Key Vault keys for the storage account, an SSE-KMS key for the S3 bucket, CMEK for the GCS bucket. Revoking the key instantly renders the state unreadable, which is exactly what you want during a credential-compromise response.

The one thing SSE does not do is keep plaintext out of the stored object before it reaches the backend — the state JSON, secrets and all, is what gets written and then encrypted at rest. If you need the stored bytes themselves to be ciphertext the platform can’t read, that’s client-side state encryption, and here Terraform and OpenTofu diverge: stock Terraform has no built-in client-side state encryption, whereas OpenTofu (1.7+) ships an encryption {} block that encrypts state and plan client-side before they hit the backend.

# OpenTofu 1.7+ ONLY — encrypt state/plan client-side before it hits the backend.
# (Stock Terraform has no built-in client-side state encryption.)
terraform {
  encryption {
    key_provider "pbkdf2" "team" {
      passphrase = var.state_passphrase   # or an aws_kms / gcp_kms / openbao provider
    }
    method "aes_gcm" "default" {
      keys = key_provider.pbkdf2.team
    }
    state { method = method.aes_gcm.default }
    plan  { method = method.aes_gcm.default }
  }
}

Access control is the other half. Scope write access to the specific CI workload identities and a tiny break-glass group; on Azure, grant Storage Blob Data Contributor on the container, not the subscription; on AWS, scope the bucket/prefix in the IAM policy. Deny public network access and front the store with a private endpoint. And enforce the boundary in CI, not just in docs — this is where the human-in-the-loop rule lives: plan on every pull request, apply only from the protected branch, all via OIDC so there’s no long-lived key to leak.

# .github/workflows/terraform.yml — plan on PR, apply only from main via OIDC
name: terraform
on:
  pull_request:
  push:
    branches: [main]
permissions:
  id-token: write        # OIDC: no long-lived cloud keys
  contents: read
jobs:
  terraform:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: hashicorp/setup-terraform@v3
      - name: Init (partial backend config)
        run: terraform init -backend-config=envs/prod.azurerm.tfbackend
      - name: Plan
        run: terraform plan -lock-timeout=300s -out=tfplan
      - name: Apply (main only  the one human-reviewed writer)
        if: github.ref == 'refs/heads/main'
        run: terraform apply -lock-timeout=300s tfplan

Verify

Confirm the backend, locking, and recovery story actually work end to end:

# 1. The backend initializes and reports the right type and key
terraform init -backend-config=envs/prod.azurerm.tfbackend
terraform state list | head    # state is reachable and populated

# 2. Locking is real: hold a lock in one shell...
terraform plan -lock-timeout=0   # acquires and holds briefly
# ...a concurrent apply elsewhere should block or error on the lock

# 3. A split/import is non-destructive
terraform plan   # expect "to import" / "has moved" / "forget", 0 to destroy

# 4. Versioning gives you a restore point
az storage blob list --account-name sttfstateprod001 -c tfstate \
  --prefix "platform/prod" --include v --auth-mode login -o table

A healthy result: init binds to the correct key, concurrent runs serialize on the lock, restructuring plans show zero destroys, and prior state versions are listable for break-glass restore.

Common beginner mistakes

The traps here aren’t syntax errors — they’re wrong mental models that produce confident, dangerous actions. Each below is the misconception, why it’s wrong, and the model to replace it with.

Practice challenges

Work these in order; each builds on the last. Try before opening the solution.

  1. Stand up a locked backend (beginner). Create a versioned, encrypted state store and write a backend block that authenticates with a workload identity (not an account key). Prove locking exists. <details><summary>Solution</summary>

    Use the section-2 azurerm block (use_azuread_auth = true) or the s3 block with use_lockfile = true and encrypt = true, over a bucket/container that has versioning enabled. Confirm locking by running two plans at once — the second should report Error acquiring the state lock.

    Why: locking + encryption + versioning + OIDC auth are the four non-negotiables of a scaled backend; a store missing any one is not production-ready. </details>

  2. One module, two environments (beginner → intermediate). Drive a single root module against dev and prod using partial config, with no code duplication and no chance of a key collision. <details><summary>Solution</summary>

    Keep a type-only backend "azurerm" { use_azuread_auth = true } block, and one file per env: envs/dev.azurerm.tfbackend and envs/prod.azurerm.tfbackend, each with a distinct key like platform/dev/eastus2.tfstate and platform/prod/eastus2.tfstate. Init with terraform init -reconfigure -backend-config=envs/<env>.azurerm.tfbackend.

    Why: a consistent <stack>/<env>/<region> key means two environments can never write the same object, and partial config removes the copy-pasted root module. </details>

  3. Prove locking, then clear it safely (intermediate). Hold a lock, watch a concurrent run block, read the lock error, and force-unlock correctly. <details><summary>Solution</summary>

    Start a long plan/apply in one shell; in another, run any state operation and read the Error acquiring the state lock message — it prints the lock ID and holder. Only after confirming the first process has actually exited, run terraform force-unlock <LOCK_ID>; then terraform plan to confirm no damage.

    Why: serialization is the entire value of a lock; force-unlock is safe only once you’ve verified the holder is dead — otherwise you enable the two-writer race it’s meant to recover from. </details>

  4. Move a resource without destroying it (intermediate → advanced). Relocate one resource from a monolith into a separate stack using declarative blocks, and verify both sides are non-destructive. <details><summary>Solution</summary>

    In the source stack add a removed block with lifecycle { destroy = false } for the resource; in the destination stack add an import block plus a matching resource definition. Run terraform plan on both: source must show a forget (0 to destroy), destination an import with 0 changes.

    Why: removed + import is plan-reviewable in each repo’s PR, which is far safer than a cross-file terraform state mv that operates blindly on local files. </details>

  5. Migrate a backend and prove it’s non-destructive (advanced). Move a state object to a new backend (local → remote, or DynamoDB-locking → use_lockfile) and prove nothing changed. <details><summary>Solution</summary>

    Edit backend.tf to the target backend, then terraform init -migrate-state and accept the copy prompt — or do it by hand: terraform state pull > migrate.tfstate, terraform init -reconfigure, terraform state push migrate.tfstate. In both cases the acceptance test is terraform plan reporting No changes.

    Why: a correct migration only moves where state lives, never what it says — any plan result other than “No changes” means the migration failed and you must not apply. </details>

  6. Decouple a cross-stack read (advanced). Wire a consumer to a producer’s value in a way that is secret-safe and survives the producer changing its backend location. <details><summary>Solution</summary>

    Replace a terraform_remote_state read with either a provider data lookup (data "azurerm_virtual_network" "hub" by name) or a parameter-store contract (producer writes app_subnet_id to SSM / App Configuration; consumer reads it). Both avoid coupling to the producer’s backend path and never expose the full output set.

    Why: a lookup binds only to a naming convention and reads the live API (no stale-output risk, no secret exposure); a published contract binds only to agreed keys and can be RBAC-scoped per value. </details>

Glossary

Checklist

Pitfalls and next steps

The recurring failures are boringly consistent: a backend with no locking; secrets read out of a terraform_remote_state output; terraform import run without a backup and without reviewing the plan; force-unlock wired into a retry loop; and a “we’ll split it later” monolith that’s now too scary to touch. Every one is cheap to prevent and brutal to unwind under incident pressure.

From here, codify the boundaries you’ve drawn: enforce the key-naming and “no secrets in outputs” rules with policy as code (Sentinel or OPA/Conftest) at the plan stage, wrap state operations in a thin internal CLI so engineers can’t fat-finger a cross-state state mv, and add automated drift detection that plans every stack on a schedule so divergence between state and reality surfaces in a dashboard rather than in an outage. Remote state stops being a liability the day it becomes locked, versioned, least-privileged, and small enough that no single apply can ruin your week.

TerraformRemote StateBackendState LockingAzure
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments