Infrastructure as code
Servers, networks, and managed services used to be provisioned by hand or by one-off scripts that only their author fully understood. Infrastructure as code (IaC) replaces that with the same discipline this course has already applied to application code: infrastructure is defined in text files, checked into version control, reviewed before it changes anything, and applied through a repeatable engine instead of a person clicking through a console. This page covers the declarative model that dominates modern IaC, the idempotency property that makes it trustworthy, and the two hardest operational problems it introduces: drift and concurrent state changes.
An imperative script is a recipe: "preheat the oven, mix the batter, bake 25 minutes." Run it twice and you've baked two cakes — the steps don't check what already exists. A declarative IaC file is a photo of the finished cake pinned to the fridge: "this is what the kitchen should contain." The tool's job is to compare the photo to what's actually in the kitchen right now and do only whatever work closes the gap — nothing, if the cake's already there; remove and rebake, if someone swapped in a different flavor.
Declarative vs. imperative: describing the end state vs. scripting the steps
An imperative approach specifies the sequence of operations needed to reach a result: "create a VM, then attach a disk, then open port 443, then install nginx." A shell script full of aws or gcloud CLI calls is imperative IaC — it works, but it only describes how to get from nothing to the desired state. It says nothing about what to do if the VM already exists, if someone manually changed the firewall rule last Tuesday, or if the script is re-run against an environment that's already half-built. Correctness depends entirely on the operator running the right steps, in the right order, from the right starting point.
A declarative approach specifies the desired end state and delegates the "how" to a tool: "there should be exactly one VM, this size, with this disk, with port 443 open." The engine — a provider plugin, typically backed by the same cloud API the imperative script would have called — figures out the ordering, the diff against current reality, and the specific create/update/delete calls required. This is the model behind Terraform's HCL, AWS CloudFormation's JSON/YAML templates, and Pulumi's approach of expressing the same declarative resource graph in a general-purpose language (TypeScript, Python, Go) instead of a bespoke DSL.
Declarative IaC dominates infrastructure tooling for a structural reason, not a stylistic one: infrastructure is long-lived and mutated by many actors over time, so the tool needs to reconcile current state against desired state on every run, not just execute a fixed script from a known starting point. That reconciliation is also what enables the plan-then-apply workflow below — you can only preview "what will change" if the tool already knows how to compute a diff, and diffing is only well-defined against a declared target state, not a list of steps.
Idempotency: the property that makes repetition safe
An operation is idempotent if applying it once has the same effect as applying it many times. In IaC terms: running terraform apply (or the CloudFormation or Pulumi equivalent) twice in a row against unchanged configuration should produce zero infrastructure changes the second time, because the tool recognizes that current state already matches desired state and does nothing. This is the property that makes IaC safe to re-run after a failed apply, a flaky network call, or a CI job retry — an imperative create-instance script re-run after a partial failure risks creating a second instance; a declarative apply re-run just finishes reconciling toward the same target.
Idempotency isn't automatic just because a tool is declarative — it depends on the provider correctly detecting existing resources (usually by an ID stored in state, see below) rather than blindly issuing create calls. It also has a practical corollary for authors: resources should be defined so that re-applying the same configuration is a no-op, which means avoiding constructs that generate a new value on every run (an unpinned random suffix in a resource name, a timestamp baked into a tag) unless that non-determinism is genuinely intended. A config that produces a diff on every apply even though nothing meaningfully changed is a broken idempotency contract, and it erodes the one guarantee that makes IaC trustworthy to run unattended in a pipeline.
Idempotency is what lets IaC applies run unattended in CI on every merge to main, the same way tests do. If "apply" were not safe to repeat, every run would need a human deciding whether it was safe — which defeats the point of automating infrastructure changes in the first place.
State: drift and concurrent-access locking
To compute a diff between desired and current state, a declarative tool needs a record of what it last created and with what identifiers — this is state (Terraform's .tfstate, CloudFormation's managed stack state, Pulumi's stack state backend). State is what turns "here's a config file" into "here's exactly which real cloud resources this config file owns." Two problems follow directly from keeping that record.
The first is drift: the gap that opens up when real infrastructure changes without going through the IaC tool — someone edits a security group rule in the console during an incident, an autoscaler changes an instance count, another automation resizes a disk. State now disagrees with reality, and the next plan either silently reverts the manual fix (surprising and disruptive) or the team has to explicitly reconcile it (via a refresh/import step) before trusting the plan again. Drift is why most teams enforce "changes go through the IaC pipeline, full stop" as a hard rule rather than a suggestion, and why some tools support scheduled drift-detection runs that alert without applying anything.
The second is state locking: without it, two engineers — or a human and a CI job — running apply against the same state at the same time can race, each computing a plan against a snapshot that's already stale by the time it executes, corrupting the state file or issuing conflicting API calls against the same resource. Locking (a DynamoDB table with Terraform's S3 backend, a native lock in Terraform Cloud, an equivalent in other tools) serializes applies against a given state by holding an exclusive lock for the duration of the operation, so a second concurrent apply blocks or fails fast instead of racing.
The plan-then-apply workflow: infrastructure's code review
Declarative IaC tools split changes into two steps. Plan computes the diff between desired state (the config) and current state (refreshed from the real infrastructure, subject to the drift caveat above) and prints exactly what would be created, changed, or destroyed — without touching anything. Apply executes that plan. This separation is deliberate: a plan is a dry run that a human (or an automated policy check) can inspect before anything irreversible happens, which is precisely the review step a pull request provides for application code, covered in CI/CD pipelines.
In a mature pipeline, plan runs automatically on every pull request that touches infrastructure config and posts its output as a PR comment, so a reviewer sees "this will destroy and recreate 1 resource, modify 3, add 2" before approving — the same way they'd see a code diff. apply is typically gated behind that approval and runs only on merge to the trunk branch, often from CI rather than a laptop, so the applied plan matches exactly what was reviewed. Skipping straight to apply, or applying a plan that's gone stale because someone else merged infrastructure changes in between, is one of the more common ways teams get bitten — which is exactly the concurrent-apply problem state locking exists to prevent.
The snippet below shows the shape of a minimal declarative resource: an HCL-style block declaring one cloud storage bucket with a couple of attributes. Nothing here is a step to execute — it's a statement of what should exist, which is what a plan diffs against and an apply reconciles toward.
resource "cloud_storage_bucket" "reports" {
name = "acme-billing-reports"
location = "us-east1"
storage_class = "STANDARD"
versioning {
enabled = true
}
}
Re-running plan against this exact block with the bucket already created and matching produces no changes — that's idempotency in action. Change storage_class to "NEARLINE" and re-run, and the plan reports one in-place update, showing the old and new value, before anything is applied.
Where the tool categories differ
All three tool categories referenced above are declarative, but they differ in how the desired state is expressed and where the reconciliation engine runs. Terraform-style tools use a purpose-built configuration language (HCL) that's cloud-agnostic by design — the same tool manages AWS, GCP, Azure, and dozens of other providers through a plugin interface, at the cost of learning a DSL. CloudFormation-style tools are native to one cloud provider, expressed in JSON or YAML, with the reconciliation engine run as a managed service by that provider rather than a CLI the team operates — simpler integration with that one platform's other services, no portability elsewhere. Pulumi-style tools express the same declarative resource graph using general-purpose languages, trading a bespoke DSL for real loops, functions, and package managers, which appeals to teams that want infrastructure code to use the same language and tooling (linters, IDEs, unit test frameworks) as their application code.
None of these differences change the core model this page covers: desired state in text, a plan before an apply, idempotent reconciliation, and state as the record of what the tool owns. Picking among them is mostly a question of existing cloud footprint, team language preferences, and whether multi-cloud portability matters — not a difference in the underlying discipline. The DevOps toolchain covers where these tools sit relative to configuration management tools like Ansible, which solve an adjacent but distinct problem: configuring what runs inside a machine, rather than provisioning the machine itself — see configuration management.
A plan that looks like a small change can hide a destructive one. Renaming a resource block, or changing an attribute the provider treats as immutable (like a database engine version on some providers), can force a destroy-and-recreate instead of an in-place update — read the plan's -/+ markers, not just the resource count, before approving an apply against anything stateful.
1. What is the structural reason declarative IaC tools dominate over imperative scripts for managing long-lived infrastructure? 2. What does it mean for an IaC apply to be idempotent, and what kind of resource definition breaks that property? 3. What is infrastructure drift, and why do most teams treat "changes go through the pipeline only" as a hard rule rather than a suggestion? 4. What problem does state locking solve, and what happens without it when two applies run concurrently?
Check your answers
- Infrastructure is long-lived and gets mutated by many actors over time, so the tool needs to reconcile current state against desired state on every run rather than execute a fixed sequence of steps from one known starting point — declaring the end state is what makes that diff-and-reconcile approach well-defined.
- Idempotent means running apply again with unchanged configuration produces zero changes, because current state already matches desired state. A resource definition that generates a new value on every run — an unpinned random suffix, a baked-in timestamp — breaks it by producing a diff even when nothing meaningful changed.
- Drift is real infrastructure diverging from what's recorded in state, typically from manual console edits or other automation acting outside the IaC tool. Teams enforce pipeline-only changes because drift makes the next plan unreliable — it either silently reverts a manual fix or requires an explicit reconcile step before the plan can be trusted again.
- State locking prevents two applies from racing against the same state at the same time. Without it, each apply can compute a plan against a snapshot that's already stale by the time it executes, corrupting the state file or issuing conflicting API calls against the same resource.