What Is Infrastructure as Code?
Why infrastructure is described in files instead of built by hand: declarative and imperative approaches, idempotency, drift, mutable and immutable infrastructure, and the main tool families.
What this topic covers
The Cloud topic introduces infrastructure as code in one page: why files beat clicking, the init, plan and apply workflow, state, modules and drift. Read that overview first if the idea is new. This topic goes deeper, with two tool families that cover most daily work:
- Terraform and OpenTofu (pages 3–8) create and change infrastructure: virtual machines, networks, DNS records, storage, cloud accounts.
- Ansible (pages 9–12) configures what runs on servers: packages, configuration files, users, scheduled jobs.
- The other pages cover the file formats both use, secrets, other tools, machine images and bootstrapping, testing and policy, pipelines and troubleshooting.
The running example is Kestrel Energy, a fictional Dutch energy
supplier: a customer portal on web01 and
web02, a meter-api, PostgreSQL on db01 and
a nightly billing export. Ravi Kumar, the portal tech lead, writes
the OpenTofu configuration; Hamid Rahimi, the database
administrator, owns the playbooks for db01; Yara
Haddad, the release manager, approves production applies in the
pipeline.
About the recorded output
Output labeled Recorded output on these pages was produced
for real with OpenTofu 1.13.1 and ansible-core 2.19, against local
files instead of real servers or clouds. The project folder appears
as /home/amara/kestrel-infra. Output that needs a real
cloud, real servers or a tool that was not installed is labeled
illustrative: written from the official documentation, but
not captured from a run. As of October 2026 the current releases are
OpenTofu 1.13.1, Terraform 1.16.5 and ansible-core 2.21; the
recordings use ansible-core 2.19 because 2.20 and later need Python
3.12 or newer on the machine that runs Ansible.
From hand work to code
Picture Kestrel Energy before IaC. The two portal servers were set
up from a wiki page, years apart, by different people. During an
incident last winter someone raised a connection limit on
web02 and installed a debugging tool, straight on the
server. Nobody wrote it down. Today web01 and
web02 are supposed to be identical, and they are not.
A server like web02 is a
snowflake server: unique,
built and repaired by hand, and impossible to reproduce exactly
because nobody knows everything that was done to it. Snowflakes are
fragile in a very practical way: when one dies, rebuilding it is
archaeology, and when a problem occurs on only one of two
"identical" servers, the difference is the first suspect and the
hardest to find.
Infrastructure as code
(IaC) turns that around. The server is described in text files —
which packages, which configuration, which users, which firewall
rules — and a tool applies the description. The files live in
version control, so every change is a reviewable diff with an author and a reason,
and the same files build web01, web02 and
next year's web03.
Desired state and convergence
Most IaC tools work with a
desired state: a description of
how things should be, not of the steps to get there. "Both web
servers have worker_connections 2048 and a maintenance
page." The tool reads the actual state, compares, and changes only
what differs. Bringing the actual state to the desired state is
called converging.
The core of that loop fits in a few lines of Python. The program
below is not how any real tool is written, but it behaves the same
way: the first run repairs web02, the second run finds
nothing to do.
desired = {
"web01": {"worker_connections": 2048, "maintenance_page": True},
"web02": {"worker_connections": 2048, "maintenance_page": True},
}
actual = {
"web01": {"worker_connections": 2048, "maintenance_page": True},
# Changed by hand during an incident, and the page was never added:
"web02": {"worker_connections": 4096},
}
def converge(desired, actual):
"""Make actual match desired; return the list of changes made."""
changes = []
for host in sorted(desired):
have = actual.setdefault(host, {})
for setting, want in sorted(desired[host].items()):
if have.get(setting) != want:
changes.append(f"{host}: {setting} {have.get(setting)!r} -> {want!r}")
have[setting] = want
return changes
for run in (1, 2):
changes = converge(desired, actual)
print(f"run {run}: changed={len(changes)}")
for change in changes:
print(" ", change)
run 1: changed=2
web02: maintenance_page None -> True
web02: worker_connections 4096 -> 2048
run 2: changed=0
Two details matter in practice. First, a tool can only converge what
it knows about: the debugging tool someone installed on
web02 is not in the desired state, so this loop never
notices it. Real tools are the same — Ansible manages only what its
tasks mention, while Terraform and OpenTofu manage only resources
recorded in their state. Second, when the loop runs differs
per tool. Kubernetes
controllers
and
GitOps
agents run a
reconciliation loop
continuously. Terraform, OpenTofu and Ansible converge only when
someone or something runs them; between runs, reality can wander
off.
Declarative and imperative
The Cloud page explains the difference between a declarative configuration (the end state) and an imperative script (the steps). In IaC terms, an imperative configuration is a list of commands that change a system — "append this line", "create this user", "start this service" — which does exactly that each time it runs, whether or not the result is already there. The contrast in miniature:
upstream = ["server 192.0.2.11:8080;"]
def add_line(lines, line):
"""Imperative: one step, done every time it runs."""
lines.append(line)
def ensure_line(lines, line):
"""Declarative: describe the result; act only if it is missing."""
if line in lines:
return "ok"
lines.append(line)
return "changed"
imperative = list(upstream)
for run in (1, 2):
add_line(imperative, "server 192.0.2.12:8080;")
print("imperative after 2 runs:", imperative)
declarative = list(upstream)
for run in (1, 2):
result = ensure_line(declarative, "server 192.0.2.12:8080;")
print(f"run {run}: {result}")
print("declarative after 2 runs:", declarative)
imperative after 2 runs: ['server 192.0.2.11:8080;', 'server 192.0.2.12:8080;', 'server 192.0.2.12:8080;'] run 1: changed run 2: ok declarative after 2 runs: ['server 192.0.2.11:8080;', 'server 192.0.2.12:8080;']
Real tools sit at different points between the two ends:
- Terraform and OpenTofu are fully declarative. You describe resources; the order of execution comes from the references between them (the dependency graph), not from the order in the files.
-
Ansible is procedural with declarative building
blocks. Tasks run in the order you write them, so order matters,
but most modules describe a state:
state: present,state: directory, "this line exists in this file". The exceptions are thecommandandshellmodules, which run a command and report changed every time unless you tell Ansible how to know better (see Playbooks and Idempotency). - Shell and PowerShell scripts are imperative, but can be written to check before they act — an idempotent script.
Idempotency in practice
Idempotence is the property of an
operation that gives the same result whether it runs once or many
times: a run that changes nothing the second time is idempotent. It
is what makes IaC safe to run again and again: on a schedule, after
a merge, or just to check. Both tool families show it plainly. Here
is the second tofu plan after the first configuration
on
Terraform and OpenTofu: First Steps
was applied:
$ tofu plan
random_id.deployment: Refreshing state... [id=CoDo_w] random_password.db: Refreshing state... [id=none] local_file.upstream: Refreshing state... [id=577cb8ce792092da6359d85e27648b4f11d5a85a] No changes. Your infrastructure matches the configuration.
And the summary of the same Ansible playbook run twice against
web01, web02 and db01 (the
full run is on
Playbooks and Idempotency):
$ ansible-playbook site.yml
$ ansible-playbook site.yml
… PLAY RECAP ********************************************************************* db01 : ok=2 changed=2 unreachable=0 failed=0 skipped=1 rescued=0 ignored=0 web01 : ok=5 changed=4 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 web02 : ok=5 changed=4 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 … PLAY RECAP ********************************************************************* db01 : ok=2 changed=0 unreachable=0 failed=0 skipped=1 rescued=0 ignored=0 web01 : ok=4 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 web02 : ok=4 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
The second run reports changed=0 on every host. That
line is worth watching: a playbook that reports changes on every run
either has a task that is not idempotent, or something keeps
changing the servers behind its back.
Provisioning and configuration management
IaC work falls into two jobs that are easy to blur:
- Provisioning creates the infrastructure itself: the virtual machine, its disk and network interface, the subnet, the firewall rules, the DNS record, the managed database. It talks to the APIs of a cloud or a hypervisor. Terraform and OpenTofu are the typical provisioning tools. (In identity management, "provisioning" means creating user accounts — a different meaning.)
- Configuration management, in the IaC sense, keeps the software inside existing machines in the desired state: installed packages, configuration files, services, users, scheduled jobs. It talks to the operating system, usually over SSH. Ansible, Puppet, Chef and Salt are configuration management tools.
For Kestrel, OpenTofu creates web01,
web02 and db01 and their network; Ansible
then installs nginx and PostgreSQL on them and puts the nightly
billing export in place as a cron entry. The line is not sharp —
Terraform can hand a new server a script to run at first boot, and
Ansible has modules that create cloud resources — but using each
tool for its own job keeps both simpler.
Push and pull
Configuration management tools reach the servers in one of two ways. Ansible is agentless and push-based: a control machine connects to each server over SSH (or WinRM for Windows) when you run a playbook, and nothing Ansible-specific is installed on the servers. Puppet, Chef and Salt traditionally install an agent on every server that regularly pulls its desired state from a central server and applies it, so drift is corrected without anyone pressing a button (Salt can also work without agents). The Network Automation page compares them briefly, and Other Infrastructure as Code Tools in more detail.
Mutable and immutable infrastructure
There are two ways to bring a change to a running server.
With
mutable infrastructure, servers are long-lived and changed in place: a playbook upgrades
nginx on web01, edits its configuration and reloads it.
This is how most organizations run their virtual machines, and
configuration management tools are made for it. The risk is
accumulation: every in-place change, and every failed half-change,
adds to the server's history, and over months servers drift apart
even when the code says they are the same.
With immutable infrastructure, a running server is never changed. A change means building a new machine image (or container image) with the change in it, starting new servers from that image, moving traffic to them and deleting the old ones. Every server of a version is identical because it came from the same image, and rolling back means starting the previous image. Containers are the everyday form of this idea: you do not patch a running container, you replace it (see What Are Containers?).
| Mutable | Immutable | |
|---|---|---|
| A change means | Updating the running server | Building a new image and replacing the server |
| Typical tools | Ansible, Puppet, Chef, Salt | Packer, cloud-init, containers, Terraform/OpenTofu |
| Strength | Fast small changes; works with existing servers | No drift between servers; simple rollback |
| Weakness | Servers accumulate history and drift apart | Needs a build pipeline; data must live outside the server |
Most teams mix the two: a database server such as
db01, holding years of data, is usually managed in
place, while stateless web servers are easy to replace.
Images and Bootstrapping
shows how images are built.
Drift: when reality and code disagree
Configuration drift
is any difference between the code and reality caused by changes
outside the code: the connection limit raised on
web02, a firewall rule added in a cloud console during
an incident. Drift has two consequences. The code is no longer a
true description, so anyone reading it is misled. And the next run
puts the old value back — perhaps weeks later, by someone who knows
nothing of the incident, quietly undoing a fix that was keeping the
portal alive.
That is why IaC only works as the only normal way to make changes. Treating the first OpenTofu run or the first playbook as a one-off migration, after which people go back to logging in and editing, brings the snowflakes back within months. Emergencies still happen; the habit that keeps the code honest is to follow every emergency change made by hand with a pull request that puts the same change in the code, the same day. Changing Infrastructure Safely shows how OpenTofu detects drift, and Infrastructure as Code in Pipelines how a scheduled job can report it every night.
The tool landscape
"IaC tool" covers very different programs. It helps to sort them by the layer they work on, from the bare infrastructure up to the running application:
| Tool | Job | Model | Keeps state? |
|---|---|---|---|
| Terraform, OpenTofu | Provisioning, any platform with an API | Declarative, run on demand | Yes, a state file |
| AWS CloudFormation, Azure Bicep and ARM templates | Provisioning, one cloud only | Declarative, run on demand | CloudFormation: yes, the stack, kept by AWS. Bicep and ARM: no state file; Azure compares with the live resources |
| Pulumi, AWS CDK | Provisioning, written in a programming language | Program that produces a declarative description | Yes |
| Ansible | Configuration management, also orchestration | Procedural tasks with declarative modules; push over SSH | No: it inspects the servers each run |
| Puppet, Chef, Salt | Configuration management | Declarative or procedural; usually agents that pull | Desired state on a central server |
| Packer, cloud-init | Machine images and first boot | Build once (Packer) or run once at boot (cloud-init) | No |
| Kubernetes, Helm, Kustomize, Argo CD, Flux | Applications in containers | Declarative; controllers and GitOps agents (Argo CD, Flux) reconcile continuously | The cluster itself |
"Keeps state" matters more than it looks. Terraform and OpenTofu
remember what they created in a
state file, which lets them delete a
resource when you remove its block. Ansible keeps no such memory:
remove a task that created a file, and the file simply stays on the
server. To remove it, you write a task with
state: absent.
Why it pays off — and what it costs
The Cloud page lists the benefits: repeatability, review, history, documentation that cannot go stale, fast recovery. For operations and service desk work, a few more are worth naming:
-
Answers in the repository. "Which servers may
connect to
db01?" is a search in the code instead of a login on the server — the repository becomes the source of truth. - Changes you can review before they happen. A plan or a check-mode run shows what would change, which fits naturally into change enablement: the change request links to the pull request and the plan.
- Less toil. Setting up the twentieth server takes as long as the second.
The costs are real too: a learning curve, tools and versions to maintain, a repository that can hold dangerous power (whoever can merge can change production), and the discipline to stop fixing things by hand. Secrets need particular care, because code, state files and logs all tend to collect them; Secrets in Automation covers that.