Module O-3·28 min read

Terraform state and drift, immutable infrastructure, and what a Kubernetes StatefulSet does for stateful systems that a Deployment cannot.

JJS
Written by Jatin Jain Saraf · Senior Software Engineer

Module O-3 — Infrastructure as Code

What this module covers: Why the environment is part of the system's behaviour, and what it means that you cannot rebuild something that only exists as a sequence of clicks. Declarative state and reconciliation, and the difference between a tool that reconciles when you run it and one that reconciles continuously. Terraform state in detail — what it is, why it must be remote and locked, why it contains secrets, and how to split it so one mistake does not reach everything. Then drift: what causes it, why the dangerous moment is the next apply rather than the divergence itself, and the mature policy, which permits emergency console changes rather than pretending they will not happen. Immutable infrastructure, and the property that makes it worth the build pipeline — the deploy path and the disaster-recovery path become the same path. Policy as code, which turns security review into a build failure. And the one Kubernetes question stateful design forces: what a StatefulSet gives a database that a Deployment cannot, along with the more important thing it still does not give you.


The Environment Is Part of the System

A depressing share of production incidents have the same root cause: something was different in production, and nobody knew. A security-group rule, a timeout on a load balancer, an instance type with less memory, a feature flag, an environment variable, a missing index, a different Postgres minor version. The code was identical and tested; the environment was not.

Infrastructure as code exists because of four properties, and only the first is usually cited:

  1. Reproducibility. The same definition produces the same environment, so staging is structurally the same as production.
  2. Review. A change to a firewall rule goes through the same pull request, diff and approval as a change to a function.
  3. Disaster recovery. You cannot rebuild what exists only as a history of console clicks — which makes IaC a prerequisite for Module O-7 rather than a nicety. "How do we recreate this environment in another region?" has an answer or it does not.
  4. Auditability. Who changed the retention policy, when, and why is a git log rather than an archaeology project.

Analogy: a building's plans versus the building itself. You can construct a house without drawings — many were — and everything works until you need a second one, or an insurer asks where the load-bearing walls are, or the building burns down and you must rebuild it exactly. The drawings are not the building; they are the ability to reproduce the building, argue about it before pouring concrete, and prove what was intended. And the crucial failure mode is the same in both fields: someone knocks a wall through during an emergency and never updates the drawings, so the next contractor works from a document that is confidently wrong.


Declarative State and Reconciliation

Imperative infrastructure is a sequence of steps: create the network, then the subnets, then the instances. Scripts of this shape are not idempotent — running one twice creates two of everything, and running one against a partially-built environment fails or duplicates.

Declarative infrastructure describes the desired end state, and a reconciler computes the difference and applies it. Running it twice is a no-op, running it against a half-built environment completes it, and running it after a deletion recreates the missing part.

The distinction that matters between tools:

ReconcilesConsequence
Terraform / CloudFormation / Pulumiwhen you run itdrift persists silently between runs
Kubernetes controllerscontinuouslya deleted pod is recreated in seconds, unasked
GitOps (Argo, Flux)continuously, from gitdrift is corrected automatically, and that is also a risk

Continuous reconciliation removes the drift problem below and introduces its own: an operator's emergency manual change is reverted within seconds, sometimes mid-incident, by a controller doing exactly its job. Both models require a policy for emergency changes; they just fail differently in its absence.


State: What It Is, and Why It Is the Fragile Part

Terraform's state file is a mapping from the resource addresses in your code to real-world identifiers, plus a cache of their attributes:

text

It exists because the cloud API cannot answer "which of these forty subnets is private[2]?" Identity lives in the state file and nowhere else, which makes the state file the most important and most dangerous artefact in the system.

It must be remote, versioned and locked. Local state means one person can work, the history is on their laptop, and it will eventually be lost or diverge. A remote backend (S3 with DynamoDB locking, GCS, Terraform Cloud) gives shared access, versioning for recovery, and — critically — locking, because two concurrent applies produce a corrupted state that no longer matches reality. Expect the operational consequence: an apply killed mid-run leaves a stale lock, and clearing it requires a force-unlock that everyone is rightly nervous about.

It contains secrets in plaintext. A generated database password, a private key, an access token — any attribute the provider returns is cached in state, whether or not it is marked sensitive in the output. So: encrypt the backend, restrict read access as tightly as production database access, and prefer not to generate secrets in Terraform at all — create them out-of-band in a secrets manager and reference them by ARN or name, so the value never enters state (Module A-13).

Split state by lifecycle and ownership. One state file for everything gives you a slow plan, a lock the whole organisation queues behind, and one blast radius:

text

The cost is cross-state references — remote state data sources, or explicit inputs passed between layers — plus an ordering you now have to know. Worth it, because the alternative is a single apply that can destroy a database while adding a security-group rule.

Refactoring state is where accidents happen. Renaming a resource in code makes Terraform plan a destroy-and-create, because the address changed. Use moved blocks (or state mv) to tell it the resource is the same one; use import to bring an existing resource under management; and treat state rm with real caution, because it makes Terraform forget a resource without deleting it — leaving an unmanaged orphan that still costs money and still serves traffic.

And protect the irreplaceable. A prevent_destroy lifecycle rule on production databases, buckets and anything holding data is a one-line guard against a plan that a tired person approves at 2 a.m.


Drift: the Danger Is the Next Apply, Not the Divergence

Drift is reality diverging from code. The causes, in order of frequency:

  1. A manual console change during an incident. Someone raised a timeout, opened a security group, or scaled a database at 3 a.m. to stop the bleeding. This is not misconduct; it is the correct thing to have done.
  2. Another tool touching the same resource — an autoscaler adjusting capacity, a controller setting an annotation, a second Terraform workspace overlapping.
  3. Provider or cloud defaults changing between versions, so a field you never set now has a different value.
  4. Cloud-managed mutations — a managed service rotating something, adding a tag, or resizing storage.

The dangerous part is not the divergence. It is what happens next:

A later, unrelated apply silently reverts the emergency fix. Someone adds a tag, the plan includes "restore security group to declared state," and the mitigation that has been holding production together for two days disappears — as a side effect of a change nobody connected to it.

The second failure mode is a plan showing unexpected destroys, which a tired operator approves because the plan output is long and the important line is in the middle.

The fix is continuous visibility, not prohibition. Telling people never to touch the console fails, because during a severe incident the console is faster than a pull request and stopping the bleeding is correct. So:

  • Run a scheduled plan in CI (hourly or daily) and alert on any non-empty diff. Drift then has a mean time to detection of an hour rather than "until the next unrelated apply."
  • Adopt a break-glass procedure: emergency manual changes are permitted, are logged, automatically create a ticket, and must be codified within an agreed window — a day or two. The change becomes legitimate rather than hidden.
  • Use ignore_changes deliberately for fields another system genuinely owns (an autoscaler's desired count, a tag applied by a cost tool), which converts permanent noise into a documented boundary.

Sign in to keep reading

The rest of this module is free — sign in with Google to unlock it and track your progress.

Sign in & Register

Discussion

0

Join the discussion

Loading comments...

© 2026 Jatin Jain Saraf (JJS). All rights reserved.