Why IaC Exists
Every tool exists because something before it hurt. Terraform's predecessor wasn't a worse tool — it was a human being in a web console, clicking, and then a second human being trying to reproduce what the first one did. This page is about what actually goes wrong in that world, why the obvious fix (write a script) doesn't work, and what a declarative tool does differently.
What & Why
Infrastructure as code (IaC) is the practice of defining your servers, networks, databases and storage in machine-readable files that live in version control, and having a tool create and modify the real infrastructure to match those files.
The bad practice it replaces
Click-ops — provisioning infrastructure by hand through a cloud provider's web console — is the default, and it is genuinely the fastest way to get one thing working once. It fails in five specific ways, and it's worth being precise about them, because "it doesn't scale" is not an argument anyone should accept.
There is no record of intent. The console shows you a bucket with versioning enabled. It cannot
tell you whether versioning is on because someone decided it should be, or because someone clicked it
by accident in 2022. A .tf file that says versioning { enabled = true }, committed by a named
person with a commit message, is a record of a decision. The console is a record of an outcome.
You cannot review a click. The single largest quality control in software — someone else reads your change before it happens — has no analogue in a web console. There is no diff. By the time a colleague could look at what you did, you have already done it to production.
Reproduction is manual and therefore wrong. Building staging to match production means someone opening two browser tabs and comparing. This works, in the sense that it produces a staging environment. It doesn't produce an identical one, and the differences are invisible until the deployment that worked in staging fails in production.
Deletion is unreliable. Nothing is more expensive than infrastructure nobody remembers creating. A console has no concept of "everything that belongs to this project", so decommissioning is an archaeology exercise, and the load balancer nobody found bills monthly forever.
And the real one: drift. Drift is divergence between what you believe your infrastructure is and what it actually is. Someone widens a security group at 2am during an incident and doesn't change it back. That's not negligence; that's an engineer fixing an outage. The problem is that nothing in the click-ops world will ever tell you it happened. Drift is silent, it accumulates, and it is the failure that IaC is genuinely built to solve — the others are conveniences by comparison.

Where Terraform sits
Terraform is a provisioning tool: it creates, changes and destroys infrastructure objects — buckets, networks, virtual machines, DNS records, IAM policies. It is cloud-agnostic, meaning it speaks to any platform with an API through a plugin, and declarative, meaning you describe the end state rather than the steps to reach it.
Three things it gets confused with
Configuration management. Ansible, Chef and Puppet configure the inside of a machine — install
packages, write files, restart services. Terraform creates the machine. The boundary blurs (Ansible
can create a VM; Terraform can run a script on one) and in both directions the crossing is a mistake.
Terraform's provisioner block, which runs commands on a resource after creating it, exists and is
almost always the wrong answer — see In Practice.
CI/CD. Jenkins, GitHub Actions and GitLab CI run your pipeline; they don't know what a subnet is.
They are the thing that runs Terraform, covered in
CI/CD & Automation. Confusing the two produces pipelines
that shell out to aws CLI commands and call it infrastructure as code.
GitOps. A workflow — git is the single source of truth and a controller continuously reconciles reality to it — most associated with Kubernetes and Argo CD. Terraform is a tool you can use GitOps-ily, but by default it reconciles when you run it, not continuously. That gap is exactly what Drift & Reconciliation is about.
When NOT to use it
Being honest about this is more useful than another list of benefits.
- A genuine one-off you will delete this afternoon. A test VM to reproduce a bug does not need a repository. Click it, use it, delete it.
- Anything with an inherently imperative, ordered, stateful procedure — a database major-version
upgrade, a data migration, a cutover. Terraform describes end states. "Take a snapshot, then
upgrade, then verify, then flip the DNS" is a runbook, and expressing it in Terraform means
fighting the model with
provisionerandnull_resourceuntil something breaks halfway through. - Application deployment. Rolling out a new container image every twenty minutes is a job for a deployment tool. Terraform's plan/apply cycle is deliberately slow and deliberately supervised.
- Where you're only willing to manage half of it. The single worst outcome in Terraform is a resource that is partly managed by Terraform and partly maintained by hand, because every plan will now propose to undo the hand-maintained part. Either bring it under management properly — see Import & Refactoring — or leave it out entirely and document that it's out. Half is worse than neither.
Core Concepts
Infrastructure as code — your infrastructure, written down in files you can diff. The practice of defining infrastructure in declarative or programmatic source files, version-controlled and applied by a tool, such that the files are the authority and the running system is derived from them.
Click-ops — building things by hand in a web console. Manual provisioning through a graphical interface. Not a slur; it's the correct approach for exploration and the wrong one for anything that must survive.
Declarative — you describe the destination. You state the desired end configuration, and the tool computes the operations required to reach it from wherever things currently are. The same file applied twice produces the same result, because the second run finds nothing to do.
Imperative — you describe the journey. You specify the operations themselves, in order. The result depends on the starting point, which means the author has to anticipate every starting point.
Desired state — what you said you want. The configuration expressed in your .tf files: the
set of resources that should exist and the attributes they should have.
Actual state — what is really there. The current configuration of the real objects in the cloud provider, discoverable only by asking the provider's API.
Drift — reality quietly disagreeing with your files. Any difference between desired and actual state that your configuration didn't cause: a manual console change, an out-of-band automation, a provider-side default that changed. Covered properly in Drift & Reconciliation.
Convergence — repeated runs move towards the description, not away from it. The property that applying the same desired state repeatedly brings actual state closer to it and then leaves it alone.
Idempotence — running it twice is the same as running it once. An operation whose repetition
has no additional effect. apply is idempotent; curl -X POST /buckets is not.
Provisioning versus configuration management — making the machine exist, versus making the machine useful. Terraform is the former. Ansible, Chef, Puppet and cloud-init are the latter.
Resource — one thing Terraform manages. A single object in a provider's API — one bucket, one
subnet, one DNS record — declared in a resource block. The full grammar is in
Resources & References.
Provider — the plugin that knows one platform's API. A separately versioned binary that translates Terraform's generic operations into calls against AWS, Azure, GCP, Cloudflare, GitHub or several thousand others. See Providers & the Registry.
State — Terraform's record of what it made. A file mapping each resource in your configuration to the real object it created, so the tool can tell "create this" apart from "modify that". It exists because the alternative — rediscovering the whole world on every run — is impossible at any real scale. Introduced properly in Anatomy of a Project; the mechanics are in State & Backends.
Plan — the dry run that tells you what would change. A computed diff between desired state, recorded state and actual state, rendered for a human to approve. See The Plan/Apply Lifecycle.
Blast radius — how much breaks if this change is wrong. The set of resources a single operation could affect. A phrase used throughout this article, and the main reason state gets split into separate files as an estate grows.
How It Works
The mechanism is a three-way comparison. Not two — that's the part people get wrong.

A tool that compared only desired state (your files) against actual state (the cloud) could tell that a bucket exists and that your file describes a bucket, but not whether this bucket is that declaration — and it could never detect deletion, because a resource you removed from your files looks identical to a resource you never wrote. So Terraform keeps a third thing: a record of what it created and which configuration address created it. Each run compares all three:
| In files? | In record? | In cloud? | Terraform concludes |
|---|---|---|---|
| ✅ | ❌ | ❌ | Create it |
| ✅ | ✅ | ✅, matching | Do nothing |
| ✅ | ✅ | ✅, differing | Update it — or replace it, if the differing attribute can't be changed in place |
| ❌ | ✅ | ✅ | Destroy it |
| ✅ | ✅ | ❌ | Recreate it — someone deleted it out of band |
| ✅ | ❌ | ✅ | Error — "already exists". This is the brownfield problem, and the answer is import |
That last row is worth sitting with, because it's the one that ambushes people on a real estate. A resource that exists in the cloud but not in Terraform's record is invisible to Terraform, and the moment you write configuration for it, Terraform tries to create a second one.
Two consequences fall out of this design immediately, and both shape everything later in the article:
Convergence is a property of the comparison, not of your files. You don't write "create if not exists". The diff produces that. This is why applying the same configuration twice is safe and why applying it to an empty account and a half-built account both work.
The record is load-bearing and therefore dangerous. If it's lost, Terraform forgets what it owns
and proposes to create everything again. If it's wrong, Terraform acts on a false belief about
reality. If two people run apply simultaneously, they write over each other's record. Those three
sentences are the entire agenda of
State & Backends and
Failure & Recovery.
Getting Started
The smallest thing that demonstrates the difference isn't a Terraform tutorial — it's running each approach twice.
First, the imperative version
A perfectly reasonable script that creates a bucket. Every cloud CLI has one of these, and every team has a directory full of them.
```bash
#!/usr/bin/env bash
aws s3api create-bucket \
--bucket tf-article-demo-0725 \
--region eu-west-1 \
--create-bucket-configuration LocationConstraint=eu-west-1
```
```bash
#!/usr/bin/env bash
az storage account create \
--name tfarticledemo0725 \
--resource-group tf-article-demo \
--location westeurope \
--sku Standard_LRS
```
```bash
#!/usr/bin/env bash
gcloud storage buckets create gs://tf-article-demo-0725 \
--location=europe-west1
```
Run it once: a bucket. Run it a second time:
# UNVERIFIED — confirm against a real run
An error occurred (BucketAlreadyOwnedByYou) when calling the CreateBucket operation:
Your previous request to create the named bucket succeeded and you already own it.
That error is the whole lesson. The script isn't wrong — it did exactly what it was told. It has no idea what already exists, so it cannot be run twice, which means it cannot be the thing you run to make sure your infrastructure is correct. You can fix this by adding an existence check, and then a check for the region, and then a check for whether versioning is enabled, and after about two hundred lines you have written a bad, single-purpose version of Terraform. This is the honest reason IaC tools exist: not because scripts are unprofessional, but because the convergence logic is the hard part and it is the same for everybody.
Now the declarative version
Bucket names must be globally unique in all three clouds, so change the suffix. Azure needs a resource group to put the storage account in, which is why its tab has an extra resource — GCP and AWS have no equivalent.
```hcl
# main.tf
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = "eu-west-1"
}
resource "aws_s3_bucket" "demo" {
bucket = "tf-article-demo-0725"
}
```
```hcl
# main.tf
terraform {
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 3.0"
}
}
}
provider "azurerm" {
features {}
}
resource "azurerm_resource_group" "demo" {
name = "tf-article-demo"
location = "westeurope"
}
resource "azurerm_storage_account" "demo" {
name = "tfarticledemo0725" # 3–24 chars, lowercase alphanumeric only
resource_group_name = azurerm_resource_group.demo.name
location = azurerm_resource_group.demo.location
account_tier = "Standard"
account_replication_type = "LRS"
}
```
```hcl
# main.tf
terraform {
required_providers {
google = {
source = "hashicorp/google"
version = "~> 5.0"
}
}
}
provider "google" {
project = "your-project-id"
region = "europe-west1"
}
resource "google_storage_bucket" "demo" {
name = "tf-article-demo-0725"
location = "EUROPE-WEST1"
}
```
terraform init # download the provider
terraform apply # show the plan, ask for confirmation, create the bucket
terraform apply # ...and again
The second apply:
# UNVERIFIED — confirm against a real run
No changes. Your infrastructure matches the configuration.
Terraform has compared your real infrastructure against your configuration
and found no differences, so no changes are needed.
Not an error. Not a second bucket. Nothing — which is the correct answer to "make sure this bucket exists" when it already does. Every command here is explained properly in The Core Workflow; the point of running them now is that sentence.
If you want to see drift with your own eyes, open the console, add a tag or label to the bucket by
hand, and run terraform plan. Terraform will notice and propose to remove it.
Then clean up, so this doesn't become the infrastructure nobody remembers creating:
terraform destroy
In Practice
The interesting question isn't whether to use IaC. It's what changes in how a team works, and which of those changes people skip.
The rule that makes it work, and the exception everyone actually needs. The rule is: nobody changes managed infrastructure by hand. The exception is: during a production incident, somebody will, and they should — a firewall rule that stops an outage is worth more than a clean audit trail. So the workable version of the rule is no manual changes except during an incident, and every incident's manual changes get reconciled into code before the incident is closed. Teams that state the absolute rule without the exception get the exception anyway, plus silence about it. Make the reconciliation step part of the incident template, not part of somebody's memory.
Coverage is a cliff, not a slope. Fifty per cent IaC coverage is not half the benefit. Any resource that Terraform partly manages is a resource that produces a confusing plan every time, and confusing plans are how teams learn to approve plans without reading them — which costs more than the manual work ever did. Draw the boundary explicitly: this state file owns these resources, and everything else is out of scope and documented as such.
Adopt brownfield incrementally, by boundary. Nobody starts on an empty account. The workable
sequence is: pick one bounded thing (a single environment, a single application's networking), import
it, get its plan to "no changes", and only then move to the next. The tooling for this — import
blocks, moved blocks, generated configuration — is a survival skill, and it's covered in
Import & Refactoring.
provisioner is the trap. It's a block that runs shell commands on a resource after creation,
and it looks like the obvious bridge between "Terraform makes the VM" and "and now install nginx on
it". HashiCorp's own documentation describes provisioners as a last resort, and they're right: a
provisioner runs only at creation, so it's invisible to plan, absent from state, and never
re-runs when the script changes. Your configuration management is now something Terraform cannot see
and cannot converge. Use a machine image built beforehand, or cloud-init/user-data, or a
configuration management tool triggered separately.
What a reviewer should look for, on a pull request that introduces Terraform to something:
- Does the plan output appear in the pull request, and does it say create where you expect creates and nothing where you expect nothing? An import that's working correctly produces no changes, and a plan full of replacements on an "import" PR means the configuration doesn't match reality yet.
- Is anything being destroyed? Every
-and-/+in a plan needs a sentence explaining it. - Are provider versions pinned, and is the lock file committed?
- Is the boundary stated — what this configuration owns, and what it deliberately doesn't?
Rollback isn't git revert. Reverting the commit and applying takes infrastructure back to the
previous described state, which is not the same as the previous real state: a deleted database is
still deleted, and re-creating it creates an empty one. Data-bearing resources have a one-way door in
them. This is the difference between rolling back application code and rolling back infrastructure,
and it's why the destroy line in a plan deserves more attention than any other.
Ecosystem
The tools you'll be asked to compare Terraform against in a design review, and the honest one-line version of each.
| Tool | What it is | Why you'd choose it over Terraform | Why you usually don't |
|---|---|---|---|
| AWS CloudFormation | AWS's own declarative provisioning service | State is managed by AWS — nothing for you to store, lock or lose; day-one support for new AWS features | AWS only; slower, less legible feedback than plan; YAML/JSON is a poor language for this |
| Azure ARM / Bicep | Azure's native templates; Bicep is a much nicer language compiling to ARM JSON | Same — Azure manages deployment state; Bicep is genuinely pleasant | Azure only; the surrounding ecosystem (policy, testing, cost) is thinner |
| Google Cloud Deployment Manager / Config Connector | GCP's native options — the former legacy, the latter a Kubernetes-based reconciler | Config Connector suits estates already run entirely through Kubernetes | Deployment Manager is effectively end-of-life; ⚠️ verify current GCP guidance before citing this in a review |
| Pulumi | IaC in TypeScript, Python, Go or C#, with a Terraform-like state and plan model | Real loops, real types, real tests, real IDE support; genuinely better for complex generated infrastructure | A general-purpose language invites general-purpose complexity into infrastructure; smaller community; the plan is harder to read because the code that produced it is harder to read |
| Ansible | Imperative-leaning automation, strongest at configuring machines | Agentless configuration management; can provision too | It doesn't keep a record of what it created, so it can't reliably tell you what to delete. Different job, frequently mistaken for the same one |
| Crossplane | Provisions cloud infrastructure via Kubernetes custom resources, reconciled continuously | Continuous reconciliation rather than run-on-demand; one control plane for apps and infrastructure | You now run a Kubernetes cluster in order to create a bucket, and the debugging surface is Kubernetes-shaped |
| OpenTofu | The MPL-licensed fork of Terraform, under the Linux Foundation | Licensing; some features Terraform lacks | Divergence is growing slowly; check which one your organisation's tooling assumes |
The one-line summary. Single-cloud, all-in, and happy to be — use the native tool; the managed state alone is worth it. More than one cloud, or cloud plus DNS plus GitHub plus a SaaS provider — Terraform, because the alternative is three tools and three mental models. Infrastructure that is genuinely programmatic, generated from data — look seriously at Pulumi and be honest about who will maintain it.
Production
Security
IaC is a security improvement and a security liability in the same move. The improvement: every
change to a firewall rule, IAM policy or public-access setting is a reviewable diff with an author and
a timestamp — an auditor's dream compared to console logs. The liability: your repository now
describes your entire attack surface, and the credentials that apply it can change anything. Two
consequences arrive on day one — the credentials Terraform runs with are the most powerful in your
organisation and should be short-lived rather than long-lived keys (see
CI/CD & Automation), and secrets must never be written in
.tf files, because state records them in plaintext (see
Secrets & Sensitive Data).
Blast radius
The same automation that creates a hundred resources correctly destroys a hundred resources
correctly. Click-ops has a natural rate limit: a human can only click so fast, and gets bored before
deleting the whole VPC. Terraform has no such limit. The mitigations — splitting state so no single
apply can reach everything, prevent_destroy on data-bearing resources, requiring approval on plans
containing destroys — are covered in Meta-Arguments and
Repo & Environment Structure. What matters at this
stage is the instinct: read the destroy lines first.
Scale
IaC's value is roughly proportional to how often infrastructure changes multiplied by how many people change it. One person with three servers gets little; twenty people with three environments and a compliance auditor get a great deal. The costs scale too — plans get slower, state files get bigger, and at some point the answer stops being "tune a flag" and becomes "split this state", which is Scale & Performance.
Team workflow
The change is that infrastructure joins the software development lifecycle: branch, pull request,
review, merge, apply. The failure mode is adopting the file format without the workflow — a
terraform/ directory that everyone applies from their laptop, with no review and no shared state,
which has all the constraints of IaC and none of the benefits. If you take one operational habit from
this article, take plan output goes in the pull request.
Reliability
Reproducibility is the payoff. Recreating an environment in a new region becomes a matter of different variables against the same configuration rather than a fortnight of clicking. Two honest caveats: the configuration reproduces infrastructure, not data, so backups remain a separate problem entirely; and a reproduction path that has never been exercised is a hypothesis, not a plan — see Failure & Recovery.
Interview Questions
Conceptual
What is infrastructure as code, and what problem does it solve?
Defining infrastructure in version-controlled, machine-readable files that a tool applies to make reality match. The headline benefits — reproducibility, review, auditability, disposability — all follow from one underlying problem: drift, the silent divergence between what you believe is running and what is actually running. Manual processes have no mechanism to detect it. A good answer names drift specifically rather than listing benefits.
Declarative versus imperative — explain the difference and why Terraform chose declarative.
Imperative specifies the operations in order; declarative specifies the desired end state and lets the tool derive the operations. Declarative wins for infrastructure because the starting state varies — empty account, half-built account, account that drifted last night — and an imperative script must anticipate every one of those, while a declarative tool computes the difference at run time. It also means the configuration file doubles as documentation of intent, which a script does not.
The honest caveat: declarative is a poor fit for genuinely ordered procedures like migrations and cutovers, which is why those stay in runbooks.
Terraform versus Ansible — when would you use each?
Terraform provisions infrastructure objects; Ansible configures the inside of machines. The deeper distinction is state: Terraform keeps a record of what it created and can therefore tell you what to destroy; Ansible enforces a described configuration on hosts you point it at and has no equivalent record. In practice they compose — Terraform creates the VMs and emits their addresses, Ansible configures them — though a pre-baked machine image is often better than either.
Why not just write shell scripts against the cloud CLI?
Because a create call isn't idempotent, so the script can't be re-run. Making it re-runnable means
adding existence checks, then attribute checks, then a record of what the script created so it knows
what to delete — at which point you've built a worse Terraform. The convergence logic is the hard
part, and it's identical for everyone, which is precisely the kind of thing that should be a shared
tool. You also lose the dry run: a script has no plan.
Terraform is declarative — but is `.tf` code, in the programming sense?
Mostly not, and the distinction matters. HCL has expressions, types and functions, but no imperative
control flow: a .tf file is evaluated to produce a resource graph, not executed top to bottom.
for_each isn't a loop that runs — it's a declaration that these instances exist. That's why
ordering within a file is irrelevant and why "run this, then that" has no direct expression. See
HCL & the Type System.
Technical depth
Why does Terraform need a state file? Why isn't the configuration plus the cloud API enough?
Three reasons. Identity — mapping the configuration address aws_s3_bucket.demo to a specific
real object; the cloud API can list buckets but can't say which declaration owns which. Deletion
detection — a resource removed from your files is indistinguishable from a resource never written,
unless something records that Terraform previously created it. Performance — rediscovering every
resource across every service on every run is impractical at real scale, so state acts as a cache.
The follow-up worth pre-empting: this makes state load-bearing, which creates the locking, security and recovery problems covered in State & Backends.
Walk through what Terraform concludes for each combination of "in config / in state / in cloud".
In config only → create. In all three and matching → nothing. In all three and differing → update, or
replace if the attribute is immutable. In state and cloud but not config → destroy. In config and
state but not cloud → recreate (deleted out of band). In config and cloud but not state → error,
because Terraform tries to create a duplicate; this is the brownfield case and the answer is
import. The last one is the answer that distinguishes someone who has used Terraform on an existing
estate.
What is drift, what causes it, and what are your options when you find it?
Divergence between recorded/desired state and reality, caused by console changes, out-of-band automation, provider-side defaults, or another Terraform configuration touching the same resource.
Three options, and choosing between them is a judgement call: revert — apply and let Terraform
restore the described state, correct when the change was unauthorised; adopt — update the
configuration to match reality, correct when the manual change was right and should have been code;
ignore — lifecycle { ignore_changes = [...] }, correct only when another system legitimately
owns that attribute, and a deliberate concession rather than a way to silence a noisy plan. See
Drift & Reconciliation.
Why is `provisioner` discouraged?
It runs only at create time, so it's invisible to plan, unrepresented in state, and never re-runs
when the script changes — meaning the configuration it applies can't converge and can't be detected
as drifted. A failed provisioner also leaves the resource marked as tainted, so the next apply
replaces the whole thing. Alternatives: pre-baked images, cloud-init/user-data, or a separate
configuration management run. The legitimate residue is genuinely rare.
How does this differ across AWS, Azure and GCP?
Terraform's model doesn't change, but three things about the surroundings do.
Scope and hierarchy. AWS scopes by account plus region. Azure nests
subscription → resource group → resource, so nearly every Azure resource needs a
resource_group_name and a location — an extra required resource with no AWS or GCP counterpart.
GCP scopes by project, set on the provider rather than per resource.
Metadata. AWS and Azure both call it tags and accept free-form keys and values. GCP uses
labels, restricted to lowercase and a limited character set — so a shared tagging module needs a
transformation for GCP rather than the same map.
The native alternative you're arguing against. CloudFormation on AWS, Bicep on Azure, Config Connector on GCP. The strength of the "just use the native tool" counter-argument varies: Bicep is pleasant enough that single-cloud Azure teams reasonably choose it, whereas GCP's native story is weak enough that Terraform is close to the default there.
The canonical table covering scope, metadata, CI authentication and naming constraints lives in Providers & the Registry.
Can a resource be managed by two Terraform configurations at once?
It can be, and it's a bug, not a feature. Each configuration has its own state, each believes it owns
the resource, and they will alternately revert each other's changes — with the symptom being a plan
that proposes the same change repeatedly and an apply that never settles. The fix is a clear
ownership boundary: one resource, one state. Where a second configuration needs to read the first
one's output, use a data source or terraform_remote_state — see
Repo & Environment Structure.
Scenario and design
You've inherited a 200-resource AWS account built entirely by hand. How do you get it under Terraform?
Not all at once. Pick a bounded, low-risk slice — one environment, or one application's networking —
and import it, aiming for plan to report no changes, which is the only real proof the
configuration matches reality. Only then take the next slice. Along the way: agree and announce a
freeze on manual changes to imported resources, write the boundary down so everyone knows what is and
isn't managed, and start with something whose accidental replacement wouldn't be a disaster — not the
production database. Tooling: import blocks with generated configuration, and terraformer for
bulk discovery, treating its output as a first draft rather than something to commit. Detail in
Import & Refactoring.
A colleague argues IaC is overkill for a five-person startup. Respond.
Take the argument seriously — for genuine throwaway experiments they're right, and IaC's value scales with change frequency and team size. But the strongest counter isn't productivity, it's that the cost of adopting IaC rises steeply with the size of the estate: retrofitting 200 hand-built resources is far more work than writing 20 from the start, and the moment you actually need reproducibility is usually the moment you can least afford a fortnight of import work. A reasonable middle position: code the durable, hard-to-recreate things — networking, IAM, databases, DNS — and let genuinely disposable experiments stay manual.
Someone widened a security group by hand during an incident. What's the right process response?
First, separate the act from the process: they were right to do it. The problem is that nothing
records it. The response is a reconciliation step in the incident close-out — run plan on the
affected configuration, see the drift, and decide explicitly whether to revert it or codify it. If it
should stay, it becomes a pull request like any other change. The systemic improvement is scheduled
drift detection so that this is caught by a job rather than by whoever next runs a plan, plus
break-glass access that is time-limited and alerts on use. What doesn't work is a policy forbidding
manual changes during incidents — that produces the same changes and less honesty about them.
Design review: a team proposes one Terraform configuration and one state file for all environments and all services. What do you say?
The problems are blast radius, contention and speed, in that order. One state means a single apply can destroy production while targeting dev, that every plan reads and locks the entire estate so concurrent work serialises, and that plans slow as the estate grows because every run refreshes everything. It also makes permissions all-or-nothing: anyone who can apply anything can apply everything.
The counter-argument to acknowledge honestly is that splitting introduces coupling between states, which has its own costs. So the answer isn't "split as much as possible" — it's to put the seams where change frequency and blast radius differ: environments always separate; within an environment, split stable foundational layers (networking, IAM) from fast-moving application layers. See Repo & Environment Structure.
Commands & Gotchas
At this stage the command surface is deliberately tiny. Everything here is covered properly in The Core Workflow.
terraform version # which Terraform, which providers — the first thing to check
terraform init # download providers; run once per directory, and after changing versions
terraform fmt # canonical formatting; run before every commit
terraform validate # syntax and internal consistency; no cloud credentials needed
terraform plan # the dry run — what would change, and why
terraform apply # show the plan, ask for confirmation, then make it so
terraform destroy # remove everything this configuration manages
| Behaviour | Why it matters |
|---|---|
apply is idempotent |
Running it twice is safe. "No changes" is a successful outcome, not a no-op you got wrong |
plan shows intent, not a guarantee |
It's computed from current state; something can change between plan and apply. Save a plan with -out when the gap matters |
destroy removes what's in state, not what's in the account |
Anything created by hand survives; anything Terraform made — including data — does not |
| Configuration reproduces infrastructure, never data | Rolling back a deleted database gives you an empty one. Backups are a separate system |
| A resource in the cloud but not in state is invisible | Writing configuration for it makes Terraform try to create a duplicate. This is why import exists |
| Half-managed is worse than unmanaged | Every plan will propose to undo the hand-maintained part, and teams learn to stop reading plans |
← Back to Orientation · Next: The Core Workflow →
⚠️ Verification checklist (delete before publishing)
Commands and output
- Run the AWS bucket script twice; capture the real second-run error. The
BucketAlreadyOwnedByYoutext is from memory — the exact wording and error code differ by region (us-east-1returnsBucketAlreadyExistsin some cases). ⚠️ verify. - Run the Azure and GCP scripts twice and capture their second-run errors too — currently not shown at all.
azmay return success on re-run rather than an error, which would weaken the example; check before relying on it. - Confirm
aws s3api create-bucketstill requires--create-bucket-configuration LocationConstraintfor non-us-east-1regions. - Confirm
gcloud storage buckets createis the current form (it replacedgsutil mb; ⚠️ verifygsutildeprecation status before implying it's gone). - Run all three Terraform configs; capture the real
applyoutput and the real second-apply"No changes" text — the wording shown is reconstructed. - Verify the drift demonstration actually produces a plan diff: add a tag/label by hand and confirm
planproposes removing it. For GCP, confirm labels behave this way and aren't ignored.
Versions and syntax
- Provider version constraints
~> 5.0(aws),~> 3.0(azurerm),~> 5.0(google) — all three are likely stale. Check current major versions and update; azurerm 4.x in particular has breaking changes including a requiredsubscription_id. ⚠️ verify. - Confirm
provider "google"still acceptsregionat the provider level alongsideproject. - Confirm
azurerm_storage_accountargument names (account_tier,account_replication_type) are unchanged in the current major version. - GCP bucket
location = "EUROPE-WEST1"— confirm the expected case and whether lowercase is accepted.
Asserted constraints
- Azure storage account name: 3–24 characters, lowercase alphanumeric only. ⚠️ verify exact rule.
- "Bucket names are globally unique in all three clouds" — verify for GCP and Azure specifically.
- The claim that a failed
provisionermarks the resource tainted and forces replacement on next apply. ⚠️ verify current behaviour. - The characterisation of GCP Deployment Manager as effectively end-of-life, and of Config Connector as the successor. ⚠️ verify current Google guidance — flagged inline already.
- HashiCorp's documented position on provisioners as "last resort" — confirm the wording still exists in current docs before paraphrasing it.
-
terraformer— confirm it's still maintained before recommending it.
Rendering
- Both
<Tabs>blocks render correctly on the published site. If tabs aren't available, fall back to labelled consecutive fences throughout. - The
<details>blocks render on the site (they work on github.com regardless). - All fifteen relative links resolve once the target files exist. Currently every one of them is a dead link.