---
title: "InfraOps"
slug: infraops
category: cloud
term_status: overlapping-synonym
evidence_status: verified
summary: "InfraOps is informal shorthand for infrastructure operations. This Atlas uses it for running infrastructure through code: declaring it, reviewing every change as a plan, applying it from a pipeline and keeping the tool's state in step with reality."
audience: [Beginners, Infrastructure and platform engineers, Cloud engineers, Architects, Engineering leaders]
last_verified: 2026-10-17
key_takeaways:
  - "InfraOps is an informal label for infrastructure operations, with no defining body. This page uses it for the infrastructure-as-code (IaC) lifecycle, which is an editorial emphasis."
  - "Keep three things apart: the code (what you intend), the state (what the tool manages) and reality (what exists). Drift and unmanaged resources live in the gaps between them."
  - "The reviewed plan is the unit of change. Apply what was reviewed, and plan again when anything else has been applied in the meantime."
  - "State and plan files can contain secrets, and a plan runs provider and data-source code. Plan credentials therefore need almost as much care as apply credentials."
  - "Size each state by blast radius. Google Cloud's guidance is at most 100 resources per state, ideally a few dozen."
  - "Replace compute from versioned images, and protect durable data explicitly: prevent_destroy does not stop a deletion when the resource's code is removed."
related: [cloudops, sysops, automationops, gitops, platformops, devops, kubernetesops, itops]
---

## What InfraOps is

### In 30 seconds

**InfraOps** is an informal contraction of *infrastructure operations*: providing and running the servers, networks, storage,
databases and cloud accounts that applications depend on. No standards body or certification defines the label.

**In this Atlas, InfraOps means running infrastructure through code.** Everything is declared in version-controlled files; each
change is previewed as a *plan*, reviewed, applied by a pipeline and then checked against what actually exists.

**In one line: InfraOps keeps three things in agreement, the code that says what should exist, the state that records what the tool
manages, and the infrastructure that really exists, and it changes them only through reviewed plans.**

**For** engineers who write or inherit infrastructure code, architects who design state layouts and pipelines, and leaders deciding
how much of the estate to codify.

::: note Term status: an informal synonym, given an editorial emphasis
"InfraOps" is a rare, informal label for a long-established practice, usually called *infrastructure operations* or *infrastructure
and operations (I&O)*. We found three kinds of use: an analyst firm's umbrella term for infrastructure automation,[^infotech] team
and job names,[^nubank-job] and a 2025 commercial product of the same name from Skyline Communications.[^skyline] The product name
is a collision, not a definition. **Editorial distinction:** the Atlas gives this page the infrastructure-as-code lifecycle. Which
cloud constructs to govern belongs to [CloudOps](../cloudops/), and how operations teams are organised and governed belongs to
[ITOps](../itops/).
:::

### At a glance

| Item | Summary |
|---|---|
| Term status | **Overlapping synonym** for infrastructure operations, not standardised; the practice taught here (IaC) is mature and well documented |
| Goal | Infrastructure rebuilt from code, changed only after review, with recorded state matching reality |
| In scope | IaC languages and engines, state and locking, plans and review, modules, drift, import and refactoring, golden images, environment parity, infrastructure testing, code-derived inventory, decommissioning |
| Out of scope | Cloud constructs and landing zones → [CloudOps](../cloudops/); the host OS → [SysOps](../sysops/); runbook automation → [AutomationOps](../automationops/); continuous reconciliation → [GitOps](../gitops/); the platform product → [PlatformOps](../platformops/); CMDB process and change approval → [ITOps](../itops/) |
| Prerequisites | Command line and Git; basic cloud or virtualisation concepts; delivery pipelines ([DevOps](../devops/)) |
| Key outputs | A state layout, versioned modules, a plan-review-apply pipeline, drift reports, an image pipeline, a coverage figure |

### The problem InfraOps solves

Infrastructure changed by hand drifts apart. Martin Fowler's "snowflake server" is a machine so altered by unrecorded changes that
nobody can rebuild it: "good for a ski resort, bad for a data center".[^fowler-snowflake] Configuration tools reduce the problem
but, as Kief Morris noted in 2013, "there is considerable scope for configuration drift" even with them.[^morris-immutable]
Four costs follow:

1. **Environments cannot be reproduced.** DORA asks teams to keep "All cloud configuration files" in version control and to
   provision any environment automatically.[^dora-vc]
2. **Changes cannot be reviewed before they happen.** A console click has no diff and no reviewer. A plan does.
3. **Nobody knows what exists.** Inventory lives in memory or in stale spreadsheets.
4. **Audit becomes archaeology.** Control frameworks expect a documented baseline that "can be changed only through change control
   procedures";[^nist-baseline] reconstructing one after the fact is expensive.

Self-service makes this urgent: Forrester's Charles Betz observed in 2020 that developers increasingly provision infrastructure
themselves with IaC, which puts pressure on the traditional infrastructure-engineering role.[^forrester-io]

| Operating reality | Before (hand-built) | After (InfraOps) |
|---|---|---|
| New environment | Days of tickets and clicks | One reviewed pull request and a pipeline run |
| A change | Typed into a console | A diff, a plan, a reviewer, a log |
| "What runs in prod?" | Ask the person who built it | Read the code, the state and the coverage report |
| Audit evidence | Screenshots | Commits, plans, approvals and drift reports |

### What InfraOps is not

- **Not a tool.** Terraform, OpenTofu, Pulumi, CloudFormation and Bicep are engines; the practice is the lifecycle around them.
- **Not [CloudOps](../cloudops/),** which decides which provider constructs exist and how they are governed. InfraOps is *how* any of
  them is written, changed and checked as code.
- **Not [GitOps](../gitops/).** GitOps adds an agent that pulls and *continuously* reconciles the declared state. A CI job that runs
  `terraform apply` after a merge is IaC with pull-request automation, not GitOps under the OpenGitOps principles.
- **Not [SysOps](../sysops/) or [AutomationOps](../automationops/),** which own the inside of a host and run-time remediation;
  InfraOps provisions and replaces the host. [PlatformOps](../platformops/) wraps its modules behind self-service.
- **Not "infrastructure that never changes".** Immutable infrastructure replaces servers instead of editing them; databases and disks
  hold durable data that must be protected, not replaced.

## Where InfraOps comes from and where it sits

### Origins and evolution

| Year | Milestone | Why it matters |
|---|---|---|
| 1993 | Mark Burgess starts CFEngine at Oslo University "as a cross platform interface that would document desired state, rather than change algorithms"[^burgess] | Desired state and convergence: the core ideas of IaC |
| 2011 | AWS launches CloudFormation: a JSON template creates "an entire stack with one function call"[^cfn-launch] | Provider-native declarative provisioning |
| 2012–2013 | Fowler's snowflake and Phoenix servers (rebuilt from scratch at regular intervals); Morris's "immutable server"; Chad Fowler's "Trash Your Servers and Burn Your Code"[^fowler-snowflake][^morris-immutable][^chad-fowler] | Replace instead of repair |
| 2014 | HashiCorp announces Terraform, a tool for building, combining and launching infrastructure safely[^tf-launch] | One engine across many providers, with a state file and plans |
| 2016 | Fowler's definition of IaC; Kief Morris's book *Infrastructure as Code*, now in its third edition[^fowler-iac][^morris-book] | The practice gets a canon |
| 2023 | HashiCorp moves future releases to the Business Source License (BSL 1.1); the community forks Terraform as OpenTofu[^bsl][^opentofu-fork] | Licensing becomes a selection criterion |
| 2025 | OpenTofu joins the CNCF Sandbox; Crossplane graduates in the CNCF[^opentofu-cncf][^crossplane] | Two engine lineages, plus controllers for cloud resources |

### Glossary and mental model

| Term | Meaning on this page |
|---|---|
| **State** | The engine's record of which real objects it manages and their last known attributes; for Terraform "a necessary requirement for Terraform to function".[^tf-state] |
| **Plan / apply** | The computed difference between the code and refreshed reality (creates, updates, replacements, deletes); executing it. |
| **Drift** | A managed object whose real configuration no longer matches what was last applied. |
| **Unmanaged resource** | An object that exists but is in no state file. Plans cannot see it. |
| **Root module** | One unit with its own state and plan. Its size sets the *blast radius* of a change. |
| **Golden image** | A tested, versioned machine or container image from which instances are created. |
| **Immutable / mutable** | Replace a component to change it, or change it in place. |
| **Idempotent** | Repeating the operation gives the same result as running it once.[^ansible-glossary] |
| **OIDC token (workload identity)** | A short-lived token the CI platform signs for one job, naming its repository and branch or environment; cloud roles trust it instead of stored keys ([DevSecOps](../devsecops/#trust-boundaries-and-identities-in-the-pipeline)).[^gh-oidc-ref] |
| **STS** | AWS Security Token Service: exchanges that token for temporary role credentials (`AssumeRoleWithWebIdentity`).[^aws-sts] |

**Mental model: three copies of the truth.** The code says what you *intend*, the state what the tool *believes it manages*, and the
provider what *actually exists*. A plan refreshes state from reality and compares it with the code. Most InfraOps failures live in a
gap between two copies, so name the gap before fixing anything.

::: figure Three copies of the truth and the gaps between them
```mermaid
%%{init: {"flowchart": {"wrappingWidth": 85, "nodeSpacing": 10, "rankSpacing": 30}}}%%
flowchart TB
  C["Code in Git: what should exist"]
  S["State: what the tool manages"]
  R["Reality: what the provider holds"]
  C -->|"plan compares"| S
  S -->|"refresh reads"| R
  C -.- G1["Gap 1: merged, not applied"]
  S -.- G4["Gap 4: in state, deleted outside"]
  R -.- G2["Gap 2: drift, changed outside"]
  R -.- G3["Gap 3: unmanaged, not imported"]
  classDef core fill:#e8f3f2,stroke:#16756f,color:#122029
  classDef risk fill:#fbeceb,stroke:#b4332b,color:#122029
  class C,S,R core
  class G1,G2,G3,G4 risk
```
Text equivalent: code in Git, state and reality form a chain: the plan compares code with state, and a refresh reads reality into
state. Four gaps hang off them by dotted lines: merged code never applied; state entries whose objects were deleted outside the
tool; drift, where managed objects changed outside the tool; and unmanaged resources, not imported, which plans cannot see.
:::

Gap 3 deserves emphasis. In HashiCorp's drift tutorial, a hand-made security group appeared in the refresh-only plan only as a new ID
inside the instance's diff; the group itself was not reported and had to be imported.[^tf-drift] **A clean plan proves that managed
objects match the code; it says nothing about unmanaged ones.** Those need the provider's inventory (see
[assurance](#assurance-of-the-operating-model)).

### How InfraOps relates to other Ops

| Neighbour | Shared ground | Its focus | InfraOps focus |
|---|---|---|---|
| [CloudOps](../cloudops/) | Accounts, networks, guardrails | Which constructs to use and govern | Writing, changing and testing them as code |
| [SysOps](../sysops/) | Servers | Inside one host | Provisioning and replacing hosts; baking images |
| [GitOps](../gitops/) | Declarative definitions, drift | Agents that continuously reconcile | State, plans, modules; push-based IaC pipelines |
| [PlatformOps](../platformops/) | Provisioning | Self-service product, golden paths | The modules behind them |
| [AutomationOps](../automationops/) | Ansible, scripts | Run-time automation and its safety | Provisioning-time code and its lifecycle |
| [KubernetesOps](../kubernetesops/) | Clusters | Operating the orchestrator | Creating clusters and their cloud dependencies |
| [ITOps](../itops/) | Change, CMDB | Change enablement, CMDB as a process | Plans as change records; state as inventory input |
| [DevSecOps](../devsecops/), [FinOps](../finops/) | Pipelines, tags | Security gates; cost decisions | Running their policies as plan checks |

## Principles and operating model

### Charter and principles

**This charter is the Atlas's proposal, not an official manifesto.** Mission: *every infrastructure change is declared, previewed,
reviewed, applied by automation and verified afterwards.*

1. **Code is the only write path.** Humans read the console; pipelines write. Emergency changes are allowed and then codified
   within a fixed time. *Trade-off:* a slower first response to some incidents.
2. **Review the plan, not only the code.** A one-line change to a module can replace a database; only the plan shows it.
3. **Apply what was reviewed.** Applying one saved plan invalidates any other plan made against the same state,[^tf-automation] so
   treat plans as perishable and recompute them after any other apply.
4. **Small states, small blast radius.** Split state by lifecycle, owner and risk, not by convenience.
5. **Replace compute, protect data.** Instances come from images; durable stores have explicit deletion protection.
6. **Pin everything that can change underneath you:** engine, providers, modules and base images.
7. **Measure the gaps.** Drift, unmanaged resources and image age are tracked like any other operational risk.

Non-goals: codifying throwaway experiments, replacing in-host configuration management, forcing one engine on every team.

### Fundamentals and building blocks

| Building block | Without it |
|---|---|
| **Definitions** (HashiCorp Configuration Language (HCL), YAML, JSON or general-purpose code) | Changes cannot be reviewed or repeated |
| **State**, remote, locked and versioned | Concurrent applies; no recovery from a bad write |
| **Plans**, human- and machine-readable | Reviewers approve code without seeing the effect |
| **Images**, versioned | Hand-patched fleets drift apart |
| **Inventory**, state reconciled with the provider's | Unmanaged resources accumulate unseen |

### Roles, ownership and RACI

| Activity | Infra engineer (author) | Code owner (reviewer) | Service owner | Security | Platform / cloud team |
|---|---|---|---|---|---|
| Write module or change | **R** | **A** | C | C | C |
| Approve production plan | C | **A/R** | C | C (policy) | I |
| Policy rules (tags, encryption, deny lists) | C | C | I | **A/R** | R |
| State backend, CI identities | C | I | I | C | **A/R** |
| Drift triage | **R** | C | **A** for their service | C if actor unknown | I |
| Image pipeline | R | C | I | C | **A** |
| Decommissioning | **R** | C | **A** | I | C |

R = responsible, A = accountable, C = consulted, I = informed. In small teams one person holds several columns, but the production
reviewer is never the author. Organisation-level change approval stays with [ITOps](../itops/).

### Lifecycle: Day 0, Day 1, Day 2

::: figure The infrastructure-as-code lifecycle
```mermaid
%%{init: {"flowchart": {"wrappingWidth": 160, "nodeSpacing": 22, "rankSpacing": 30, "padding": 8}}}%%
flowchart TB
  A1["Day 0: state layout, module boundaries"] --> A2["Day 0: backend, CI identities, policies"]
  A2 --> B1["Day 1: write or import"]
  B1 --> B2["Day 1: plan and review"]
  B2 --> B3["Day 1: apply from the pipeline"]
  B3 --> C1["Day 2: detect drift and unmanaged objects"]
  C1 --> C2["Day 2: upgrade engine, providers, images"]
  C2 --> C3["Day 2: refactor or decommission"]
  C3 -.->|"every change is a new plan"| B2
  classDef core fill:#e8f3f2,stroke:#16756f,color:#122029
  classDef neutral fill:#f1f4f6,stroke:#5b7a87,color:#122029
  class A1,A2 neutral
  class B1,B2,B3,C1,C2,C3 core
```
Text equivalent: a vertical chain. Day 0 designs the state layout and module boundaries, then the backend, CI identities and
policies. Day 1 writes or imports code, plans and reviews it, and applies it from the pipeline. Day 2 detects drift and unmanaged
objects, upgrades the engine, providers and images, and refactors or decommissions; a dotted arrow loops back to plan and review,
because every change is a new plan.
:::

- **Day 0** settles what is hard to change later: state split, naming, tagging, identities, and the state backend itself. Create the
  backend from a small bootstrap configuration with local state, then move that state into it with `terraform init -migrate-state`.[^tf-init]
- **Day 2** takes most of the effort: drift, upgrades, image rebuilds, refactoring and removal. Slack's Terraform 0.11 to 0.12 upgrade
  in 2019 took a quarter,[^slack] so plan upgrades as projects.

### Operating model and governance

| Change class | Example | Path |
|---|---|---|
| **Standard** | Scale a node group within agreed limits; rotate an image | Pull request, automated policy, one reviewer |
| **Normal** | New network, IAM change, replacement of a stateful resource | Pull request, named owner approval, change record linked to the plan |
| **Emergency** | Hand fix during an incident | Fix, then codify or revert it in a pull request within an agreed window (say 24 hours); review at the postmortem |

Three rules make it work. **Ownership is written down:** a `CODEOWNERS` entry per directory and a named owner per state.
**Exceptions expire:** every `ignore_changes`, policy waiver and unmanaged resource has an owner and a review date. **Evidence is
generated, not written:** pull request, plan summary, approval and apply log form the record that [ITOps](../itops/) and auditors need.

## Architecture and workflow

### Reference architecture

Four trust zones: **authoring** needs no cloud credentials; the **pipeline** holds separate plan (read-only) and apply identities;
the **stores** hold state and versioned artefacts; the **provider accounts** hold the resources, inventory and audit log.

::: figure Reference architecture for a plan-review-apply pipeline
```mermaid
%%{init: {"flowchart": {"wrappingWidth": 115, "nodeSpacing": 16, "rankSpacing": 28}}}%%
flowchart TB
  DEV["Engineer: code and tests, no cloud keys"] --> PR["Pull request"]
  PR --> CHK["CI: format, validate, lint, scan"]
  REG["Module and image registries"] --> PLAN
  CHK --> PLAN["CI plan job: read-only role"]
  PLAN --> GATE["Plan gate and human approval"]
  GATE --> APPLY["CI apply job: environment role"]
  PLAN --> ST["State: versioned, encrypted, locked"]
  APPLY --> ST
  APPLY --> RES["Provider: managed resources"]
  RES -.->|"read nightly"| DRIFT["Drift job: report only"]
  RES -.-> INV["Provider inventory and audit log"]
  INV --> COV["Coverage report"]
  classDef core fill:#e8f3f2,stroke:#16756f,color:#122029
  classDef decision fill:#fdf3e3,stroke:#c9800f,color:#122029
  classDef neutral fill:#f1f4f6,stroke:#5b7a87,color:#122029
  class DEV,PR,CHK,PLAN,APPLY core
  class GATE decision
  class ST,REG,RES,INV,DRIFT,COV neutral
```
Text equivalent: an engineer without cloud credentials opens a pull request. CI formats, validates, lints and scans; a read-only plan
job reads modules and images from registries, and reads state. A plan gate and human approval precede an apply job whose
environment-scoped role locks state, changes resources and writes state. A nightly, report-only drift job reads the resources; the
provider's inventory and audit log, which see every resource, feed a coverage report. Green boxes are the change path, the amber box
is the approval gate, grey boxes are stores and reports.
:::

| Component | Trust and failure notes |
|---|---|
| Repository | Branch protection, `CODEOWNERS`, lock file; never state or plan files |
| Plan identity | Reads provider APIs and state, never locks; can read every secret in state; reachable by anyone who can push a branch |
| Apply identity | Writes resources and state; reachable only from the protected environment after approval |
| State backend | Versioned, encrypted, locked; restricted readers; its own bootstrap stack[^tf-s3] |
| Registries | A bad module or image version reaches every consumer that upgrades |
| Drift job | Reports, never applies, unless the [judgement call](#a-judgement-call-auto-revert-drift-or-alert-a-human) says otherwise |
| Coverage report | Provider inventory compared with state: the only view of unmanaged resources |

**Alternatives.** Managed runners (HCP Terraform and similar) replace the pipeline and often the backend. Kubernetes-hosted
reconcilers (Crossplane, tofu-controller)[^crossplane][^tofu-controller] replace push-based applies with continuous reconciliation
([GitOps](../gitops/)). Provider-native engines (CloudFormation, Bicep) have no state file of yours to protect, but serve one provider.

### End-to-end workflow

::: figure One production change, from pull request to apply
```mermaid
%%{init: {"sequence": {"actorMargin": 8, "width": 100, "noteMargin": 6, "messageMargin": 30, "diagramMarginX": 8}}}%%
sequenceDiagram
  participant G as Git host, people
  participant CI as Pipeline
  participant STS as Cloud STS
  Note over G: Pull request
  G->>CI: Plan job (PR)
  CI->>STS: Token, PR subject
  STS-->>CI: Read-only session
  CI-->>G: Change list on PR
  Note over G: Review, merge
  G->>CI: Plan job (main)
  CI->>STS: Token, main subject
  STS-->>CI: Read-only session
  CI-->>G: Change list on run
  Note over G: Approver reads it
  G->>CI: Apply job
  CI->>STS: Token, environment
  STS-->>CI: Apply session
  Note over CI: Lock, re-plan
  Note over CI: Compare, apply
```
Text equivalent: three lanes: Git host (where people review and approve), pipeline, cloud token service. A plan job on the pull
request exchanges an OIDC token with a pull-request subject for a read-only session and posts its change list. After merge, a second
plan job on `main` does the same and posts its list on the run page. The approver reads it; the apply job exchanges a token with an
environment subject for an apply session, then locks state, re-plans, compares with the approved list and applies.
:::

**Happy path:** the pull-request plan, the post-merge plan and the apply-time plan describe the same changes. **Exception paths:**
- *The post-merge plan differs from the pull request's* (something merged or applied in between): the approver, the only check
  at this step, rejects the run and the change returns to review (principle 3).
- *State changed during the approval wait:* the comparison fails and nothing is applied; re-run for a fresh plan and approval.
- *The lock is held:* the apply job waits (`-lock-timeout`), then fails. Never break a lock you did not take.[^tf-locking]
- *Apply fails half-way:* there is no transaction-style rollback; state records what succeeded. Fix forward with a new plan.
- *Policy blocks the plan:* fix the code or request a dated exception.

## Practices, frameworks and tools

### Core practices and playbooks

#### State design and blast radius

Split state by **lifecycle** (networks change rarely, services often), **ownership** (one team per state) and **risk** (data stores
apart from states that change daily). Google Cloud's guidance is "Don't include more than 100 resources (and ideally only a few
dozen) in a single state", because every resource is refreshed on every run.[^gcp-root] Slack moved from one state per region to one
per region in each account, isolating large services because smaller states are faster and safer; by 2022 it had close to 1,400
state files owned by different teams.[^slack] Give each environment its own directory and backend key: Terraform's documentation says
CLI workspaces are not a suitable isolation mechanism when environments need different credentials.[^tf-workspaces]

#### Sharing data between states

Small states need each other's values: the service state needs the network's subnet IDs. `terraform_remote_state` reads another
state's outputs, but HashiCorp warns that its user "must have access to the entire state snapshot, which often includes some
sensitive information", and recommends publishing shared data separately.[^tf-remote-state] So producers write shared values to a
parameter store (for example `aws_ssm_parameter`), or consumers find objects with provider data sources by name or tag; on HCP
Terraform, `tfe_outputs` reads outputs without full state access. With remote state, the consumer's plan role needs `s3:GetObject`
on the producer's state key, and so sees every secret in it. Record dependencies in the root module card ([templates](#templates)).

#### Modules and versioning

A module does one job (a network; a database with its alarms), exposes few inputs and validates them. Consumers pin a registry
`version` or a Git tag or commit with `?ref=`.[^tf-modsrc] The lock file pins providers, but "Terraform does not remember version
selections for remote modules", so module pins live in the code.[^tf-lockfile] Breaking changes get a major version and an upgrade note.

#### Refactoring, importing and removing without surprises

| Intent | Terraform construct | Watch out for |
|---|---|---|
| Rename or move into a module | `moved` block (v1.1+): no destroy, no re-create[^tf-moved] | Keep historical `moved` blocks; deleting them is a breaking change for consumers |
| Adopt something that exists | `import` block (v1.5+), optionally `plan -generate-config-out`[^tf-import][^tf-changelogs] | Generated code needs review; Google advises avoiding imports where possible, because provenance is hard to understand[^gcp-ops] |
| Stop managing without deleting | `removed` block with `destroy = false` (v1.7+)[^tf-removed][^tf-changelogs] | Hand over ownership first, or the object becomes unmanaged |
| Never delete by accident | `prevent_destroy` | It "doesn't prevent Terraform from destroying a resource if you remove its configuration"[^tf-lifecycle]; also enable the provider's own deletion protection |
| Fix one resource in a crisis | `-target` | For "exceptional circumstances only"; routine use can lead to undetected configuration drift[^tf-plan] |

#### Drift: detect, classify, resolve

Run a scheduled plan per root module with `-detailed-exitcode` (exit code 2 = changes present).[^tf-plan] A refresh-only plan shows
only what changed outside the tool; `apply -refresh-only` accepts reality into state without touching infrastructure.[^tf-drift]
Classify before you act.

::: figure Classifying a difference before fixing it
```mermaid
%%{init: {"flowchart": {"wrappingWidth": 115, "nodeSpacing": 14, "rankSpacing": 26}}}%%
flowchart TB
  D["Difference found by plan or inventory"] --> Q1{"In state?"}
  Q1 -->|"Yes"| Q2{"Intended?"}
  Q1 -->|"No"| U["Unmanaged: import, hand over or delete"]
  Q2 -->|"Yes, emergency fix"| K["Codify it in a pull request"]
  Q2 -->|"No"| Q3{"Made by a service or controller?"}
  Q3 -->|"No"| Q4{"Actor known?"}
  Q3 -->|"Yes"| I["ignore_changes, with owner and expiry"]
  Q4 -->|"Yes"| V["Revert by applying reviewed code"]
  Q4 -->|"No"| SEC["Security event: preserve evidence first"]
  classDef decision fill:#fdf3e3,stroke:#c9800f,color:#122029
  classDef core fill:#e8f3f2,stroke:#16756f,color:#122029
  classDef risk fill:#fbeceb,stroke:#b4332b,color:#122029
  class Q1,Q2,Q3,Q4 decision
  class U,K,I,V core
  class SEC risk
```
Text equivalent: four questions classify a difference. Not in state: unmanaged, so import, hand over or delete it. An intended
emergency fix: codify it in a pull request. Unintended but made by a service or controller: ignore the attribute with an owner and an
expiry. Made by a known person: revert by applying reviewed code. Unknown actor: treat it as a security event and preserve evidence
first.
:::

Detection quality varies. CloudFormation compares only explicitly set properties, marks unsupported types `NOT_CHECKED` and can
report false positives such as 1024 MB versus 1 GB;[^cfn-drift] Bicep's what-if warns that properties can be "incorrectly reported as
deleted".[^bicep-whatif] Budget triage time for false positives.

#### Golden images and immutable compute

Bake operating system, agents and hardening into an image; Packer creates "identical machine images for multiple platforms from a
single source configuration".[^packer] Test it, publish it to a channel and roll instances by replacement. AWS's
Well-Architected guidance forbids in-place updates, patches and configuration changes on production workloads under immutable
infrastructure, citing reduced drift and the option to disable SSH.[^aws-rel08] Configuration management still serves inside images
and on long-lived hosts ([AutomationOps](../automationops/)).

#### Environment parity

Use the same modules everywhere and vary only inputs. The Twelve-Factor App asks for "the same type and version of each of the
backing services".[^twelve-factor] Parity of *versions* matters more than of *size*: a smaller staging database on the same engine
version is a fair test; a different engine is not.

#### Decommissioning

Plan the destroy, check dependants, snapshot durable data, apply, then remove code and image channels. Lift deletion protection
deliberately, in the same reviewed change.

### Methods, standards and frameworks

| Source | Type | What it asks of InfraOps |
|---|---|---|
| NIST SP 800-53 Rev. 5, CM family[^nist-80053] | Controls, binding only where adopted by law or contract | CM-2 baseline, CM-3 change records, CM-6 approved deviations, CM-8 complete inventory |
| AWS Well-Architected REL08-BP04[^aws-rel08] | Vendor best practice | Deploy by replacement; detect and remediate drift |
| DORA version control[^dora-vc] | Research-based recommendation | All infrastructure configuration versioned; automated environments |
| Google Cloud Terraform practices[^gcp-root][^gcp-ops] | Vendor guidance | Small root modules, plan first, pipelines, version-pin reviews |

### Tooling landscape

| Job | Representative options (as of October 2026) | Selection notes |
|---|---|---|
| Multi-provider declarative engine | Terraform (v1.16.6, BSL 1.1 from 1.6.0[^tf-license]); OpenTofu (v1.13.1, Mozilla Public License (MPL) 2.0, CNCF Sandbox[^opentofu-cncf])[^releases] | Licence, managed-service ecosystem, state encryption (OpenTofu since 1.7)[^tofu-encryption] |
| General-purpose languages | Pulumi; AWS CDK (synthesises CloudFormation)[^aws-rel08] | Loops and tests in a familiar language; Pulumi Cloud is Pulumi's default backend[^pulumi-state] |
| Provider-native engines | CloudFormation; Bicep/ARM | No state file to run (Bicep "doesn't store state"[^ms-bicep-tf]); one provider; drift and preview fidelity vary[^cfn-drift][^bicep-whatif] |
| Continuous reconcilers | Crossplane; Flux tofu-controller | Needs Kubernetes; see [GitOps](../gitops/) |
| Images | Packer (v1.16.1); EC2 Image Builder (distributes only if tests pass[^imagebuilder]); HCP Packer (channels, ancestry, revocation[^hcp-packer]) | Test before publish; track ancestry; schedule rebuilds |
| Configuration management | Ansible, Puppet, Chef | Check mode is "just a simulation";[^ansible-check] keep inside hosts and images |
| Static checks | `terraform validate`, TFLint, Checkov, Trivy (tfsec now part of Trivy)[^tf-validate][^tflint][^checkov][^tfsec] | Fast; mostly code-level, so blind to the real change (Checkov can also scan plan JSON[^checkov-plan]) |
| Plan policy | OPA/Conftest, custom scripts on plan JSON[^opa-terraform][^conftest] | Policies see the real change, not just the code |
| Tests | `terraform test` (v1.6+, mocks v1.7+), Terratest[^tf-tests][^terratest] | Apply-mode tests create real, billable resources |
| Cost in review | Infracost and similar[^infracost] | Pricing estimates, not bills; owned by [FinOps](../finops/) |

### Automation and AI

Automate formatting, validation, plans, policy, drift jobs and image rebuilds in the pipeline; keep a human approval for production
deletes and replacements. AI assistants draft modules and explain plans, and vendors expose registry documentation and workspace
operations to agents through MCP servers.[^tf-mcp] Generated code passes the same review and gates, and agents hold no apply credentials.

## InfraOps in practice

### Reference implementation

**Assumptions:** one AWS account per environment, Terraform 1.16 (not checked with OpenTofu), GitHub Actions with OIDC, the S3
backend with native locking, one root module per component and environment. `example-org` and `ACCOUNT_ID` are placeholders.
Beginners can do the [labs](#hands-on-labs) first and return here.

```text
infra-live/                 # modules live in their own repository, by tag
  live/
    prod/network/           # one root module = one state = one plan
    prod/database/
    staging/network/
  tools/plan_gate.py        # Lab 3
  tools/drift.sh            # nightly drift report
  .github/workflows/        # one workflow per root module (or a matrix)
  CODEOWNERS                # owner review: live/prod/**, tools/**,
                            # .github/workflows/**
```

_Example status: not executed and not machine-checked (no Terraform binary on the authoring host); written against the Terraform
v1.16 documentation._

```hcl
# live/prod/network/versions.tf
terraform {
  required_version = ">= 1.11.0, < 2.0.0" # S3 native locking is GA from 1.11

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.69"
    }
  }

  backend "s3" {
    # bucket: versioning on, created by a separate bootstrap stack
    bucket       = "example-tfstate-ACCOUNT_ID"
    key          = "prod/network/terraform.tfstate"
    region       = "eu-west-1"
    encrypt      = true
    use_lockfile = true # replaces the deprecated DynamoDB lock table
  }
}

provider "aws" {
  region = "eu-west-1"
  default_tags {
    tags = { owner = "network-team", cost-centre = "cc-100", managed-by = "terraform" }
  }
}

module "network" {
  # pinned to a tag
  source     = "git::https://git.example.com/infra/modules.git//network?ref=v3.4.1"
  cidr_block = "10.40.0.0/16"
  az_count   = 3
}
```

The pipeline plans on every pull request and again after merge. The post-merge plan job posts its change list on the run's summary
page, where the production approver reviews the deployment;[^gh-summary] the apply job re-plans and applies only if the list still
matches. The download step reads *this* run's summary by default,[^gh-actions-versions] so the code checks post-merge against apply
time; post-merge against the pull request is the approver's check. Plan files never travel between jobs: they hold state and
variables,[^tf-automation] and repository readers can download artifacts.[^gh-artifacts] Concurrency is per job: a new push cancels
its pull request's stale plan; applies queue and never cancel, because by default a newly queued run cancels the pending one, leaving
a merged change unapplied.[^gh-concurrency]

_Example status: YAML syntax-checked by the Atlas validator; not executed (needs a GitHub repository, an AWS account and the two
roles below). Action versions are the latest majors on 2026-10-16;[^gh-actions-versions] pin full commit SHAs in production._

```yaml
name: prod-network
on:
  pull_request:
    paths: ["live/prod/network/**"]
  push:
    branches: [main]
    paths: ["live/prod/network/**"]
permissions:
  contents: read
env:
  TF_IN_AUTOMATION: "1"
  TF_VERSION: "1.16.6" # one pin for both jobs
  WORKDIR: live/prod/network
jobs:
  plan:
    runs-on: ubuntu-24.04
    concurrency: # a new push to the same pull request cancels its stale plan
      group: plan-prod-network-${{ github.event.pull_request.number || github.run_id }}
      cancel-in-progress: true
    permissions:
      contents: read
      id-token: write
    steps:
      - uses: actions/checkout@v7
      - uses: hashicorp/setup-terraform@v4
        with:
          terraform_version: ${{ env.TF_VERSION }}
          terraform_wrapper: false
      - uses: aws-actions/configure-aws-credentials@v6
        with:
          role-to-assume: arn:aws:iam::ACCOUNT_ID:role/tf-prod-network-plan
          aws-region: eu-west-1
      - name: Plan without the lock, gate, publish the change list
        working-directory: ${{ env.WORKDIR }}
        run: |
          terraform init -input=false
          terraform validate
          terraform plan -input=false -lock=false -out=tfplan
          terraform show -json tfplan > plan.json
          ALLOW=$(paste -sd, approved-destroys.txt 2>/dev/null || true)
          python3 "$GITHUB_WORKSPACE/tools/plan_gate.py" plan.json \
            --allow "$ALLOW" --summary-out plan-summary.json
          printf 'Change list to approve (full plan in the job log):\n\n' \
            >> "$GITHUB_STEP_SUMMARY"
          sed 's/^/    /' plan-summary.json >> "$GITHUB_STEP_SUMMARY"
          rm -f tfplan plan.json
      - uses: actions/upload-artifact@v7
        with:
          name: plan-summary
          path: ${{ env.WORKDIR }}/plan-summary.json
          retention-days: 7
  apply:
    if: github.event_name == 'push'
    needs: plan
    runs-on: ubuntu-24.04
    environment: production
    concurrency: # queue every merged apply in order; never cancel one
      group: apply-prod-network
      cancel-in-progress: false
      queue: max # up to 100 pending (GitHub docs, October 2026)
    permissions:
      contents: read
      id-token: write
    steps:
      - uses: actions/checkout@v7
      - uses: hashicorp/setup-terraform@v4
        with:
          terraform_version: ${{ env.TF_VERSION }}
          terraform_wrapper: false
      - uses: aws-actions/configure-aws-credentials@v6
        with:
          role-to-assume: arn:aws:iam::ACCOUNT_ID:role/tf-prod-network-apply
          aws-region: eu-west-1
      - uses: actions/download-artifact@v8
        with:
          name: plan-summary # from this run's post-merge plan job
          path: approved
      - name: Re-plan with the lock, compare with the approved list, apply
        working-directory: ${{ env.WORKDIR }}
        run: |
          terraform init -input=false
          terraform plan -input=false -lock-timeout=5m -out=tfplan
          terraform show -json tfplan > plan.json
          ALLOW=$(paste -sd, approved-destroys.txt 2>/dev/null || true)
          python3 "$GITHUB_WORKSPACE/tools/plan_gate.py" plan.json \
            --allow "$ALLOW" --compare "$GITHUB_WORKSPACE/approved/plan-summary.json"
          terraform apply -input=false -lock-timeout=5m tfplan
```

**Who can reach which credential.** OIDC trust, wildcard subjects and GitHub's 2026 immutable subject format are taught in
[DevSecOps](../devsecops/#trust-boundaries-and-identities-in-the-pipeline); three points are IaC-specific.

- **Any branch author can use the plan role.** It trusts pull requests and `main`. Forks get no `id-token: write` unless an
  administrator allows write tokens for them, but a `pull_request` run executes the merge commit, edited workflow included,[^gh-pr]
  so anyone who can push a branch can run any command with the role. Plans run code anyway: data sources are read during
  planning,[^tf-data] and `external` runs a program[^tf-external] (this page's inference).
- **So the plan role only reads**, but that includes every secret in state.[^tf-sensitive] It never locks, so it cannot delete a
  lock file: plans use `-lock=false`, which Terraform calls dangerous when others run commands concurrently;[^tf-plan] the cost
  here is a possibly stale plan, caught by the apply job's locked re-plan.
- **The apply role trusts only `environment:production`**, so it is as strong as that environment: required reviewers, "prevent
  self-review", a `main`-only deployment branch rule (which also keeps edited branch workflows out) and no administrator bypass
  (allowed by default). On GitHub Free, Pro and Team plans, required reviewers work only in public repositories.[^gh-env]

_Example status: JSON syntax-checked; not executed. The first document is the apply role's trust policy. The second is the plan
role's state access: the S3 backend documents `s3:ListBucket` on the bucket, `s3:GetObject` and `s3:PutObject` on the state key, and
get/put/delete on the `.tflock` key;[^tf-s3] the plan role keeps only the reads. Its trust policy lists the two subjects
`repo:example-org/infra-live:pull_request` and `repo:example-org/infra-live:ref:refs/heads/main`. All three subjects use GitHub's
previous format; a repository created, renamed or transferred after 2026-07-15, or opted in, needs the immutable form
`repo:example-org@OWNER_ID/infra-live@REPO_ID:…` instead.[^gh-oidc-ref]_

```json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "TrustProductionEnvironmentOnly",
      "Effect": "Allow",
      "Principal": {
        "Federated":
          "arn:aws:iam::ACCOUNT_ID:oidc-provider/token.actions.githubusercontent.com"
      },
      "Action": "sts:AssumeRoleWithWebIdentity",
      "Condition": {
        "StringEquals": {
          "token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
          "token.actions.githubusercontent.com:sub":
            "repo:example-org/infra-live:environment:production"
        }
      }
    }
  ]
}
```

```json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ListState",
      "Effect": "Allow",
      "Action": "s3:ListBucket",
      "Resource": "arn:aws:s3:::example-tfstate-ACCOUNT_ID"
    },
    {
      "Sid": "ReadStateOnlyNoLock",
      "Effect": "Allow",
      "Action": "s3:GetObject",
      "Resource":
        "arn:aws:s3:::example-tfstate-ACCOUNT_ID/prod/network/terraform.tfstate"
    }
  ]
}
```

The nightly drift job below reports and never applies. Run it once per environment (a matrix job), each time with that environment's
plan role, so no single identity reads every account's state. _Example status: bash syntax-checked, and run against a stub
`terraform` that returned each exit code in turn; not run against real Terraform._

```bash
#!/usr/bin/env bash
# tools/drift.sh ENV: nightly drift report for the root modules of one
# environment, run with that environment's read-only plan role.
# Exit 0 = clean, 2 = changes found, 1 = an error in any module.
set -uo pipefail
env_name=${1:?usage: drift.sh ENVIRONMENT}
root=$(pwd)
status=0
for dir in "live/$env_name"/*/; do
  cd "$root/$dir" || { echo "error    $dir"; status=1; continue; }
  if ! terraform init -input=false -no-color > drift.log 2>&1; then
    echo "error    $dir (init failed, see drift.log)"; status=1; continue
  fi
  terraform plan -input=false -lock=false -detailed-exitcode -no-color \
    -out=drift.tfplan >> drift.log 2>&1
  rc=$?
  case "$rc" in
    0) echo "clean    $dir" ;;
    2) echo "changes  $dir"
       terraform show -json drift.tfplan \
         | python3 "$root/tools/plan_gate.py" /dev/stdin --max-changes 100000 || true
       if [ "$status" -eq 0 ]; then status=2; fi ;;
    *) echo "error    $dir (see drift.log)"; status=1 ;;
  esac
  rm -f drift.tfplan
done
exit "$status"
```

A golden-image template for Packer, with encrypted volumes, IMDSv2 (the session-oriented version of the EC2 instance metadata
service)[^aws-imds] and a deprecation date about six months after each build. _Example status: not executed and not syntax-checked
(no Packer binary); options and functions checked against the Amazon plugin v1.8.3 and Packer documentation;[^packer-ebs] confirm the
current Ubuntu image name pattern with your provider._

```hcl
packer {
  required_plugins {
    amazon = {
      version = "~> 1.8"
      source  = "github.com/hashicorp/amazon"
    }
  }
}

variable "image_version" {
  type = string
}

source "amazon-ebs" "base" {
  region        = "eu-west-1"
  instance_type = "t3.small"
  ssh_username  = "ubuntu"
  ami_name      = "base-ubuntu-24.04-${var.image_version}"
  encrypt_boot  = true
  imds_support  = "v2.0"
  deprecate_at  = timeadd(timestamp(), "4380h") # about six months
  source_ami_filter {
    filters = {
      name                = "ubuntu/images/hvm-ssd-gp3/ubuntu-noble-24.04-amd64-server-*"
      root-device-type    = "ebs"
      virtualization-type = "hvm"
    }
    owners      = ["099720109477"]
    most_recent = true
  }
  metadata_options {
    http_endpoint = "enabled"
    http_tokens   = "required"
  }
  tags = {
    image_version = var.image_version
    base_ami_name = "{{ .SourceAMIName }}"
  }
}

build {
  sources = ["source.amazon-ebs.base"]
  provisioner "shell" {
    inline = [
      "sudo apt-get update",
      "sudo DEBIAN_FRONTEND=noninteractive apt-get -y upgrade",
      "sudo apt-get -y install auditd unattended-upgrades",
    ]
  }
}
```

### Hands-on labs

Labs 1 and 2 use the `local` provider (no cloud, no cost); Lab 3 needs only Python 3.

#### Lab 1 (beginner): state, plans and drift on your laptop

- **Objective:** see the three copies of the truth and a drift, with no cloud.
- **Prerequisites:** Terraform 1.7 or later (or OpenTofu, untested here), internet access to download the `hashicorp/local` provider,
  an empty directory. 20 minutes. **Status:** not executed by the author.

```hcl
terraform {
  required_version = ">= 1.7.0"
  required_providers {
    local = {
      source  = "hashicorp/local"
      version = "~> 2.9"
    }
  }
}

resource "local_file" "motd" {
  filename        = "${path.module}/out/motd.txt"
  content         = "Welcome. Owner: payments\n"
  file_permission = "0644"
}
```

1. Save the block as `main.tf` and run `terraform init`; the new `.terraform.lock.hcl` would be committed.[^tf-lockfile]
2. `terraform plan -out=tfplan`, read it, then `terraform apply tfplan`. Passing the saved plan *is* the approval.[^tf-apply]
3. Inspect the copies: `cat out/motd.txt` (reality), `terraform state show local_file.motd` (state), `main.tf` (code).
4. Create drift: `echo "edited by hand" >> out/motd.txt`.
5. Run `terraform plan -refresh-only` and read it, then `terraform plan -detailed-exitcode; echo "exit=$?"`.
6. Decide: code wins (leave `main.tf` as it is), or reality wins (edit `content` in `main.tf` to the file's new text). Either way,
   run `terraform apply`, then plan again.

- **Expected output:** step 2 plans one resource to add. In step 5 the normal plan exits 2 (changes present).[^tf-plan] This provider
  drops a file from state when its checksum differs,[^local-provider] so the refresh-only plan reports it deleted outside Terraform and
  the normal plan re-creates it rather than updating it. That is why *reality wins* also needs an apply: the object must return to
  state, and the apply writes the same text back. Drift looks different in every provider.
- **Validation:** after the apply in step 6, `terraform plan -detailed-exitcode` exits 0.
- **Troubleshooting:** a state-lock error means another terminal is running Terraform; wait. Provider downloads need internet access
  or a mirror.
- **Teardown:** `terraform destroy`, then delete the directory, including `terraform.tfstate*` (never commit these).

#### Lab 2 (intermediate): a tested module and a safe rename

- **Objective:** validate inputs, test a module with `terraform test`, and rename it without destroying anything.
- **Prerequisites:** Lab 1; Terraform 1.7 or later. 40 minutes. **Status:** not executed by the author.

Create `modules/motd/main.tf`:

```hcl
variable "owner" {
  type        = string
  description = "Owning team: lowercase letters and hyphens"
  validation {
    condition     = can(regex("^[a-z][a-z-]{1,30}$", var.owner))
    error_message = "owner must be 2-31 lowercase letters or hyphens."
  }
}

resource "local_file" "motd" {
  filename = "${path.module}/out/motd-${var.owner}.txt"
  content  = "Owner: ${var.owner}\n"
}

output "path" {
  value = local_file.motd.filename
}
```

and `modules/motd/tests/motd.tftest.hcl`:

```hcl
variables {
  owner = "payments"
}

run "content_names_owner" {
  command = plan
  assert {
    condition     = local_file.motd.content == "Owner: payments\n"
    error_message = "The file must name its owner."
  }
}

run "rejects_bad_owner" {
  command = plan
  variables {
    owner = "Payments Team"
  }
  expect_failures = [var.owner]
}
```

1. In `modules/motd`, run `terraform init` and `terraform test`. Both runs use `command = plan`, so nothing is created; the second
   passes because validation fails as expected.[^tf-tests]
2. In the parent directory, add a `module "motd"` block with `source = "./modules/motd"` and `owner = "payments"`; init and apply.
3. Rename the block to `module "banner"` and plan, without applying: one file destroyed, one created.
4. Add a `moved` block with `from = module.motd` and `to = module.banner`, and plan again.[^tf-moved]

- **Expected output:** step 1 reports two passing runs; step 3 shows one to add and one to destroy; step 4 shows the object moving
  to the new address with nothing added or destroyed.
- **Validation:** after applying step 4, `terraform state list` shows `module.banner.local_file.motd`.
- **Troubleshooting:** if no tests are found, check the `.tftest.hcl` suffix and the `tests/` directory location.
- **Teardown:** `terraform destroy`; delete the directory.

#### Lab 3 (advanced): a plan gate on machine-readable plans

- **Objective:** block plans that replace protected data stores, miss required tags or exceed a blast-radius limit, and produce the
  change summary the reference pipeline compares.
- **Prerequisites:** Python 3.8+. 30 minutes. **Status:** executed on 2026-10-16 against the hand-written sample below, shaped per
  Terraform's documented plan JSON format;[^tf-json] not yet run against real Terraform output.

_Example status: executed (Python 3, synthetic input)._

```python
#!/usr/bin/env python3
"""Plan gate: read `terraform show -json tfplan` output and block risky plans.

Exit codes: 0 = pass, 1 = policy violation or summary mismatch, 2 = bad input.
"""
import argparse
import json
import sys

PROTECTED = {"aws_db_instance", "aws_rds_cluster", "aws_s3_bucket",
             "aws_dynamodb_table", "aws_kms_key", "aws_efs_file_system"}


def classify(actions):
    if actions in (["delete", "create"], ["create", "delete"]):
        return "replace"
    return actions[0] if len(actions) == 1 else "+".join(actions)


def main():
    p = argparse.ArgumentParser()
    p.add_argument("plan_json")
    p.add_argument("--max-changes", type=int, default=25)
    p.add_argument("--required-tags", default="owner,cost-centre")
    p.add_argument("--allow", default="",
                   help="comma-separated addresses approved for delete/replace")
    p.add_argument("--summary-out", help="write the reviewed change list here")
    p.add_argument("--compare", help="fail unless the change list equals this summary")
    a = p.parse_args()
    try:
        with open(a.plan_json, encoding="utf-8") as f:
            plan = json.load(f)
    except (OSError, ValueError) as e:
        print(f"ERROR cannot read plan JSON: {e}")
        return 2
    allow = {x for x in a.allow.split(",") if x}
    required = [t for t in a.required_tags.split(",") if t]
    changes, problems = [], []
    for rc in plan.get("resource_changes", []):
        if rc.get("mode") != "managed":
            continue
        kind = classify(rc["change"]["actions"])
        if kind == "no-op":
            continue
        addr = rc["address"]
        changes.append([addr, kind])
        protected = rc["type"] in PROTECTED and addr not in allow
        if kind in ("delete", "replace") and protected:
            problems.append(f"BLOCK {kind} of protected {addr} "
                            "(add to --allow only with a written approval)")
        after = rc["change"].get("after") or {}
        # tags_all includes the AWS provider's default_tags
        tags = after.get("tags_all", after.get("tags"))
        if kind in ("create", "update", "replace") and isinstance(tags, dict):
            missing = [t for t in required if t not in tags]
            if missing:
                problems.append(f"BLOCK {addr} lacks required tags: {', '.join(missing)}")
    if len(changes) > a.max_changes:
        problems.append(f"BLOCK {len(changes)} changes exceed the "
                        f"blast-radius limit of {a.max_changes}")
    changes.sort()
    drift = len(plan.get("resource_drift", []))
    counts = {}
    for _, kind in changes:
        counts[kind] = counts.get(kind, 0) + 1
    print(f"changes={len(changes)} {json.dumps(counts, sort_keys=True)} "
          f"drifted_objects={drift}")
    if a.summary_out:
        with open(a.summary_out, "w", encoding="utf-8") as f:
            json.dump(changes, f, indent=1)
    if a.compare:
        with open(a.compare, encoding="utf-8") as f:
            if json.load(f) != changes:
                problems.append("BLOCK the change list differs from the approved "
                                "plan summary: re-review")
    for line in problems:
        print(line)
    return 1 if problems else 0


if __name__ == "__main__":
    sys.exit(main())
```

Save the sample as `sample-plan.json`:

```json
{
  "format_version": "1.2",
  "resource_drift": [
    {
      "address": "aws_security_group.web", "mode": "managed",
      "type": "aws_security_group", "name": "web",
      "change": {"actions": ["update"], "before": {}, "after": {}}
    }
  ],
  "resource_changes": [
    {
      "address": "aws_s3_bucket.logs", "mode": "managed",
      "type": "aws_s3_bucket", "name": "logs",
      "change": {
        "actions": ["no-op"],
        "before": {"tags": {"owner": "payments"}},
        "after": {"tags": {"owner": "payments"}}
      }
    },
    {
      "address": "aws_db_instance.main", "mode": "managed",
      "type": "aws_db_instance", "name": "main",
      "change": {
        "actions": ["delete", "create"],
        "before": {},
        "after": {"tags": {"owner": "payments", "cost-centre": "cc-42"}}
      }
    },
    {
      "address": "aws_instance.api[0]", "mode": "managed",
      "type": "aws_instance", "name": "api",
      "change": {
        "actions": ["create"],
        "before": null,
        "after": {"tags": {"owner": "payments"}}
      }
    },
    {
      "address": "aws_sqs_queue.jobs", "mode": "managed",
      "type": "aws_sqs_queue", "name": "jobs",
      "change": {
        "actions": ["create"],
        "before": null,
        "after": {
          "tags": {"Name": "jobs"},
          "tags_all": {"Name": "jobs", "owner": "payments", "cost-centre": "cc-42"}
        }
      }
    },
    {
      "address": "data.aws_ami.base", "mode": "data",
      "type": "aws_ami", "name": "base",
      "change": {"actions": ["read"], "before": null, "after": {}}
    }
  ]
}
```

1. `python3 plan_gate.py sample-plan.json; echo "exit=$?"`
2. `python3 plan_gate.py sample-plan.json --allow aws_db_instance.main --required-tags owner --summary-out approved.json; echo "exit=$?"`
3. `python3 plan_gate.py sample-plan.json --allow aws_db_instance.main --required-tags owner --max-changes 2; echo "exit=$?"`
4. With a real plan: `terraform plan -out=tfplan && terraform show -json tfplan > plan.json`, then run step 1 on `plan.json`.

- **Expected output (as executed):** step 1 prints `changes=3 {"create": 2, "replace": 1} drifted_objects=1`, blocks the database
  replacement and the instance missing `cost-centre`, and exits 1; the SQS queue passes because the AWS provider merges default tags
  into `tags_all`.[^aws-tags] Step 2 prints only the summary and exits 0. Step 3 reports the blast-radius limit of 2 and exits 1.
- **Validation:** `approved.json` lists three `[address, action]` pairs; the no-op bucket and the data-source read are excluded.
- **Troubleshooting:** only a saved plan (`-out`) can be shown as JSON; tags unknown until apply are absent from `after`, so the tag
  rule skips them.
- **Limits:** the summary compares *which* objects change and *how*, not attribute values: a tripwire, not a proof.
- **Teardown:** delete the files; a real `plan.json` contains state data, so never commit or upload it.

### Use cases and scenarios

| Scenario | Context and method | Trade-offs and pitfalls | Success measure |
|---|---|---|---|
| **Slack: state at scale** (sourced, 2022)[^slack] | State per region per account plus global; large services isolated; Jenkins plan and apply stages; a tool that plans every affected state on each pull request; a versioned module catalogue for compliance-sensitive states only | Testing every consumer of an updated module was prompted but not enforced, so "a change may break certain state files, but may work for others"; the catalogue slowed quick testing | Plans shown on pull requests; merges with broken plans blocked |
| **Nubank: immutable infrastructure** (sourced, 2019)[^nubank] | Definition files as source of truth, compiled to CloudFormation by an in-house tool; new resources run beside old ones under rising load before old stacks are deleted; "3000+ stacks" | Relies on monitoring, with alerts defined as code; our inference: extra capacity during every change | Changes by replacement, with health checked before deletion |
| **Adopting a console-built estate** (illustrative) | 300 hand-made resources: import the ten riskiest with `import` blocks, rebuild stateless pieces from modules, put console writes behind break-glass | Imported code mirrors old accidents ([refactoring](#refactoring-importing-and-removing-without-surprises)) | Coverage 0 → 80% in a quarter; no console writes outside break-glass |

## Measure, secure and pay

### Metrics and KPIs

These metrics are **this Atlas's proposal**; no standard defines them. Infrastructure applies also count towards DORA's delivery
metrics ([DevOps](../devops/)).

| Metric | Definition and formula | Data source | Pitfall | Cadence |
|---|---|---|---|---|
| **IaC coverage** | Managed in-scope resources ÷ all in-scope resources | State listings vs provider inventory | Shrinking the scope to flatter it | Monthly |
| **Drift incidence** | Root modules with an unexplained difference in the period ÷ root modules checked | Drift job | Counting known, accepted differences as drift | Weekly |
| **Drift age** | Resolution time − first detection, median and maximum | Drift job and tickets | Averages hide one six-week-old drift | Weekly |
| **Out-of-band writes** | Write calls by identities other than pipelines and break-glass | Provider audit log | Counting read-only calls | Weekly |
| **Failed applies** | Applies that fail or need an unplanned corrective apply within 24 h ÷ applies | Pipeline history | Re-runs after transient API errors counted twice | Monthly |
| **Blast radius** | 95th percentile of resources changed per apply; largest state size | Plan summaries; state listings | One huge but routine change masks the trend | Monthly |
| **Image age** | Share of running instances whose image is older than 30 days | Instance metadata, image registry | Hosts outside the image pipeline are invisible | Weekly |

**Worked example (illustrative numbers).** The provider inventory lists 1,840 in-scope resources and state files list 1,512 of them:
coverage = 1,512 ÷ 1,840 = **82.2%**. Over 30 days the nightly drift job finds unexplained differences in 11 of 140 root modules:
drift incidence = **7.9%**. Their ages at resolution, in hours, are 3, 5, 20, 26, 30, 48, 72, 96, 120, 200 and 400: the median is the
sixth value, **48 hours**, but four exceed a 72-hour target and the worst lasted almost 17 days. 450 of 600 instances run images under
30 days old: **75%**. The useful reading is "328 resources nobody reviews, and one drift left for 17 days", not "82%".

**Vanity metrics:** lines of IaC, module counts, and resources tagged `managed-by=terraform` (tags can be copied by hand).

### Quality, security, privacy and compliance

- **State is a secret store you did not mean to build.** State and plan files can hold passwords and API tokens.[^tf-sensitive]
  Restrict readers, encrypt, version, and keep secrets out: ephemeral resources (Terraform 1.10) and write-only arguments (1.11) are
  not persisted;[^tf-changelogs] OpenTofu (1.7+) encrypts state and plan files, but "cannot read your state file without the correct
  key".[^tofu-encryption]
- **Supply chain:** commit `.terraform.lock.hcl` (provider hashes);[^tf-lockfile] pin module refs; take base images only from approved
  owners.
- **Least privilege:** read-only console access for humans; plan roles that only read; a logged, alerting break-glass role.
- **Audit evidence:** where NIST SP 800-53 applies, pull request, plan, approval and apply log are CM-3 change records; code and
  images the CM-2 baseline; dated exceptions CM-6 deviations; state plus the coverage report the CM-8 inventory.[^nist-80053]
  Obligations vary by jurisdiction and contract (not legal advice). Evidence collection: [ComplianceOps](../complianceops/);
  security policy: [CloudSecOps](../cloudsecops/), [DevSecOps](../devsecops/).

### Cost, capacity and sustainability

**Tests:** apply-mode tests and Terratest create billable resources;[^terratest] prefer plan-mode tests and mocks. **Replacement:**
blue-green (a new copy beside the old, then a switch) briefly doubles capacity. **Review:** pull-request cost figures are estimates;
spend decisions belong to [FinOps](../finops/). **Removal:** forgotten resources are usually unmanaged, so the coverage report finds
savings. Expiring environments cut cost and idle energy use.

**Capacity as code.** Editorial split: forecasts and SLO-driven targets belong to [SRE](../sre/), quota policy to
[CloudOps](../cloudops/); this page covers codifying the numbers. Autoscaling bounds (`min_size`, `max_size`) and quota values live
in reviewed code; the AWS provider's `aws_servicequotas_service_quota` requests an increase when the desired value exceeds the
current one.[^aws-sq] Review a lowered maximum like a delete: it removes headroom.

### Assurance of the operating model

- **Coverage report:** monthly; each unmanaged resource gets an owner and a decision.
- **Out-of-band write alerts** on human identities; an **exceptions register** with expiry dates.
- **Rebuild drill:** quarterly, build staging from an empty account; whatever fails is an undocumented dependency.
- **State recovery drill:** restore an old state version in a sandbox before you need to in an incident.

## When things go wrong

### Common failures and troubleshooting

| Symptom | Likely cause | Diagnosis | Remediation | Prevention |
|---|---|---|---|---|
| "Error acquiring the state lock" | Another run, or a crashed job left its lock | Lock info shows who and when | Wait; force-unlock only your own lock[^tf-locking] | One pipeline per state; lock timeouts |
| Plan wants to replace something unexpectedly | An attribute that forces replacement changed; a rename without `moved`; a provider upgrade | Read the plan's replacement reason; diff provider versions in the lock file | Revert the attribute; add `moved`; or plan the replacement with `create_before_destroy` | Plan gate on replacements of protected types |
| A diff that never goes away | The service normalises or mutates the value; defaults not set explicitly | Compare `before` and `after` in plan JSON | Set the normalised value; or `ignore_changes` with an expiry | Track recurring diffs as defects |
| Create fails: object already exists | Created by hand or by another state | Search the provider inventory and other states | Import, or rename the new resource | Coverage report; out-of-band write alerts |
| Apply failed half-way | API error or quota limit | State records what succeeded | Fix the cause and plan again; do not hand-edit state | Smaller states; quota headroom ([CloudOps](../cloudops/)) |
| State lost or corrupted | Manual edit, bad migration, deleted object | Compare state serials and versions | Pause its pipelines, save the current copy (`terraform state pull`), restore the last good object version, record it; `state push` refuses an older serial unless forced, which HashiCorp does not recommend[^tf-state-push] | Bucket versioning;[^tf-s3] `terraform state` commands only, never hand edits[^gcp-ops] |
| Secret visible in plan or state | Secret passed as a normal argument | Search state for the value | Rotate the secret; restrict and purge old state versions | Ephemeral values or write-only arguments |

Blast-radius limits belong in tools. In February 2017 an authorised Amazon S3 engineer "using an established playbook executed a
command" with one wrong input and removed far more servers than intended; AWS changed the tool "to remove capacity more slowly" and
added minimum-capacity safeguards.[^s3-outage] A pipeline that can delete a hundred resources in one apply needs the same limit, as in
the [plan gate](#hands-on-labs).

### Worked runbook: a plan that wants to replace the production database

_Illustrative scenario._ A pull request upgrades the AWS provider and renames `aws_db_instance.db` to `aws_db_instance.main`. The plan
gate prints `changes=2 {"create": 1, "delete": 1} drifted_objects=0` and blocks it with `BLOCK delete of protected
aws_db_instance.db …`; the create at `.main` passes (output from running the Lab 3 gate on a hand-written plan of this shape).

1. **Stop.** Nothing was applied; the gate worked. Do not allow-list the address to "get it through".
2. **Read the shape.** A delete and a create at *different* addresses is a rename without `moved`: Terraform deletes the object
   at the old address.[^tf-moved] A `["delete", "create"]` pair at *one* address is a forced replacement (`BLOCK replace`), and the
   plan marks the cause `# forces replacement`.[^tf-plan]
3. **Fix the rename** with a `moved` block from `aws_db_instance.db` to `aws_db_instance.main`; the plan should show a move and zero
   destroys.[^tf-moved]
4. **Check the upgrade.** If a `replace` of `aws_db_instance.main` now appears, the provider upgrade forces it: revert it, read its
   upgrade guide, and upgrade in a separate pull request.
5. **If replacement is truly required** (an attribute that cannot change in place), run a migration, not an apply: snapshot, restore to
   a new instance, switch the application, and delete the old one in a separate, approved change.
6. **Verify** that the final plan has no destroy or replace of a protected type, and get the summary approved.
7. **Follow up:** add `prevent_destroy` *and* provider deletion protection; move the database to its own state if it shares one.

### Anti-patterns and myths

- **Skipping plan review because "the code is the documentation".** Code shows intent; only the plan shows effect.
- **One giant state:** slow plans, huge blast radius, lock contention.
- **Applying from laptops:** mixed engine versions, local credentials, no record. Laptops plan; pipelines apply.
- **`ignore_changes` as a mute button.** Every ignored attribute is drift you have agreed not to see; give it an expiry.
- **Myth: "a clean plan means we know what exists."** It covers only managed objects; unmanaged ones need the inventory.
- **Myth: "immutable means no patching."** Patching moves into the image pipeline, which must run on a schedule.

## Deciding and adopting

### When to use and when not to use

**Use it** for anything that lives beyond a few weeks, exists in several environments, is shared, or must be audited: networks,
identities, data stores, clusters, DNS, monitoring.

**Not, or not yet,** for sandbox experiments with an expiry date, settings a managed platform owns end to end (codify only its
configuration surface), or unstable APIs that would force monthly rewrites. Without version control and code review, start there:
IaC without review is faster ClickOps (unreviewed console changes). Hidden costs: state to protect, upgrades to schedule, people
who can read plans.

### Decision framework

::: figure Deciding how to manage a piece of infrastructure
```mermaid
%%{init: {"flowchart": {"wrappingWidth": 110, "nodeSpacing": 14, "rankSpacing": 26}}}%%
flowchart TB
  S["A piece of infrastructure"] --> Q1{"Lives beyond a few weeks, or exists twice?"}
  Q1 -->|"No"| N1["Sandbox by hand, with an expiry date"]
  Q1 -->|"Yes"| Q2{"Fully owned by a managed platform?"}
  Q2 -->|"No"| Q3{"Declarative, stable provider API?"}
  Q2 -->|"Yes"| N2["Codify only the platform's settings"]
  Q3 -->|"No"| N3["Images or config management first"]
  Q3 -->|"Yes"| Y1["Declarative IaC with remote state"]
  Y1 --> Q4{"Many changers, or must self-correct?"}
  Q4 -->|"Yes"| Y2["Add a reconciler: GitOps or controllers"]
  Q4 -->|"No"| Y3["Plan, review and apply pipeline"]
  classDef decision fill:#fdf3e3,stroke:#c9800f,color:#122029
  classDef core fill:#e8f3f2,stroke:#16756f,color:#122029
  classDef neutral fill:#f1f4f6,stroke:#5b7a87,color:#122029
  class Q1,Q2,Q3,Q4 decision
  class Y1,Y2,Y3 core
  class N1,N2,N3 neutral
```
Text equivalent: short-lived, single-copy infrastructure is built by hand in a sandbox with an expiry date. If a managed platform owns
it fully, codify only the platform's settings. Without a declarative, stable API, start with images or configuration management.
Otherwise use declarative IaC with remote state, adding a continuous reconciler if many parties change it or it must self-correct,
or else a plan, review and apply pipeline.
:::

For the engine, weigh providers, licence (BSL for Terraform from 1.6.0, MPL 2.0 for OpenTofu),[^tf-license][^opentofu-cncf] language,
where state lives, managed services and hiring. Pick one default engine; allow exceptions by decision record.

### Maturity model

**This maturity model is the Atlas's proposal, not an industry standard.**

| Level | Observable behaviour | Exit criteria to reach the next level |
|---|---|---|
| **1. Hand-built** | Console and scripts; knowledge in people's heads | All new resources in production created from code |
| **2. Codified** | Code in Git, remote state with locking, applies from laptops | Every production apply runs in a pipeline from a reviewed plan |
| **3. Pipelined** | Plans on pull requests, separate plan and apply identities, static checks | Policy gate on plans; drift checked at least weekly; coverage measured |
| **4. Governed** | Coverage above an agreed target (for example 90%), drift age tracked, images rebuilt on schedule, exceptions expire | Rebuild drill passes; out-of-band writes near zero |
| **5. Continuous** | Small states, versioned modules consumed through a platform, drift reconciled within its target, decommissioning routine | Sustained for two quarters; improvements driven by the metrics |

### Adoption roadmap: 30, 60 and 90 days

| Window | Actions | Exit metric |
|---|---|---|
| **Days 1–30** | Bootstrap the state backend; pick an engine and version policy; codify one low-risk component; CI plans with OIDC; coverage baseline | First production change applied from a pipeline |
| **Days 31–60** | Split state by lifecycle and owner; two versioned modules; plan gate; nightly drift job; emergency-change rule | Drift reported weekly; no laptop applies to production |
| **Days 61–90** | Import or rebuild the riskiest unmanaged resources; weekly image rebuild; exceptions register; first rebuild drill | Coverage up by an agreed step; image age reported |

## Toolkit

### Checklists

**Before the first production apply**
- [ ] State backend versioned, encrypted, locked, readers restricted
- [ ] Engine, providers and modules pinned; lock file committed
- [ ] Separate plan and apply identities with exact OIDC subjects
- [ ] `CODEOWNERS` and branch protection on production directories, `tools/` and `.github/workflows/`
- [ ] Plan gate for deletes, replacements, tags and blast radius
- [ ] Deletion protection on every durable data store

**Each change**
- [ ] The plan was read, not just the diff; destroys and replacements explained
- [ ] The production approver compared the post-merge change list with the pull request's
- [ ] The applied change list matches the approved one

**Monthly**
- [ ] Coverage reviewed; each unmanaged resource has an owner and a decision
- [ ] Drift ages within target; expired exceptions removed or renewed
- [ ] Engine, provider and image upgrades scheduled

**Break-glass**
- [ ] Hand change logged with ticket and time, then codified or reverted within the window
- [ ] Discussed at the postmortem ([IncidentOps](../incidentops/))

### Templates

```text
ROOT MODULE CARD          live/<env>/<component>
Owner team:               <team>        Reviewers: <CODEOWNERS group>
State key:                <bucket>/<env>/<component>/terraform.tfstate
Contains durable data:    yes/no  ->  deletion protection: <how>
Plan role / apply role:   <arn> / <arn>   OIDC subjects: <exact strings>
Depends on:               <published outputs, data sources>  Consumed by: <list>
Drift check:              nightly        Last rebuild drill: YYYY-MM-DD
```

```text
DRIFT EXCEPTION  DX-NNN
Resource / attribute:  <address> / <attribute path>
Why it differs:        <service mutation | controller | emergency fix | vendor default>
Mechanism:             ignore_changes | removed (destroy=false) | unmanaged
Owner:                 <name>     Created: YYYY-MM-DD     Expires: YYYY-MM-DD
Removal condition:     <what must change for this exception to go away>
```

```text
ADR: STATE LAYOUT
Context:      <estate, teams, environments, change frequency>
Decision:     one state per <component x environment>; data stores isolated;
              at most ~100 resources per state
Alternatives: single state per environment; CLI workspaces; managed stacks
Consequences: more published outputs between states; smaller blast radius;
              parallel plans
Review:       YYYY-MM-DD
```

### Deep comparisons

Compared on documented behaviour only, as of October 2026.

| Engine | Where state lives | Preview | Drift detection | Licence |
|---|---|---|---|---|
| Terraform | Backend you choose (S3, managed service…) | `plan`, JSON output[^tf-json] | Plan or refresh-only plan; HCP Terraform health assessments in Standard and Premium editions[^hcp-health] | BSL 1.1[^tf-license] |
| OpenTofu | Same model; optional state encryption | `plan` | Same model as Terraform | MPL 2.0[^opentofu-cncf] |
| Pulumi | Pulumi Cloud by default, or self-managed[^pulumi-state] | `preview`[^pulumi-cli] | `refresh` re-reads the stack's resources[^pulumi-cli] | Apache 2.0 engine and SDKs;[^apache-licences] Pulumi Cloud is a separate hosted application[^pulumi-state] |
| CloudFormation | AWS-managed | Change sets; drift-aware ones compare actual, previous and desired state[^cfn-changesets] | Built-in; explicitly set properties only[^cfn-drift] | AWS service |
| Bicep / ARM | None; incremental deployments.[^ms-bicep-tf] Deployment stacks add managed-resource tracking, deny settings and detach or delete on removal[^az-stacks] | What-if, with noise[^bicep-whatif] | What-if comparisons | Azure service |
| Crossplane | Kubernetes API objects | Controller-dependent | Continuous reconciliation[^crossplane] | Apache 2.0[^apache-licences] |

### FAQ

::: answer Can the pipeline itself compare the pull request's plan with the post-merge plan?
Yes, with some plumbing: find the pull request's last plan run, download its summary with `run-id` and a `github-token` that has
`actions: read`,[^gh-actions-versions] and fail on any difference. A squash merge creates a new commit, so look the run up by pull
request, not by commit. The reference pipeline leaves this check to the approver.
:::

::: answer Terraform or OpenTofu?
They share the model and most of the language. Decide on licence, the managed services and support you want, and features that
diverged after the fork, such as OpenTofu's state encryption.[^tofu-encryption] Re-check release notes; both move quickly.
:::

::: answer Is a CI job that runs terraform apply after merge "GitOps"?
No: the OpenGitOps principles require an agent that pulls and continuously reconciles. [GitOps](../gitops/) explains when a
reconciler is worth it.
:::

::: answer Should we import our existing console-built resources?
Import what is risky or hard to rebuild (data stores, DNS, identity); rebuild stateless pieces from new modules, because imported
code inherits historical accidents and Google advises avoiding imports where possible.[^gcp-ops]
:::

::: answer Do we still need Ansible if we bake images?
Often, for long-lived hosts, network devices and configuration inside images; Packer "does not replace configuration
management".[^packer]
:::

## Going further

### Advanced topics and emerging direction

- **Orchestration across states.** Terraform Stacks, generally available in HCP Terraform, deploy one set of components to many
  environments, each with its own state.[^tf-stacks]
- **Controllers for cloud resources.** Crossplane and tofu-controller bring continuous reconciliation, or detection only, to
  infrastructure.[^crossplane][^tofu-controller]
- **AI-assisted IaC.** Review, policy and separate apply identities matter more, not less; autonomous infrastructure agents are vendor
  direction, not established practice.

### Learning path

| Stage | Do this |
|---|---|
| Prerequisites | Git, a shell, one cloud ([CloudOps](../cloudops/)), Linux hosts ([SysOps](../sysops/)) |
| One day | Labs 1 and 2; the state, plan and lifecycle documentation |
| One week | A sandbox network and service with remote state and CI plans; destroy and rebuild it |
| Thirty days | OIDC, the plan gate, a drift job, an image pipeline; import one real resource; the state-layout ADR |
| Specialise | Policy as code ([DevSecOps](../devsecops/)), reconcilers ([GitOps](../gitops/)), platform modules ([PlatformOps](../platformops/)), cost in review ([FinOps](../finops/)) |
| Portfolio | A public repository with modules, tests, a pipeline that plans on pull requests, and a written drift policy |

## Wrap-up

### Executive recap and cheat sheet

- **Decide:** one default engine; state split by lifecycle, owner and risk; which changes need a named approver.
- **Practise:** plan every pull request; apply only reviewed plans, only from the pipeline; replace compute from images.
- **Protect:** state readers, exact OIDC subjects, deletion protection on data.
- **Measure:** coverage, drift incidence and age, out-of-band writes, failed applies, blast radius, image age.
- **Watch out for:** unmanaged resources behind clean plans, unexpiring `ignore_changes`, laptop applies, wildcard trust.

### A judgement call: auto-revert drift or alert a human?

**Situation (illustrative).** A payments platform has 140 root modules and a nightly drift job. An on-call engineer widened a
security-group rule by hand during a 02:00 incident; the drift job flagged it and nobody acted for nine days. The security lead wants
the drift job to **re-apply the code automatically** every night. The platform lead prefers **alert and assign**: an automatic revert at
03:00 could undo an emergency fix mid-incident.

| Option | For | Against |
|---|---|---|
| Auto-revert nightly | Code stays the truth; drift age is at most a day; matches the guidance to detect and remediate drift[^aws-rel08] | Can undo emergency fixes; applies without a human reading the plan; a bad merge is re-applied every night |
| Alert and assign | A human classifies first ([drift figure](#core-practices-and-playbooks)); no surprise applies | Drift ages if nobody owns the alert; this is exactly what happened |

**Assumptions:** emergency changes are rare but real; plans for most modules are small; the team can staff a daily drift triage.

**A defensible decision:** neither extreme. Auto-revert only modules without durable data, when the drift plan passes the plan gate
and **no incident is open**; otherwise alert an owner with a 72-hour target and escalate. Tools support both: tofu-controller has a
drift-detection-only model besides enforcement,[^tofu-controller] HCP Terraform health assessments detect without
remediating,[^hcp-health] and CloudFormation's drift-aware change sets show whether a deployment would revert out-of-band changes
made during incident response.[^cfn-changesets] Uncertainty remains about how often emergency fixes would be reverted; measure it.

**Why the attractive choice can be wrong:** blanket auto-revert turns the drift job into an unreviewed apply pipeline holding the
strongest credentials in the estate. Re-evaluate after a quarter using drift age and reverted emergency fixes.

### Learning objectives and mastery check

After this page you should be able to:

1. **Explain** the three copies of the truth and name the gap behind a given symptom.
2. **Design** a state layout and a plan-review-apply pipeline with separate, exactly scoped identities.
3. **Operate** drift detection and classify each difference before acting.
4. **Refactor** safely with `moved`, `import` and `removed`, and protect durable data.
5. **Measure** coverage, drift and image age, and decide what to fix first.

::: answer 1. A plan is clean, yet the security team finds a public bucket in the production account. How can both be true?
The bucket is unmanaged: it is in no state file, so no plan sees it (gap 3).[^tf-drift] Find it through the provider inventory, decide
whether to import, hand over or delete it, and check audit logs for who created it.
:::

::: answer 2. Why must the plan role be treated as sensitive if it cannot change anything?
It can read state, which may hold secrets,[^tf-sensitive] and planning executes provider and data-source code, including external
programs,[^tf-data][^tf-external] with that role's credentials. Anyone who can push a branch can trigger it and make it run any
command.
:::

::: answer 3. You renamed a module call and the plan shows 14 resources to destroy and 14 to add. What do you do?
Add a `moved` block from the old module address to the new one and plan again; it should show 14 moves and no destroys.[^tf-moved]
Keep the block, because removing it later breaks consumers still on the old address.
:::

::: answer 4. A teammate deleted a database's resource block, relying on prevent_destroy. Is the data safe?
No. `prevent_destroy` does not stop destruction when the configuration is removed.[^tf-lifecycle] Use `removed` with `destroy = false`
if the database should leave management, and keep the provider's deletion protection on.
:::

::: answer 5. The pull request's plan had 3 changes; the post-merge change list on the approval page has 5. What should the approver do?
Reject the deployment and send the change back for review. Something applied or merged in between (applying a plan invalidates other
plans against the same state),[^tf-automation] so the code review no longer describes what would happen. In the reference pipeline
this check is human: the code compares only the post-merge list with the apply-time plan.
:::

### Conclusion

"InfraOps" is a thin label over a deep practice. What makes the practice work is not a particular engine; it is the discipline of
keeping intent, record and reality in agreement and changing them only through plans that people actually read. The engines will keep
changing (licences, managed services, AI assistants), but the hard problems stay the same: state you must protect, plans that run
code, data you must not replace, and resources nobody manages. Teams that measure those gaps, and close them deliberately, get what
infrastructure as code promised: environments they can rebuild and changes they can explain.

## Sources and further reading

Each source was opened on the date its entry gives. The evidence ledger (`research/infraops-ledger.md`) maps claims to sources and
records the quotation check.

[^infotech]: [Next-Generation InfraOps](https://www.infotech.com/research/ss/next-generation-infraops) — Info-Tech Research Group, undated. Checked 2026-10-16.
[^nubank-job]: [InfraOps Engineer (Nubank, Mexico City; closed posting)](https://careers.base10.vc/companies/nubank/jobs/24333404-infraops-engineer) — Base10 careers board, undated. Checked 2026-10-16.
[^skyline]: [Skyline Communications launches InfraOps](https://www.satellitemarkets.com/products-and-services/skyline-communications-launches-infraops-new-era-digital-infrastructure-operations) — Satellite Markets, 2025-10-01. Checked 2026-10-16.
[^forrester-io]: [The I&O Organization Under Pressure](https://go.forrester.com/blogs/the-io-organization-under-pressure) — Charles Betz, Forrester, 2020-01-24. Checked 2026-10-16.
[^fowler-snowflake]: [SnowflakeServer](https://martinfowler.com/bliki/SnowflakeServer.html) (checked 2026-10-16) and [PhoenixServer](https://martinfowler.com/bliki/PhoenixServer.html) (checked 2026-10-17) — Martin Fowler, both 2012-07-10.
[^morris-immutable]: [ImmutableServer](https://martinfowler.com/bliki/ImmutableServer.html) — Kief Morris, martinfowler.com, 2013-06-13. Checked 2026-10-16.
[^chad-fowler]: [Trash Your Servers and Burn Your Code: Immutable Infrastructure and Disposable Components](https://chadfowler.com/articles/trash-your-servers-and-burn-your-code.html) — Chad Fowler, 2013-06-23. Checked 2026-10-16.
[^fowler-iac]: [InfrastructureAsCode](https://martinfowler.com/bliki/InfrastructureAsCode.html) — Martin Fowler, 2016-03-01. Checked 2026-10-16.
[^morris-book]: [Infrastructure as Code (book)](https://infrastructure-as-code.com/book/) — Kief Morris, O'Reilly, third edition. Checked 2026-10-16.
[^burgess]: [Cfengine: some facts](https://www.usenix.org/event/config10/burgess.pdf) — Mark Burgess, USENIX Configuration Management Summit, 2010. Checked 2026-10-16.
[^cfn-launch]: [AWS CloudFormation – Create Your AWS Stack From a Recipe](https://aws.amazon.com/blogs/aws/cloudformation-create-your-aws-stack-from-a-recipe/) — Jeff Barr, AWS, 2011-02-25. Checked 2026-10-16.
[^tf-launch]: [HashiCorp Terraform](https://www.hashicorp.com/en/blog/terraform-announcement) — HashiCorp, 2014-07-28. Checked 2026-10-16.
[^bsl]: [HashiCorp adopts the Business Source License](https://www.hashicorp.com/en/blog/hashicorp-adopts-business-source-license) — Armon Dadgar, HashiCorp, 2023-08-10. Checked 2026-10-16.
[^tf-license]: [Terraform LICENSE (BSL 1.1, version 1.6.0 or later)](https://github.com/hashicorp/terraform/blob/main/LICENSE) — HashiCorp/IBM, GitHub. Checked 2026-10-16.
[^opentofu-fork]: [OpenTofu Announces Fork of Terraform](https://opentofu.org/blog/opentofu-announces-fork-of-terraform/) — OpenTofu, 2023-08-25. Checked 2026-10-16.
[^opentofu-cncf]: [OpenTofu project page](https://www.cncf.io/projects/opentofu/) — CNCF, Sandbox since 2025-04-23; licence: [MPL 2.0 LICENSE](https://github.com/opentofu/opentofu/blob/main/LICENSE). Checked 2026-10-16.
[^crossplane]: [CNCF Announces Graduation of Crossplane](https://www.cncf.io/announcements/2025/11/06/cloud-native-computing-foundation-announces-graduation-of-crossplane/) — CNCF, 2025-11-06 (project page: graduated 2025-10-28). Checked 2026-10-16.
[^tofu-controller]: [tofu-controller](https://github.com/flux-iac/tofu-controller) — flux-iac, GitHub, release v0.16.5. Checked 2026-10-16.
[^dora-vc]: [Capabilities: Version control](https://dora.dev/capabilities/version-control/) — DORA, updated 2026-01-12. Checked 2026-10-16.
[^nist-baseline]: [Glossary: baseline configuration](https://csrc.nist.gov/glossary/term/baseline_configuration) — NIST CSRC (from SP 800-128 and SP 800-53 Rev. 5). Checked 2026-10-16.
[^nist-80053]: [SP 800-53 Rev. 5, Security and Privacy Controls](https://csrc.nist.gov/pubs/sp/800/53/r5/upd1/final) — NIST, release 5.2.0 (2025-08-27); control text read from the [OSCAL catalogue](https://github.com/usnistgov/oscal-content). Checked 2026-10-16.
[^releases]: Release pages for [Terraform v1.16.6](https://github.com/hashicorp/terraform/releases/tag/v1.16.6) (2026-10-14), [OpenTofu v1.13.1](https://github.com/opentofu/opentofu/releases/tag/v1.13.1) (2026-10-01) and [Packer v1.16.1](https://github.com/hashicorp/packer/releases/tag/v1.16.1) (2026-09-18) — GitHub. Checked 2026-10-16.
[^tf-state]: [Purpose of Terraform State](https://developer.hashicorp.com/terraform/language/state/purpose) — HashiCorp. Checked 2026-10-16.
[^tf-locking]: [State locking](https://developer.hashicorp.com/terraform/language/state/locking) and [force-unlock](https://developer.hashicorp.com/terraform/cli/commands/force-unlock) — HashiCorp. Checked 2026-10-16.
[^tf-sensitive]: [Sensitive data in state](https://developer.hashicorp.com/terraform/language/state/sensitive-data) — HashiCorp. Checked 2026-10-16.
[^tf-changelogs]: Terraform changelogs for [1.5](https://github.com/hashicorp/terraform/blob/v1.5/CHANGELOG.md), [1.7](https://github.com/hashicorp/terraform/blob/v1.7/CHANGELOG.md), [1.10](https://github.com/hashicorp/terraform/blob/v1.10/CHANGELOG.md) and [1.11](https://github.com/hashicorp/terraform/blob/v1.11/CHANGELOG.md) — HashiCorp, 2023–2025. Checked 2026-10-16.
[^tf-s3]: [Backend type: s3](https://developer.hashicorp.com/terraform/language/backend/s3) — HashiCorp. Checked 2026-10-16.
[^tf-plan]: [Command: plan](https://developer.hashicorp.com/terraform/cli/commands/plan) — HashiCorp. Checked 2026-10-17.
[^tf-apply]: [Command: apply](https://developer.hashicorp.com/terraform/cli/commands/apply) — HashiCorp. Checked 2026-10-16.
[^tf-automation]: [Running Terraform in automation](https://developer.hashicorp.com/terraform/tutorials/automation/automate-terraform) — HashiCorp tutorial. Checked 2026-10-16.
[^tf-data]: [Data sources](https://developer.hashicorp.com/terraform/language/data-sources) — HashiCorp. Checked 2026-10-16.
[^tf-external]: [external data source](https://github.com/hashicorp/terraform-provider-external/blob/main/docs/data-sources/external.md) — HashiCorp, terraform-provider-external. Checked 2026-10-16.
[^tf-import]: [import block](https://developer.hashicorp.com/terraform/language/block/import) and [Import existing resources](https://developer.hashicorp.com/terraform/language/import) — HashiCorp. Checked 2026-10-16.
[^tf-moved]: [Refactoring with moved blocks](https://developer.hashicorp.com/terraform/language/modules/develop/refactoring) — HashiCorp. Checked 2026-10-16.
[^tf-removed]: [removed block](https://developer.hashicorp.com/terraform/language/block/removed) — HashiCorp. Checked 2026-10-16.
[^tf-lifecycle]: [The lifecycle meta-argument](https://developer.hashicorp.com/terraform/language/meta-arguments/lifecycle) — HashiCorp. Checked 2026-10-16.
[^tf-tests]: [Tests](https://developer.hashicorp.com/terraform/language/tests) — HashiCorp. Checked 2026-10-16.
[^tf-json]: [JSON output format](https://developer.hashicorp.com/terraform/internals/json-format) — HashiCorp. Checked 2026-10-16.
[^tf-lockfile]: [Dependency lock file](https://developer.hashicorp.com/terraform/language/files/dependency-lock) — HashiCorp. Checked 2026-10-16.
[^tf-drift]: [Manage resource drift](https://developer.hashicorp.com/terraform/tutorials/state/resource-drift) — HashiCorp tutorial. Checked 2026-10-16.
[^tf-workspaces]: [Workspaces](https://developer.hashicorp.com/terraform/cli/workspaces) — HashiCorp. Checked 2026-10-16.
[^tf-validate]: [Command: validate](https://developer.hashicorp.com/terraform/cli/commands/validate) — HashiCorp. Checked 2026-10-16.
[^tf-init]: [Command: init](https://developer.hashicorp.com/terraform/cli/commands/init) — HashiCorp. Checked 2026-10-16.
[^tf-modsrc]: [Module sources](https://developer.hashicorp.com/terraform/language/modules/sources) — HashiCorp. Checked 2026-10-16.
[^tf-remote-state]: [The terraform_remote_state data source](https://developer.hashicorp.com/terraform/language/state/remote-state-data) — HashiCorp. Checked 2026-10-17.
[^tf-state-push]: [Command: state push](https://developer.hashicorp.com/terraform/cli/commands/state/push) and [state pull](https://developer.hashicorp.com/terraform/cli/commands/state/pull) — HashiCorp. Checked 2026-10-17.
[^hcp-health]: [Health assessments](https://developer.hashicorp.com/terraform/cloud-docs/workspaces/health) — HashiCorp (HCP Terraform). Checked 2026-10-16.
[^tf-stacks]: [Stacks](https://developer.hashicorp.com/terraform/language/stacks) — HashiCorp (HCP Terraform). Checked 2026-10-16.
[^tf-mcp]: [Terraform MCP server](https://developer.hashicorp.com/terraform/mcp-server) — HashiCorp. Checked 2026-10-16.
[^tofu-encryption]: [State and plan encryption](https://opentofu.org/docs/language/state/encryption/) — OpenTofu (introduced in 1.7.0). Checked 2026-10-16.
[^aws-tags]: [Resource tagging guide](https://github.com/hashicorp/terraform-provider-aws/blob/main/website/docs/guides/resource-tagging.html.markdown) — Terraform AWS provider. Checked 2026-10-16.
[^aws-sq]: [aws_servicequotas_service_quota](https://github.com/hashicorp/terraform-provider-aws/blob/main/website/docs/r/servicequotas_service_quota.html.markdown) — Terraform AWS provider documentation. Checked 2026-10-17.
[^local-provider]: [resource_local_file.go](https://github.com/hashicorp/terraform-provider-local/blob/main/internal/provider/resource_local_file.go) — terraform-provider-local source (latest release v2.9.1). Checked 2026-10-16.
[^pulumi-state]: [State and backends](https://www.pulumi.com/docs/iac/concepts/state-and-backends/) — Pulumi. Checked 2026-10-16.
[^cfn-drift]: [Detect unmanaged configuration changes with drift detection](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-stack-drift.html) — AWS CloudFormation User Guide. Checked 2026-10-16.
[^ms-bicep-tf]: [Comparing Terraform and Bicep](https://learn.microsoft.com/en-us/azure/developer/terraform/comparing-terraform-and-bicep) — Microsoft Learn, updated 2025-06-17. Checked 2026-10-16.
[^cfn-changesets]: [Update stacks using change sets](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-changesets.html) and [Using drift-aware change sets](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/drift-aware-change-sets.html) — AWS CloudFormation User Guide. Checked 2026-10-16.
[^pulumi-cli]: [pulumi preview](https://www.pulumi.com/docs/iac/cli/commands/pulumi_preview/) and [pulumi refresh](https://www.pulumi.com/docs/iac/cli/commands/pulumi_refresh/) — Pulumi CLI reference. Checked 2026-10-16.
[^apache-licences]: LICENSE files of [pulumi/pulumi](https://github.com/pulumi/pulumi/blob/master/LICENSE) and [crossplane/crossplane](https://github.com/crossplane/crossplane/blob/main/LICENSE) (both Apache License 2.0) — GitHub. Checked 2026-10-17.
[^bicep-whatif]: [Bicep what-if](https://learn.microsoft.com/en-us/azure/azure-resource-manager/bicep/deploy-what-if) — Microsoft Learn, updated 2026-08-14. Checked 2026-10-16.
[^az-stacks]: [Deployment stacks](https://learn.microsoft.com/en-us/azure/azure-resource-manager/bicep/deployment-stacks) — Microsoft Learn, updated 2026-06-24. Checked 2026-10-17.
[^packer]: [Introduction to Packer](https://developer.hashicorp.com/packer/docs/intro) — HashiCorp. Checked 2026-10-16.
[^packer-ebs]: [Amazon EBS builder](https://developer.hashicorp.com/packer/integrations/hashicorp/amazon/latest/components/builder/ebs) (plugin v1.8.3), [packer block](https://developer.hashicorp.com/packer/docs/templates/hcl_templates/blocks/packer) (checked 2026-10-16), and the [timeadd](https://developer.hashicorp.com/packer/docs/templates/hcl_templates/functions/datetime/timeadd) and [timestamp](https://developer.hashicorp.com/packer/docs/templates/hcl_templates/functions/datetime/timestamp) functions (checked 2026-10-17) — HashiCorp.
[^hcp-packer]: [HCP Packer](https://developer.hashicorp.com/hcp/docs/packer) — HashiCorp. Checked 2026-10-16.
[^imagebuilder]: [What is Image Builder?](https://docs.aws.amazon.com/imagebuilder/latest/userguide/what-is-image-builder.html) — AWS. Checked 2026-10-16.
[^aws-imds]: [Use the Instance Metadata Service to access instance metadata](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/configuring-instance-metadata-service.html) — Amazon EC2 User Guide. Checked 2026-10-17.
[^ansible-check]: [Check mode and diff mode](https://docs.ansible.com/ansible/latest/playbook_guide/playbooks_checkmode.html) — Ansible documentation. Checked 2026-10-16.
[^ansible-glossary]: [Glossary: idempotency](https://docs.ansible.com/ansible/latest/reference_appendices/glossary.html) — Ansible documentation. Checked 2026-10-16.
[^opa-terraform]: [Terraform](https://www.openpolicyagent.org/docs/terraform) — Open Policy Agent documentation. Checked 2026-10-16.
[^conftest]: [Conftest](https://www.conftest.dev/) — Open Policy Agent project. Checked 2026-10-16.
[^checkov]: [What is Checkov?](https://www.checkov.io/1.Welcome/What%20is%20Checkov.html) — Checkov documentation. Checked 2026-10-16.
[^checkov-plan]: [Terraform plan scanning](https://www.checkov.io/7.Scan%20Examples/Terraform%20Plan%20Scanning.html) — Checkov documentation. Checked 2026-10-17.
[^tfsec]: [tfsec README: tfsec is now part of Trivy](https://github.com/aquasecurity/tfsec) — Aqua Security, GitHub. Checked 2026-10-16.
[^tflint]: [TFLint](https://github.com/terraform-linters/tflint) — terraform-linters, GitHub. Checked 2026-10-16.
[^terratest]: [Terratest](https://terratest.gruntwork.io/) — Gruntwork. Checked 2026-10-16.
[^infracost]: [Infracost documentation](https://www.infracost.io/docs/) — Infracost, undated. Checked 2026-10-16.
[^gcp-root]: [Best practices for root modules](https://docs.cloud.google.com/docs/terraform/best-practices/root-modules) — Google Cloud, updated 2026-10-13. Checked 2026-10-16.
[^gcp-ops]: [Best practices for Terraform operations](https://docs.cloud.google.com/docs/terraform/best-practices/operations) — Google Cloud, updated 2026-10-13. Checked 2026-10-16.
[^aws-rel08]: [REL08-BP04 Deploy using immutable infrastructure](https://docs.aws.amazon.com/wellarchitected/latest/reliability-pillar/rel_tracking_change_management_immutable_infrastructure.html) — AWS Well-Architected Framework. Checked 2026-10-16.
[^twelve-factor]: [X. Dev/prod parity](https://12factor.net/dev-prod-parity) — The Twelve-Factor App. Checked 2026-10-16.
[^slack]: [How We Use Terraform At Slack](https://slack.engineering/how-we-use-terraform-at-slack/) — Archie Gunasekara, Slack Engineering, 2022-10-25. Checked 2026-10-16.
[^nubank]: [Leveraging AWS CloudFormation to create an immutable infrastructure at Nubank](https://aws.amazon.com/blogs/mt/leveraging-immutable-infrastructure-nubank/) — AWS Cloud Operations Blog, 2019-10-09. Checked 2026-10-16.
[^s3-outage]: [Summary of the Amazon S3 Service Disruption in the Northern Virginia (US-EAST-1) Region](https://aws.amazon.com/message/41926/) — AWS, 2017. Checked 2026-10-16.
[^gh-oidc-ref]: [OpenID Connect reference](https://docs.github.com/en/actions/reference/security/oidc) — GitHub Docs. Checked 2026-10-17.
[^gh-env]: [Deployments and environments](https://docs.github.com/en/actions/reference/workflows-and-actions/deployments-and-environments) and [Managing environments for deployment](https://docs.github.com/en/actions/how-tos/deploy/configure-and-manage-deployments/manage-environments) (setting "Prevent self-review") — GitHub Docs. Checked 2026-10-17.
[^gh-artifacts]: [Downloading workflow artifacts](https://docs.github.com/en/actions/how-tos/manage-workflow-runs/download-workflow-artifacts) — GitHub Docs. Checked 2026-10-16.
[^gh-summary]: [Adding a job summary](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-commands) and [Reviewing deployments](https://docs.github.com/en/actions/how-tos/deploy/configure-and-manage-deployments/review-deployments) — GitHub Docs. Checked 2026-10-17.
[^gh-pr]: [Workflow syntax: permissions in a forked repository](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax) and [Events that trigger workflows: pull_request](https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows) — GitHub Docs. Checked 2026-10-17.
[^gh-concurrency]: [Control the concurrency of workflows and jobs](https://docs.github.com/en/actions/how-tos/write-workflows/choose-when-workflows-run/control-workflow-concurrency) (`queue: single` default, `queue: max`) — GitHub Docs. Checked 2026-10-17.
[^aws-sts]: [AssumeRoleWithWebIdentity](https://docs.aws.amazon.com/STS/latest/APIReference/API_AssumeRoleWithWebIdentity.html) — AWS Security Token Service API Reference. Checked 2026-10-17.
[^gh-actions-versions]: Releases of [checkout](https://github.com/actions/checkout/releases) v7.0.1, [setup-terraform](https://github.com/hashicorp/setup-terraform/releases) v4.0.1, [configure-aws-credentials](https://github.com/aws-actions/configure-aws-credentials/releases) v6.3.0, [upload-artifact](https://github.com/actions/upload-artifact/releases) v7.0.2, [download-artifact](https://github.com/actions/download-artifact/releases) v8.0.2 (checked 2026-10-16; its [action.yml](https://github.com/actions/download-artifact/blob/v8.0.2/action.yml) and [README](https://github.com/actions/download-artifact/blob/v8.0.2/README.md) on `run-id`, `github-token` and `actions:read`, checked 2026-10-17) — GitHub.

### Changelog

- 2026-10-16: first edition. Lab 3's plan gate executed against a synthetic plan file; HCL examples written against current
  documentation but not executed; attributed quotations matched against raw source text.
- 2026-10-17, review round 1: pipeline checks stated precisely (code compares post-merge with apply time; the approver compares
  pull request with post-merge), per-job concurrency, a plan role that never locks; new sections on sharing data between states and
  capacity as code; runbook, Lab 1, drift script, Packer date, licences and Bicep row corrected; OIDC basics linked to DevSecOps;
  figures narrowed for phones.
- 2026-10-17, post-score polish: OIDC example notes GitHub's immutable subjects for repositories created after 2026-07-15;
  `-target` paraphrase matched to its source; figure 4 fits phones and its last note no longer spills; one Terraform pin in the
  workflow; Lab 3 sample JSON compacted (re-run, same output); "Prevent self-review" sourced to its how-to page; a repeated
  import caveat and the duplicate prevent_destroy myth cut.
