IaC Principles and Tool Landscape
The properties that separate healthy infrastructure-as-code from a pile of scripts, and how the major tool categories differ in practice.
Infrastructure as code means the definition of your infrastructure lives in version control and is applied by tooling rather than by hand. That much is nearly universal now. The difference between an estate that is a genuine asset and one that is a liability comes down to four properties.
The four properties that matter
Declarative over imperative. Describe the desired end state, not the steps to reach it. Imperative scripts encode an assumed starting point; when reality differs, they fail or — worse — succeed while producing something unintended.
flowchart LR
subgraph "Imperative"
I1[create vpc] --> I2[create subnet] --> I3[create gateway]
I3 --> I4["Fails if the VPC already exists.<br/>Requires you to know current state."]
end
subgraph "Declarative"
D1["desired: vpc + subnet + gateway"] --> D2[Tool computes the diff<br/>from actual to desired]
D2 --> D3["Same definition works whether<br/>nothing, some, or all of it exists"]
end
Idempotent. Applying the same definition twice produces the same result and no side effects on the second run. This is what makes automated reconciliation safe — and it is a property you must verify, not assume, particularly with provisioners and shell escape hatches.
Versioned. Every change is a reviewable commit with an author, a rationale, and a revert path. Infrastructure changes get the same review as application code, because their blast radius is usually larger.
Reproducible. The same definition and the same inputs produce the same infrastructure, in a new region or a new account, without undocumented manual steps. Reproducibility is what makes disaster recovery and environment provisioning tractable; it is also the property most often broken by one manual step nobody wrote down.
The tool landscape
The categories matter more than the individual products, because they answer different questions.
| Category | Question it answers | Representative tools | Model |
|---|---|---|---|
| Provisioning | What cloud resources exist? | Terraform / OpenTofu, Pulumi, CloudFormation, Bicep, Crossplane | Declarative, state-tracking |
| Configuration management | What is installed and configured on this machine? | Ansible, Chef, Puppet, Salt | Convergent, agent or push |
| Image building | What does the base image contain? | Packer, Docker/BuildKit, bootc | Build-time, immutable output |
| Container orchestration | What workloads run, where, with what resources? | Kubernetes manifests, Helm, Kustomize | Declarative, continuously reconciled |
| Policy | Is this change allowed? | OPA/Rego, Sentinel, Kyverno, Checkov | Evaluated against a plan |
A modern estate typically uses several: image building to bake a golden image, provisioning to create the infrastructure that runs it, orchestration for workloads, and policy across all of them. Configuration management shrinks in importance as immutable infrastructure grows — see Drift, State, and Immutability.
Provisioning tool choice
The relevant axes when choosing, in rough order of practical impact:
- State model. Terraform-style tools keep an explicit state file mapping definitions to real resources; CloudFormation-style tools let the cloud provider hold state. Explicit state is more flexible and more dangerous — it can be corrupted, and it must be stored securely because it contains resource attributes including some secrets.
- Configuration language. A domain-specific language (HCL) constrains what you can express, which limits both damage and flexibility. A general-purpose language (Pulumi, CDK) gives you loops, types, and unit tests — along with the ability to write infrastructure code too clever for anyone to review.
- Multi-cloud reality. Cross-provider tools abstract the workflow, not the resources. You will still write provider-specific resource definitions. Choose for consistent workflow and ecosystem, not for a portability that does not exist.
- Ecosystem and provider coverage. In practice this decides more real migrations than any language preference.
Non-negotiable practices
Remote, locked state. State on a laptop is a single point of failure and a source of concurrent-apply corruption. Use remote backends with state locking, encryption at rest, and versioning. Restrict who can read it — state files contain resource attributes, including generated passwords.
Separate state per environment and per blast-radius boundary. One state file containing the whole estate means every change plans against everything, plans take twenty minutes, and one bad apply can affect production while you were changing dev.
flowchart TD
subgraph "Anti-pattern: monolithic state"
A1[(single state)] --- A2[dev + staging + prod<br/>network + data + apps]
A2 --> A3["20-minute plans<br/>Every change risks everything"]
end
subgraph "Recommended: sliced by lifecycle"
B1[(prod/network)] --- B2[(prod/data)] --- B3[(prod/apps)]
B4[(staging/…)] --- B5[(dev/…)]
B3 --> B6["Fast plans<br/>Contained blast radius<br/>Different change cadences"]
end
Slice along lines that change at different rates: networking changes rarely, and application infrastructure changes daily. Coupling them forces the careful thing to move at the speed of the fast thing.
Pin everything. Provider versions, module versions, and base image digests. Unpinned dependencies mean an apply you did not change can produce a diff you did not expect, from a provider release you did not know about.
Plan in CI, apply from CI. Humans should never run apply against production
from a laptop. The pipeline plans on pull request, posts the plan for review,
and applies on merge with credentials no human holds.
sequenceDiagram
participant D as Developer
participant CI as Pipeline
participant P as Policy engine
participant C as Cloud
D->>CI: open pull request
CI->>C: terraform plan (read-only role)
CI->>P: evaluate plan against policy
P-->>CI: pass / fail with reasons
CI-->>D: post plan + policy result as PR comment
D->>CI: merge after review
CI->>C: terraform apply (write role, OIDC, short-lived)
CI-->>D: report applied changes
Never edit infrastructure through the console. Console access for humans should be read-only in production. Every exception erodes the guarantee that the repository describes reality, and each one is discovered later at a bad moment.
Secrets do not go in IaC
Infrastructure definitions are code and get committed. Secrets must come from a secret manager at apply time, referenced by identifier rather than value. Note that a secret injected at apply time may still land in the state file — which is one more reason state must be encrypted and access-controlled. See Secrets Management.
Adoption checklist
- All infrastructure is declarative and idempotent; imperative scripts are the exception and are documented.
- State is remote, locked, encrypted, versioned, and access-restricted.
- State is sliced by environment and lifecycle, not monolithic.
- Provider and module versions are pinned.
- Plans run on pull request; applies run only from CI using short-lived credentials.
- Human console access to production is read-only.
- No secret values appear in infrastructure definitions.
Last updated 19 Aug 2026, 00:00 UTC.