Most tools that build infrastructure run steps. This one keeps a map. You describe the end state you want; it compares that against a file recording which real object each block already stands for, and proposes the difference. Almost everything that surprises people — a plan that wants to destroy something you merely renamed, a change nobody made, a run that silently waits on another — comes from that map rather than the language. So the language is the short part of this guide and the map is the long part: learn what state holds, when it is read, and who may write it.
Each chapter opens with the short version. Tap one to read the detail.
The model, its plugins, and its one escape hatch
~2 min
You describe the end state; the tool derives the steps, which is why running the same configuration twice proposes nothing the second time. The work is done by provider plugins you pin like any dependency, and by provisioners, which sit outside the model.
Configuration is declarative: it describes the end state of your infrastructure, not the steps to reach it. The loop is write, plan, apply. A plan describes what will be created, updated or destroyed, computed from your configuration and the infrastructure that already exists; after a successful apply, configuration, infrastructure and state all agree.
The misconception is treating this as a script runner. A script executes its steps on every invocation; here the steps are derived, so a second run against unchanged input proposes nothing. That idempotence follows from comparing before acting — not a feature you switch on.
The doing is delegated to provider plugins, which manage resources through each platform's own application programming interface (API). A provider requirement is a local name, a source address and a version constraint; the source address is the identity, since callers may pick any local name. Constrain with ~>, which lets only the rightmost component increment — ~> 1.0.4 accepts patch releases and nothing further. Then commit the lock file, .terraform.lock.hcl: it records the exact version selected, and once recorded that version is re-selected even when a newer exists, so only -upgrade re-resolves. "Initialising again upgraded my providers" is not the behavior once a lock exists — if it happened, the lock was not committed.
Provisioners are where the model stops. A local-exec provisioner runs a command on the apply machine; nothing in your file describes a state that can be re-checked. The reference is blunt — provisioner behavior cannot be predictably modeled, so exhaust the alternatives first. And a failed creation-time provisioner marks the resource tainted, to be destroyed and re-created next apply: a shell script exiting non-zero costs you the server.
Rule: describing a state keeps you inside the model; issuing a command steps outside it, and you owe yourself an answer for the second run.
State: the map from your configuration to real things
~2 min
State binds each resource block to the object it actually created — how the tool knows which server aws_instance.web means. It is plain text, secrets included: marking a value sensitive hides it from output, not from the file.
State stores bindings between objects in a remote system and the resource instances declared in your configuration, plus metadata, and caches attributes so large configurations need not be re-read each time. That binding is what makes later updates and deletions possible: the remote object's identity is recorded against a particular resource instance.
The misconception is that state is a cache — irritating, regenerable, safe to lose. It is derived from nothing. Delete it and your next plan sees a configuration describing three servers and a map describing none: it proposes creating three more while the originals keep running and billing.
Secrets are the second surprise: state is a plain text file that includes any secret values defined in your configuration. Marking a variable sensitive redacts it from command output — but those values are still stored in state and plan files, readable by anyone holding either. The mark propagates: expressions referencing a sensitive value are themselves sensitive. Reading a password from a secret manager does not rescue you; the fetched value lands in state like any other attribute. Only ephemeral values, deliberately omitted from state and plan files, stay out.
Hence two handling rules: never commit state, and never edit it by hand.
Rule: treat state as production data, with the care you give database credentials — that is literally what is in it.
A plan is a diff against state, refreshed
~2 min
Planning is three steps: read the existing remote objects to bring state up to date, compare configuration against that state, propose actions. The trap is believing it diffs against reality — switch the refresh off and you diff a map that may be weeks stale.
The documented sequence is precise. First it reads the current state of already-existing remote objects, so that state is up to date. Then it compares the configuration to the prior state. Only then does it propose actions to make the remote objects match the configuration.
The misconception is that a plan shows what will happen to your infrastructure. It shows the difference between your configuration and state, which is only as fresh as the refresh preceding it. -refresh=false disables that synchronization, so the plan is computed against whatever the map last recorded: faster plans that lie.
Drift — a change somebody made by hand — is therefore not something a plan hunts for; it is invisible until a refresh notices it. A refresh-only plan exists for exactly this: its only goal is to update state and root module outputs to match changes made outside of Terraform.
Saved plans are where automation lives. -out writes the plan to a file, and applying that file runs without prompting, because passing it is the approval — the plan-on-a-pull-request shape. Two cautions: a saved plan holds sensitive data in cleartext even where the terminal obscured it, and -detailed-exitcode (0 no changes, 1 error, 2 non-empty diff) is what a pipeline should branch on.
Rule: before trusting a plan, ask when state was last refreshed — and treat -target as the documented recovery tool it is, not a way to shrink a frightening plan.
The order you never wrote down
~2 min
Nothing runs in file order. References between resources become edges in a graph, which is validated to have no cycles and walked in parallel as soon as each node's dependencies finish. An ordering you assumed but never expressed does not exist.
Ordering is derived, not written. Interpolations in resource and provider configurations are parsed for dependencies: a reference to another resource's attribute becomes an edge between them. depends_on adds one explicitly; every resource also depends on its provider. The graph is validated to have no cycles and a single root, then walked in parallel — a node runs as soon as all its dependencies have run, ten at a time by default.
So file layout means nothing: every configuration file in a directory is evaluated as one document, and splitting blocks across files is only a convenience for readers.
The failure this creates is quiet. If A must exist before B but B never mentions A, there is no edge, and the two are walked concurrently. It works most of the time, fails under load, and reads as a flaky platform. Make the dependency real — reference an attribute of A, or declare it with depends_on.
Data sources live in the same graph: a data block is read during planning when its arguments are known; when it depends on a managed resource this plan will change, the read is deferred to apply and its attributes show as computed. Looking up something you also create in the same run is a graph position, not a query.
Rule: if you can point at the reference that creates an edge, the ordering exists; if you can only point at line numbers, it does not.
Four things that can happen to a resource
~2 min
Every planned change is one of four actions: create, destroy, update in place, or destroy and re-create. Which one you get is decided by the remote platform's limits, not by how small your edit looked — and deleting a block means destroy.
The four actions are the vocabulary of every plan. Resources in the configuration that do not yet exist are created. Resources that exist in state but not in the configuration are destroyed. Changed arguments are updated in place — unless the remote API cannot change them, in which case the object is destroyed and re-created. State is then updated so all three match again.
The second is the one that bites: deleting a resource block is not "stop managing this" but a destroy instruction, and it will be carried out.
lifecycle holds the escape hatches, each with a documented edge. prevent_destroy rejects plans that would destroy the object — but explicitly does not protect you if you remove the resource's configuration, the exact move people expect it to guard. create_before_destroy builds the replacement first, and is opt-in because many object types have unique-name constraints preventing old and new coexisting. ignore_changes weighs an argument when planning a create and ignores it on an update — what you want when another system edits a tag.
Concretely: change an argument the platform's API treats as immutable and the plan shows a destroy-and-create pair — the only route the platform offers to the state you asked for.
Rule: read which of the four actions a plan proposes before reading anything else, and never assume a small edit implies a small action.
count and for_each are two addressing schemes
~2 min
count addresses instances by position — [0], [1] — while for_each addresses them by map key or set member. Because state binds real objects to those addresses, the choice decides what happens when a middle item disappears.
count creates that many instances, each with a count.index and an address like aws_instance.server[0]. for_each accepts a map or a set of strings and creates one instance per element, addressed by key — aws_instance.server["web"] — with each.key and each.value available inside.
Treating these as two spellings of one feature is the mistake: they are two ways of naming instances, and the name is what state records against the real object.
Follow it through with three servers named from a list under count. Remove the middle name. The third now sits at index 1, so the address [1] — which state bound to the object created for the old second name — describes a different server, and index 2 no longer appears in the configuration. The resulting plan is a change to [1] and a destroy of [2]: one deletion, two actions, neither what you intended. Keyed by name under for_each, only the removed key's instance is touched, because every other address still means what it meant.
One real constraint: every value for_each iterates over must be known before any remote operation, and count cannot be unknown either.
Rule: use count only when instances are interchangeable and the number is all that matters; the moment an instance has an identity, key it with for_each.
Changing the map without touching the territory
~2 min
A resource's address is its key in state, so renaming a block reads as "destroy the old, create a new". The moved, removed and import blocks re-point the map without touching real infrastructure — and unlike the state subcommands, they produce a reviewable plan.
The misconception is that these are text edits. That address is a key in state, so renaming a badly-named resource is the classic first disaster: the old address vanishes from the configuration while the new one has nothing bound to it, and the plan is a destroy and a create — of the same live database.
A moved block fixes that: given from and to addresses, the existing object is renamed to the new address before the plan is built, so the run does not destroy it. Nothing about the real resource changes — only its key.
A removed block answers the opposite question — how do you stop managing something without deleting it? Name the address in from and add lifecycle { destroy = false }: the resource leaves state while the object survives. Without it, deleting the configuration destroys the object.
import goes the other way, declaring an address in state for an object that already exists. It does not write configuration for you: you still need a matching resource block, or plan with -generate-config-out to generate them.
All three are plannable, which is the argument for them: terraform state mv and terraform state rm do the same jobs immediately and unreviewably, and modifying state outside a plan or apply can make the tool lose track of managed resources.
Rule: when the shape of your configuration changes but your infrastructure should not, reach for a block that produces a plan, never a command that edits the map directly.
Shared state, and the lock that makes it safe
~2 min
The default backend writes state to a local file, which fails the moment a second person or pipeline runs. A remote backend fixes sharing; locking fixes concurrency. Workspaces give one configuration several states, but are not environment isolation.
The backend defines where state is stored. By default that is local — a file on your disk, invisible to everyone else, and why a second engineer's first apply proposes rebuilding everything. A remote backend lets several people work on one set of resources.
Sharing then creates the concurrency problem, which locking solves. Locking happens automatically on every operation that could write state, and if a lock cannot be acquired the operation stops. -lock=false exists and is explicitly not recommended. Two details matter: not all backends support locking, so a poorly chosen one removes the guarantee silently; and force-unlock needs the lock's identifier and carries a real warning, because unlocking while somebody else holds it lets two writers loose on one file.
One rule catches everybody: a backend block cannot refer to named values — not input variables, not locals, not data sources. You cannot parameterize the bucket by environment the obvious way; the supported route is partial configuration, supplying the remaining arguments at initialization.
Workspaces are the neighboring trap: they give one configuration several named states and expose terraform.workspace, but are documented as inappropriate where separate credentials and access controls are needed, since all share one backend configuration.
Rule: one state per thing that can fail independently, on a locking backend; environments needing different credentials need different configurations, not different workspaces.
A module is a directory with a contract
~2 min
A module is just a directory of configuration; what makes it reusable is its contract — variables in, outputs out, nothing else visible. Locals are private, and a module meant to be called by others must not configure its own providers.
The root module is the set of files in your working directory; child modules are called with module blocks. Inputs are variable blocks — one with no default is required, and the tool asks for a value before it will plan. Outputs are the module's public surface, read as module.<label>.<output>. Locals sit between them and are private, visible only where defined.
Variable precedence is the trap here, because most people assume it backwards. Highest wins: command-line -var and -var-file in the order given, then *.auto.tfvars in lexical order, then terraform.tfvars.json, then terraform.tfvars, then environment variables, then the block's own default. An environment variable does not override a checked-in variables file — it sits just above the default.
Two documented rules save real pain. source must be a literal string, so it cannot be built from an expression; version applies only to registry sources. And a module intended to be called by others must contain no provider blocks — a provider configuration has to outlive every resource it manages, so a module carrying its own removes both at once and strands objects that cannot then be destroyed.
One more: a module that is a thin wrapper around a single resource type is discouraged — if you cannot name it without repeating that type, use the resource directly.
Rule: a module earns its existence by naming a concept, not by hiding a resource — and its contract is its variables and outputs, never its internals.
The language in one pass
~2 min
Configuration is blocks and arguments, and file order never matters. Two things carry most of the confusion: null means "as if omitted" rather than empty, and a value the platform has not produced yet is unknown, which spreads silently through every expression touching it.
Syntactically there is little to learn: arguments assign values to names, blocks contain other content, and every configuration file in a directory is evaluated as one document.
Types are ordinary — string, number, bool; list, set, map; object and tuple — with two edges worth knowing. A set has no ordering and cannot be indexed, so convert it to a list first. And null does not mean empty or zero: setting an argument to null makes the tool behave as if you had omitted it, falling back to its default or erroring if it is mandatory.
Unknown values are the concept nothing prepares you for. When a platform generates an identifier at creation time, that value cannot be predicted during planning; it appears as (known after apply). Unknowns propagate automatically — a known value combined with an unknown one yields an unknown result. That is why a for_each key or a count derived from something you are creating in the same run cannot work: the value does not exist yet.
Functions are a fixed built-in set; you cannot define your own in configuration, though providers can supply some. A few are impure — timestamp() and uuid() return something different every call. Put one in a resource argument and every plan differs from state forever, which reads as a bug and is not.
Rule: when a plan shows a change you cannot explain, check first for an unknown value spreading, then for an impure function baked into an argument.
Four kinds of check, and what each one can catch
~2 min
Validation, custom conditions, check blocks and terraform test fail at different moments and with different force. Two surprise people: a failed check is only a warning, and terraform test provisions real infrastructure unless told otherwise.
terraform validate verifies that a configuration is syntactically valid and internally consistent, including attribute names and value types. What it deliberately does not do is contact anything: no remote service, no provider interface, and it runs regardless of variables or state. A clean validate says your configuration is well-formed, never that an apply will succeed.
Custom conditions are stricter: variable validation blocks run just before planning, and precondition/postcondition blocks on a resource stop the operation when they fail.
check blocks are deliberately weaker, and this catches people out. A check runs as the final step of a plan or apply, and when its assertion fails the result is a warning while the operation continues. That is the intended design — continuous verification that should not block a deploy — but it means a failing check is invisible to any pipeline reading only the exit status.
terraform test is a real test runner: files ending .tftest.hcl hold run blocks with assertions. The default command in a run block is apply, so tests create real infrastructure, and the framework attempts to destroy everything it created when the file ends. command = plan avoids creating anything, and mocked providers let a module be exercised without a live platform.
Rule: match the gate to the failure — validate for shape, conditions for invariants that must stop a run, checks for what is worth knowing but not worth blocking on.
Written by Keentune. We are not affiliated with or endorsed by the organizations whose documentation informs this guide, and any linked sources belong to their respective owners.
All exam, test, and product names and trademarks are the property of their respective owners and are used here for identification and reference only. Keentune is independent study practice — not affiliated with, authorized, or endorsed by any of these organizations.