What InfraOps is
Link to section “What InfraOps is”In 30 seconds
Link to section “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.
At a glance
Link to section “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; the host OS → SysOps; runbook automation → AutomationOps; continuous reconciliation → GitOps; the platform product → PlatformOps; CMDB process and change approval → ITOps |
| Prerequisites | Command line and Git; basic cloud or virtualisation concepts; delivery pipelines (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
Link to section “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”.[4] Configuration tools reduce the problem but, as Kief Morris noted in 2013, “there is considerable scope for configuration drift” even with them.[5] Four costs follow:
- Environments cannot be reproduced. DORA asks teams to keep “All cloud configuration files” in version control and to provision any environment automatically.[6]
- Changes cannot be reviewed before they happen. A console click has no diff and no reviewer. A plan does.
- Nobody knows what exists. Inventory lives in memory or in stale spreadsheets.
- Audit becomes archaeology. Control frameworks expect a documented baseline that “can be changed only through change control procedures”;[7] 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.[8]
| 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
Link to section “What InfraOps is not”- Not a tool. Terraform, OpenTofu, Pulumi, CloudFormation and Bicep are engines; the practice is the lifecycle around them.
- Not 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 adds an agent that pulls and continuously reconciles the declared state. A CI job that runs
terraform applyafter a merge is IaC with pull-request automation, not GitOps under the OpenGitOps principles. - Not SysOps or AutomationOps, which own the inside of a host and run-time remediation; InfraOps provisions and replaces the host. 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
Link to section “Where InfraOps comes from and where it sits”Origins and evolution
Link to section “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”[9] | Desired state and convergence: the core ideas of IaC |
| 2011 | AWS launches CloudFormation: a JSON template creates “an entire stack with one function call”[10] | 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”[4][5][11] | Replace instead of repair |
| 2014 | HashiCorp announces Terraform, a tool for building, combining and launching infrastructure safely[12] | 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[13][14] | The practice gets a canon |
| 2023 | HashiCorp moves future releases to the Business Source License (BSL 1.1); the community forks Terraform as OpenTofu[15][16] | Licensing becomes a selection criterion |
| 2025 | OpenTofu joins the CNCF Sandbox; Crossplane graduates in the CNCF[17][18] | Two engine lineages, plus controllers for cloud resources |
Glossary and mental model
Link to section “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”.[19] |
| 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.[20] |
| 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).[21] |
| STS | AWS Security Token Service: exchanges that token for temporary role credentials (AssumeRoleWithWebIdentity).[22] |
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.
Diagram source (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.[23] A clean plan proves that managed objects match the code; it says nothing about unmanaged ones. Those need the provider’s inventory (see assurance).
How InfraOps relates to other Ops
Link to section “How InfraOps relates to other Ops”| Neighbour | Shared ground | Its focus | InfraOps focus |
|---|---|---|---|
| CloudOps | Accounts, networks, guardrails | Which constructs to use and govern | Writing, changing and testing them as code |
| SysOps | Servers | Inside one host | Provisioning and replacing hosts; baking images |
| GitOps | Declarative definitions, drift | Agents that continuously reconcile | State, plans, modules; push-based IaC pipelines |
| PlatformOps | Provisioning | Self-service product, golden paths | The modules behind them |
| AutomationOps | Ansible, scripts | Run-time automation and its safety | Provisioning-time code and its lifecycle |
| KubernetesOps | Clusters | Operating the orchestrator | Creating clusters and their cloud dependencies |
| ITOps | Change, CMDB | Change enablement, CMDB as a process | Plans as change records; state as inventory input |
| DevSecOps, FinOps | Pipelines, tags | Security gates; cost decisions | Running their policies as plan checks |
Principles and operating model
Link to section “Principles and operating model”Charter and principles
Link to section “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.
- 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.
- Review the plan, not only the code. A one-line change to a module can replace a database; only the plan shows it.
- Apply what was reviewed. Applying one saved plan invalidates any other plan made against the same state,[24] so treat plans as perishable and recompute them after any other apply.
- Small states, small blast radius. Split state by lifecycle, owner and risk, not by convenience.
- Replace compute, protect data. Instances come from images; durable stores have explicit deletion protection.
- Pin everything that can change underneath you: engine, providers, modules and base images.
- 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
Link to section “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
Link to section “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.
Lifecycle: Day 0, Day 1, Day 2
Link to section “Lifecycle: Day 0, Day 1, Day 2”Diagram source (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.[25] - 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,[26] so plan upgrades as projects.
Operating model and governance
Link to section “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 and auditors need.
Architecture and workflow
Link to section “Architecture and workflow”Reference architecture
Link to section “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.
Diagram source (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[27] |
| Registries | A bad module or image version reaches every consumer that upgrades |
| Drift job | Reports, never applies, unless the judgement call 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)[18][28] replace push-based applies with continuous reconciliation (GitOps). Provider-native engines (CloudFormation, Bicep) have no state file of yours to protect, but serve one provider.
End-to-end workflow
Link to section “End-to-end workflow”Diagram source (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.[29] - 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
Link to section “Practices, frameworks and tools”Core practices and playbooks
Link to section “Core practices and playbooks”State design and blast radius
Link to section “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.[30] 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.[26] 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.[31]
Sharing data between states
Link to section “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.[32] 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).
Modules and versioning
Link to section “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=.[33] The lock file pins providers, but “Terraform does not remember version
selections for remote modules”, so module pins live in the code.[34] Breaking changes get a major version and an upgrade note.
Refactoring, importing and removing without surprises
Link to section “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[35] |
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[36][37] |
Generated code needs review; Google advises avoiding imports where possible, because provenance is hard to understand[38] |
| Stop managing without deleting | removed block with destroy = false (v1.7+)[39][37] |
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”[40]; 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[41] |
Drift: detect, classify, resolve
Link to section “Drift: detect, classify, resolve”Run a scheduled plan per root module with -detailed-exitcode (exit code 2 = changes present).[41] A refresh-only plan shows
only what changed outside the tool; apply -refresh-only accepts reality into state without touching infrastructure.[23]
Classify before you act.
Diagram source (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;[42] Bicep’s what-if warns that properties can be “incorrectly reported as
deleted”.[43] Budget triage time for false positives.
Golden images and immutable compute
Link to section “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”.[44] 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.[45] Configuration management still serves inside images and on long-lived hosts (AutomationOps).
Environment parity
Link to section “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”.[46] 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
Link to section “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
Link to section “Methods, standards and frameworks”| Source | Type | What it asks of InfraOps |
|---|---|---|
| NIST SP 800-53 Rev. 5, CM family[47] | 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[45] | Vendor best practice | Deploy by replacement; detect and remediate drift |
| DORA version control[6] | Research-based recommendation | All infrastructure configuration versioned; automated environments |
| Google Cloud Terraform practices[30][38] | Vendor guidance | Small root modules, plan first, pipelines, version-pin reviews |
Tooling landscape
Link to section “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[48]); OpenTofu (v1.13.1, Mozilla Public License (MPL) 2.0, CNCF Sandbox[17])[49] | Licence, managed-service ecosystem, state encryption (OpenTofu since 1.7)[50] |
| General-purpose languages | Pulumi; AWS CDK (synthesises CloudFormation)[45] | Loops and tests in a familiar language; Pulumi Cloud is Pulumi’s default backend[51] |
| Provider-native engines | CloudFormation; Bicep/ARM | No state file to run (Bicep “doesn’t store state”[52]); one provider; drift and preview fidelity vary[42][43] |
| Continuous reconcilers | Crossplane; Flux tofu-controller | Needs Kubernetes; see GitOps |
| Images | Packer (v1.16.1); EC2 Image Builder (distributes only if tests pass[53]); HCP Packer (channels, ancestry, revocation[54]) | Test before publish; track ancestry; schedule rebuilds |
| Configuration management | Ansible, Puppet, Chef | Check mode is “just a simulation”;[55] keep inside hosts and images |
| Static checks | terraform validate, TFLint, Checkov, Trivy (tfsec now part of Trivy)[56][57][58][59] |
Fast; mostly code-level, so blind to the real change (Checkov can also scan plan JSON[60]) |
| Plan policy | OPA/Conftest, custom scripts on plan JSON[61][62] | Policies see the real change, not just the code |
| Tests | terraform test (v1.6+, mocks v1.7+), Terratest[63][64] |
Apply-mode tests create real, billable resources |
| Cost in review | Infracost and similar[65] | Pricing estimates, not bills; owned by FinOps |
Automation and AI
Link to section “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.[66] Generated code passes the same review and gates, and agents hold no apply credentials.
InfraOps in practice
Link to section “InfraOps in practice”Reference implementation
Link to section “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 first and return here.
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.
# 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;[67] the apply job re-plans and applies only if the list still matches. The download step reads this run’s summary by default,[68] 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,[24] and repository readers can download artifacts.[69] 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.[70]
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;[68] pin full commit SHAs in production.
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; three points are IaC-specific.
- Any branch author can use the plan role. It trusts pull requests and
main. Forks get noid-token: writeunless an administrator allows write tokens for them, but apull_requestrun executes the merge commit, edited workflow included,[71] so anyone who can push a branch can run any command with the role. Plans run code anyway: data sources are read during planning,[72] andexternalruns a program[73] (this page’s inference). - So the plan role only reads, but that includes every secret in state.[74] It never locks, so it cannot delete a
lock file: plans use
-lock=false, which Terraform calls dangerous when others run commands concurrently;[41] 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”, amain-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.[75]
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;[27] 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.[21]
{
"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"
}
}
}
]
}
{
"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.
#!/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)[76] 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;[77] confirm the current Ubuntu image name pattern with your provider.
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
Link to section “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
Link to section “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/localprovider, an empty directory. 20 minutes. Status: not executed by the author.
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"
}
- Save the block as
main.tfand runterraform init; the new.terraform.lock.hclwould be committed.[34] terraform plan -out=tfplan, read it, thenterraform apply tfplan. Passing the saved plan is the approval.[78]- Inspect the copies:
cat out/motd.txt(reality),terraform state show local_file.motd(state),main.tf(code). - Create drift:
echo "edited by hand" >> out/motd.txt. - Run
terraform plan -refresh-onlyand read it, thenterraform plan -detailed-exitcode; echo "exit=$?". - Decide: code wins (leave
main.tfas it is), or reality wins (editcontentinmain.tfto the file’s new text). Either way, runterraform apply, then plan again.
- Expected output: step 2 plans one resource to add. In step 5 the normal plan exits 2 (changes present).[41] This provider drops a file from state when its checksum differs,[79] 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-exitcodeexits 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, includingterraform.tfstate*(never commit these).
Lab 2 (intermediate): a tested module and a safe rename
Link to section “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:
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:
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]
}
- In
modules/motd, runterraform initandterraform test. Both runs usecommand = plan, so nothing is created; the second passes because validation fails as expected.[63] - In the parent directory, add a
module "motd"block withsource = "./modules/motd"andowner = "payments"; init and apply. - Rename the block to
module "banner"and plan, without applying: one file destroyed, one created. - Add a
movedblock withfrom = module.motdandto = module.banner, and plan again.[35]
- 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 listshowsmodule.banner.local_file.motd. - Troubleshooting: if no tests are found, check the
.tftest.hclsuffix and thetests/directory location. - Teardown:
terraform destroy; delete the directory.
Lab 3 (advanced): a plan gate on machine-readable plans
Link to section “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;[80] not yet run against real Terraform output.
Example status: executed (Python 3, synthetic input).
#!/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:
{
"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": {}}
}
]
}
python3 plan_gate.py sample-plan.json; echo "exit=$?"python3 plan_gate.py sample-plan.json --allow aws_db_instance.main --required-tags owner --summary-out approved.json; echo "exit=$?"python3 plan_gate.py sample-plan.json --allow aws_db_instance.main --required-tags owner --max-changes 2; echo "exit=$?"- With a real plan:
terraform plan -out=tfplan && terraform show -json tfplan > plan.json, then run step 1 onplan.json.
- Expected output (as executed): step 1 prints
changes=3 {"create": 2, "replace": 1} drifted_objects=1, blocks the database replacement and the instance missingcost-centre, and exits 1; the SQS queue passes because the AWS provider merges default tags intotags_all.[81] Step 2 prints only the summary and exits 0. Step 3 reports the blast-radius limit of 2 and exits 1. - Validation:
approved.jsonlists 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 fromafter, 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.jsoncontains state data, so never commit or upload it.
Use cases and scenarios
Link to section “Use cases and scenarios”| Scenario | Context and method | Trade-offs and pitfalls | Success measure |
|---|---|---|---|
| Slack: state at scale (sourced, 2022)[26] | 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)[82] | 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) | Coverage 0 → 80% in a quarter; no console writes outside break-glass |
Measure, secure and pay
Link to section “Measure, secure and pay”Metrics and KPIs
Link to section “Metrics and KPIs”These metrics are this Atlas’s proposal; no standard defines them. Infrastructure applies also count towards DORA’s delivery metrics (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
Link to section “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.[74] Restrict readers, encrypt, version, and keep secrets out: ephemeral resources (Terraform 1.10) and write-only arguments (1.11) are not persisted;[37] OpenTofu (1.7+) encrypts state and plan files, but “cannot read your state file without the correct key”.[50]
- Supply chain: commit
.terraform.lock.hcl(provider hashes);[34] 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.[47] Obligations vary by jurisdiction and contract (not legal advice). Evidence collection: ComplianceOps; security policy: CloudSecOps, DevSecOps.
Cost, capacity and sustainability
Link to section “Cost, capacity and sustainability”Tests: apply-mode tests and Terratest create billable resources;[64] 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. 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, quota policy to
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.[83] Review a lowered maximum like a delete: it removes headroom.
Assurance of the operating model
Link to section “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
Link to section “When things go wrong”Common failures and troubleshooting
Link to section “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[29] | 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) |
| 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[84] |
Bucket versioning;[27] terraform state commands only, never hand edits[38] |
| 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.[85] A pipeline that can delete a hundred resources in one apply needs the same limit, as in the plan gate.
Worked runbook: a plan that wants to replace the production database
Link to section “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).
- Stop. Nothing was applied; the gate worked. Do not allow-list the address to “get it through”.
- Read the shape. A delete and a create at different addresses is a rename without
moved: Terraform deletes the object at the old address.[35] A["delete", "create"]pair at one address is a forced replacement (BLOCK replace), and the plan marks the cause# forces replacement.[41] - Fix the rename with a
movedblock fromaws_db_instance.dbtoaws_db_instance.main; the plan should show a move and zero destroys.[35] - Check the upgrade. If a
replaceofaws_db_instance.mainnow appears, the provider upgrade forces it: revert it, read its upgrade guide, and upgrade in a separate pull request. - 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.
- Verify that the final plan has no destroy or replace of a protected type, and get the summary approved.
- Follow up: add
prevent_destroyand provider deletion protection; move the database to its own state if it shares one.
Anti-patterns and myths
Link to section “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_changesas 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
Link to section “Deciding and adopting”When to use and when not to use
Link to section “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
Link to section “Decision framework”Diagram source (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),[48][17] language, where state lives, managed services and hiring. Pick one default engine; allow exceptions by decision record.
Maturity model
Link to section “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
Link to section “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
Link to section “Toolkit”Checklists
Link to section “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
-
CODEOWNERSand 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)
Templates
Link to section “Templates”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
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>
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
Link to section “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[80] |
Plan or refresh-only plan; HCP Terraform health assessments in Standard and Premium editions[86] | BSL 1.1[48] |
| OpenTofu | Same model; optional state encryption | plan |
Same model as Terraform | MPL 2.0[17] |
| Pulumi | Pulumi Cloud by default, or self-managed[51] | preview[87] |
refresh re-reads the stack’s resources[87] |
Apache 2.0 engine and SDKs;[88] Pulumi Cloud is a separate hosted application[51] |
| CloudFormation | AWS-managed | Change sets; drift-aware ones compare actual, previous and desired state[89] | Built-in; explicitly set properties only[42] | AWS service |
| Bicep / ARM | None; incremental deployments.[52] Deployment stacks add managed-resource tracking, deny settings and detach or delete on removal[90] | What-if, with noise[43] | What-if comparisons | Azure service |
| Crossplane | Kubernetes API objects | Controller-dependent | Continuous reconciliation[18] | Apache 2.0[88] |
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,[68] 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.
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.[50] Re-check release notes; both move quickly.
Is a CI job that runs terraform apply after merge “GitOps”?
No: the OpenGitOps principles require an agent that pulls and continuously reconciles. GitOps explains when a reconciler is worth it.
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.[38]
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”.[44]
Going further
Link to section “Going further”Advanced topics and emerging direction
Link to section “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.[91]
- Controllers for cloud resources. Crossplane and tofu-controller bring continuous reconciliation, or detection only, to infrastructure.[18][28]
- AI-assisted IaC. Review, policy and separate apply identities matter more, not less; autonomous infrastructure agents are vendor direction, not established practice.
Learning path
Link to section “Learning path”| Stage | Do this |
|---|---|
| Prerequisites | Git, a shell, one cloud (CloudOps), Linux hosts (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), reconcilers (GitOps), platform modules (PlatformOps), cost in review (FinOps) |
| Portfolio | A public repository with modules, tests, a pipeline that plans on pull requests, and a written drift policy |
Wrap-up
Link to section “Wrap-up”Executive recap and cheat sheet
Link to section “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?
Link to section “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[45] | 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); 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,[28] HCP Terraform health assessments detect without remediating,[86] and CloudFormation’s drift-aware change sets show whether a deployment would revert out-of-band changes made during incident response.[89] 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
Link to section “Learning objectives and mastery check”After this page you should be able to:
- Explain the three copies of the truth and name the gap behind a given symptom.
- Design a state layout and a plan-review-apply pipeline with separate, exactly scoped identities.
- Operate drift detection and classify each difference before acting.
- Refactor safely with
moved,importandremoved, and protect durable data. - Measure coverage, drift and image age, and decide what to fix first.
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).[23] Find it through the provider inventory, decide whether to import, hand over or delete it, and check audit logs for who created it.
2. Why must the plan role be treated as sensitive if it cannot change anything?
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.[35]
Keep the block, because removing it later breaks consumers still on the old address.
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.[40] Use removed with destroy = false
if the database should leave management, and keep the provider’s deletion protection on.
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),[24] 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
Link to section “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
Link to section “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.
Changelog
Link to section “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;
-targetparaphrase 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.
Next-Generation InfraOps — Info-Tech Research Group, undated. Checked 2026-10-16. ↩︎
InfraOps Engineer (Nubank, Mexico City; closed posting) — Base10 careers board, undated. Checked 2026-10-16. ↩︎
Skyline Communications launches InfraOps — Satellite Markets, 2025-10-01. Checked 2026-10-16. ↩︎
SnowflakeServer (checked 2026-10-16) and PhoenixServer (checked 2026-10-17) — Martin Fowler, both 2012-07-10. ↩︎ ↩︎
ImmutableServer — Kief Morris, martinfowler.com, 2013-06-13. Checked 2026-10-16. ↩︎ ↩︎
Capabilities: Version control — DORA, updated 2026-01-12. Checked 2026-10-16. ↩︎ ↩︎
Glossary: baseline configuration — NIST CSRC (from SP 800-128 and SP 800-53 Rev. 5). Checked 2026-10-16. ↩︎
The I&O Organization Under Pressure — Charles Betz, Forrester, 2020-01-24. Checked 2026-10-16. ↩︎
Cfengine: some facts — Mark Burgess, USENIX Configuration Management Summit, 2010. Checked 2026-10-16. ↩︎
AWS CloudFormation – Create Your AWS Stack From a Recipe — Jeff Barr, AWS, 2011-02-25. Checked 2026-10-16. ↩︎
Trash Your Servers and Burn Your Code: Immutable Infrastructure and Disposable Components — Chad Fowler, 2013-06-23. Checked 2026-10-16. ↩︎
HashiCorp Terraform — HashiCorp, 2014-07-28. Checked 2026-10-16. ↩︎
InfrastructureAsCode — Martin Fowler, 2016-03-01. Checked 2026-10-16. ↩︎
Infrastructure as Code (book) — Kief Morris, O’Reilly, third edition. Checked 2026-10-16. ↩︎
HashiCorp adopts the Business Source License — Armon Dadgar, HashiCorp, 2023-08-10. Checked 2026-10-16. ↩︎
OpenTofu Announces Fork of Terraform — OpenTofu, 2023-08-25. Checked 2026-10-16. ↩︎
OpenTofu project page — CNCF, Sandbox since 2025-04-23; licence: MPL 2.0 LICENSE. Checked 2026-10-16. ↩︎ ↩︎ ↩︎ ↩︎
CNCF Announces Graduation of Crossplane — CNCF, 2025-11-06 (project page: graduated 2025-10-28). Checked 2026-10-16. ↩︎ ↩︎ ↩︎ ↩︎
Purpose of Terraform State — HashiCorp. Checked 2026-10-16. ↩︎
Glossary: idempotency — Ansible documentation. Checked 2026-10-16. ↩︎
OpenID Connect reference — GitHub Docs. Checked 2026-10-17. ↩︎ ↩︎
AssumeRoleWithWebIdentity — AWS Security Token Service API Reference. Checked 2026-10-17. ↩︎
Manage resource drift — HashiCorp tutorial. Checked 2026-10-16. ↩︎ ↩︎ ↩︎
Running Terraform in automation — HashiCorp tutorial. Checked 2026-10-16. ↩︎ ↩︎ ↩︎
Command: init — HashiCorp. Checked 2026-10-16. ↩︎
How We Use Terraform At Slack — Archie Gunasekara, Slack Engineering, 2022-10-25. Checked 2026-10-16. ↩︎ ↩︎ ↩︎
Backend type: s3 — HashiCorp. Checked 2026-10-16. ↩︎ ↩︎ ↩︎
tofu-controller — flux-iac, GitHub, release v0.16.5. Checked 2026-10-16. ↩︎ ↩︎ ↩︎
State locking and force-unlock — HashiCorp. Checked 2026-10-16. ↩︎ ↩︎
Best practices for root modules — Google Cloud, updated 2026-10-13. Checked 2026-10-16. ↩︎ ↩︎
Workspaces — HashiCorp. Checked 2026-10-16. ↩︎
The terraform_remote_state data source — HashiCorp. Checked 2026-10-17. ↩︎
Module sources — HashiCorp. Checked 2026-10-16. ↩︎
Dependency lock file — HashiCorp. Checked 2026-10-16. ↩︎ ↩︎ ↩︎
Refactoring with moved blocks — HashiCorp. Checked 2026-10-16. ↩︎ ↩︎ ↩︎ ↩︎ ↩︎
import block and Import existing resources — HashiCorp. Checked 2026-10-16. ↩︎
Terraform changelogs for 1.5, 1.7, 1.10 and 1.11 — HashiCorp, 2023–2025. Checked 2026-10-16. ↩︎ ↩︎ ↩︎
Best practices for Terraform operations — Google Cloud, updated 2026-10-13. Checked 2026-10-16. ↩︎ ↩︎ ↩︎ ↩︎
removed block — HashiCorp. Checked 2026-10-16. ↩︎
The lifecycle meta-argument — HashiCorp. Checked 2026-10-16. ↩︎ ↩︎
Command: plan — HashiCorp. Checked 2026-10-17. ↩︎ ↩︎ ↩︎ ↩︎ ↩︎
Detect unmanaged configuration changes with drift detection — AWS CloudFormation User Guide. Checked 2026-10-16. ↩︎ ↩︎ ↩︎
Bicep what-if — Microsoft Learn, updated 2026-08-14. Checked 2026-10-16. ↩︎ ↩︎ ↩︎
Introduction to Packer — HashiCorp. Checked 2026-10-16. ↩︎ ↩︎
REL08-BP04 Deploy using immutable infrastructure — AWS Well-Architected Framework. Checked 2026-10-16. ↩︎ ↩︎ ↩︎ ↩︎
X. Dev/prod parity — The Twelve-Factor App. Checked 2026-10-16. ↩︎
SP 800-53 Rev. 5, Security and Privacy Controls — NIST, release 5.2.0 (2025-08-27); control text read from the OSCAL catalogue. Checked 2026-10-16. ↩︎ ↩︎
Terraform LICENSE (BSL 1.1, version 1.6.0 or later) — HashiCorp/IBM, GitHub. Checked 2026-10-16. ↩︎ ↩︎ ↩︎
Release pages for Terraform v1.16.6 (2026-10-14), OpenTofu v1.13.1 (2026-10-01) and Packer v1.16.1 (2026-09-18) — GitHub. Checked 2026-10-16. ↩︎
State and plan encryption — OpenTofu (introduced in 1.7.0). Checked 2026-10-16. ↩︎ ↩︎ ↩︎
State and backends — Pulumi. Checked 2026-10-16. ↩︎ ↩︎ ↩︎
Comparing Terraform and Bicep — Microsoft Learn, updated 2025-06-17. Checked 2026-10-16. ↩︎ ↩︎
What is Image Builder? — AWS. Checked 2026-10-16. ↩︎
HCP Packer — HashiCorp. Checked 2026-10-16. ↩︎
Check mode and diff mode — Ansible documentation. Checked 2026-10-16. ↩︎
Command: validate — HashiCorp. Checked 2026-10-16. ↩︎
What is Checkov? — Checkov documentation. Checked 2026-10-16. ↩︎
tfsec README: tfsec is now part of Trivy — Aqua Security, GitHub. Checked 2026-10-16. ↩︎
Terraform plan scanning — Checkov documentation. Checked 2026-10-17. ↩︎
Terraform — Open Policy Agent documentation. Checked 2026-10-16. ↩︎
Conftest — Open Policy Agent project. Checked 2026-10-16. ↩︎
Infracost documentation — Infracost, undated. Checked 2026-10-16. ↩︎
Terraform MCP server — HashiCorp. Checked 2026-10-16. ↩︎
Adding a job summary and Reviewing deployments — GitHub Docs. Checked 2026-10-17. ↩︎
Releases of checkout v7.0.1, setup-terraform v4.0.1, configure-aws-credentials v6.3.0, upload-artifact v7.0.2, download-artifact v8.0.2 (checked 2026-10-16; its action.yml and README on
run-id,github-tokenandactions:read, checked 2026-10-17) — GitHub. ↩︎ ↩︎ ↩︎Downloading workflow artifacts — GitHub Docs. Checked 2026-10-16. ↩︎
Control the concurrency of workflows and jobs (
queue: singledefault,queue: max) — GitHub Docs. Checked 2026-10-17. ↩︎Workflow syntax: permissions in a forked repository and Events that trigger workflows: pull_request — GitHub Docs. Checked 2026-10-17. ↩︎
Data sources — HashiCorp. Checked 2026-10-16. ↩︎ ↩︎
external data source — HashiCorp, terraform-provider-external. Checked 2026-10-16. ↩︎ ↩︎
Sensitive data in state — HashiCorp. Checked 2026-10-16. ↩︎ ↩︎ ↩︎
Deployments and environments and Managing environments for deployment (setting “Prevent self-review”) — GitHub Docs. Checked 2026-10-17. ↩︎
Use the Instance Metadata Service to access instance metadata — Amazon EC2 User Guide. Checked 2026-10-17. ↩︎
Amazon EBS builder (plugin v1.8.3), packer block (checked 2026-10-16), and the timeadd and timestamp functions (checked 2026-10-17) — HashiCorp. ↩︎
Command: apply — HashiCorp. Checked 2026-10-16. ↩︎
resource_local_file.go — terraform-provider-local source (latest release v2.9.1). Checked 2026-10-16. ↩︎
JSON output format — HashiCorp. Checked 2026-10-16. ↩︎ ↩︎
Resource tagging guide — Terraform AWS provider. Checked 2026-10-16. ↩︎
Leveraging AWS CloudFormation to create an immutable infrastructure at Nubank — AWS Cloud Operations Blog, 2019-10-09. Checked 2026-10-16. ↩︎
aws_servicequotas_service_quota — Terraform AWS provider documentation. Checked 2026-10-17. ↩︎
Command: state push and state pull — HashiCorp. Checked 2026-10-17. ↩︎
Summary of the Amazon S3 Service Disruption in the Northern Virginia (US-EAST-1) Region — AWS, 2017. Checked 2026-10-16. ↩︎
Health assessments — HashiCorp (HCP Terraform). Checked 2026-10-16. ↩︎ ↩︎
pulumi preview and pulumi refresh — Pulumi CLI reference. Checked 2026-10-16. ↩︎ ↩︎
LICENSE files of pulumi/pulumi and crossplane/crossplane (both Apache License 2.0) — GitHub. Checked 2026-10-17. ↩︎ ↩︎
Update stacks using change sets and Using drift-aware change sets — AWS CloudFormation User Guide. Checked 2026-10-16. ↩︎ ↩︎
Deployment stacks — Microsoft Learn, updated 2026-06-24. Checked 2026-10-17. ↩︎