Keentune

Terraform & IaC curriculum

23 chapters
·
136 concepts
·
free
Everything the adaptive question bank can teach and test in Terraform & IaC, from foundations through advanced practice. Work through it in order, or start practising and let the questions find your level.
New here? Read the Terraform & IaC guide
A free 16-minute primer — the mental model, the mistakes beginners make, and what to practise first.
A. Infrastructure as code and the Terraform workflow
you describe the end state; Terraform derives the steps, so there is no "do this, then that"
applying an unchanged configuration a second time plans no changes; a config that always changes something is a bug
the core loop is write, review a plan, apply; the plan is the artifact a human actually reviews
every run reconciles three things: the configuration, the real infrastructure, and the recorded state
replacing an object beats mutating it, which is why Terraform's answer to an unchangeable attribute is replacement
Terraform core knows no cloud; every API call goes through a separately versioned provider plugin
B. Configuration syntax and file layout
a block is a type, zero or more quoted labels, and a body of name = expression arguments
all .tf files in the directory are merged into one configuration; the order you write blocks in never affects execution
<<EOT starts a multi-line string; <<-EOT additionally strips the shared leading indentation
${...} substitutes an expression, and $${ emits a literal dollar-brace for a downstream templating system
%{if} and %{for} inside a string produce conditional and repeated text, and ~ trims the adjacent whitespace
.tf.json is an equivalent generated-code syntax, and a _override.tf file merges on top of the base definition
C. Types and values
string, number and bool, with automatic conversion between them wherever it is unambiguous
list, set and map each hold one element type; a set is unordered and de-duplicated, a map is keyed by string
an object gives each named attribute its own type, a tuple gives each position its own type
null means "argument not set", which is not the same as an empty string, zero, or an empty list
a value not yet computed prints as "(known after apply)" and makes everything derived from it unknown too
optional(string, "x") in a type constraint lets a caller omit an attribute and supplies the default
D. Expressions
var., local., data., module., each., count., and a bare type.name for a managed resource
both result expressions of a ? b : c must convert to one type, so mixing a string and a list fails
[for ...] produces a tuple, {for k => v ...} produces an object; the brackets choose the result type
the if clause of a for expression drops elements, which is how filtering is expressed without a function
aws_instance.web[*].id collects one attribute across every instance of a resource
dynamic generates repeated nested BLOCKS from a collection; it cannot generate arguments or meta-argument blocks
path.module, path.root, path.cwd and terraform.workspace describe where and how the run is happening
E. Built-in functions
the language has no user-defined functions; only a provider can contribute one
try returns the first argument that evaluates without error, can reports success as a bool
merge, flatten, zipmap and setproduct are how nested iteration is expressed in a language with no loops
file reads bytes as-is; templatefile renders a template whose only variables are the ones you pass it
timestamp, uuid and bcrypt return something new every run, producing a diff that never settles
lookup takes a fallback for a missing key, and element wraps around using the index modulo the list length
F. Input variables
a variable with no default is required, and Terraform prompts for it rather than assuming a value
lowest to highest: default, TF_VAR_ environment, terraform.tfvars, terraform.tfvars.json, *.auto.tfvars in lexical order, then -var and -var-file
a condition plus an error_message rejects bad input during plan instead of failing halfway through an apply
sensitive = true redacts the value in CLI output; the value is still written to state in cleartext
ephemeral = true keeps the value out of state and plan files entirely, at the cost of where it may be used
with nullable = false, an explicitly passed null falls back to the default instead of arriving as null
G. Locals and outputs
a local is a named expression private to its module; no CLI flag or tfvars file can override one
a child module's resources are unreachable from the parent unless an output block exposes the value
an output computed from a sensitive value errors unless the output is itself marked sensitive
depends_on in an output block defers it when the real dependency is not visible in the expression
root-module outputs are printed by apply, readable via terraform output, and stored in the state file
H. Resources and resource behavior
per resource Terraform plans exactly one of: create, update in place, destroy and re-create, or no-op
an attribute the remote API cannot change after creation makes an edit plan a replacement, not an update
state binds a configuration address to a remote object, so renaming the block reads as destroy plus create
deleting a resource block is an instruction to destroy the object, not to stop managing it
terraform_data stores arbitrary values and triggers_replace, superseding null_resource since 1.4
the provider argument picks a named alias configuration for one resource instead of the default
I. count and for_each
count makes a numerically indexed collection whose instances are name[0], name[1], and so on
removing an item from the middle of a counted list re-indexes it and plans a replacement for every item after the gap
for_each addresses instances by map key, so deleting one entry touches only that instance
a list is rejected; toset() converts one, and each element then becomes its own key
the keys must be known during plan, so they cannot be derived from an attribute of a resource this run creates
count = var.enabled ? 1 : 0 toggles a resource, and every reference to it then needs the [0] index
J. lifecycle and depends_on
builds the replacement first, and Terraform propagates the setting to the resource's dependencies so the graph stays acyclic
rejects any plan that would destroy the object, but deleting the resource block deletes the guard along with it
stops a drifting attribute from planning an update; all ignores every attribute after creation
forces replacement when a referenced resource or attribute changes, even though nothing about this resource did
assumptions checked before the change and guarantees checked after, both of which fail the run
lifecycle arguments are read while building the graph, too early for variables or any computed expression
references create ordering automatically; depends_on is only for a dependency Terraform cannot see in an expression
K. Data sources
a data block queries something that already exists and never creates, changes or destroys it
data sources normally resolve while planning, so their values appear as concrete numbers in the diff
an unknown argument or a depends_on pushes the read to apply time and makes everything downstream unknown
looking up an object the same run is creating is a race; reference the resource attribute instead
terraform_remote_state reads another configuration's outputs, and requires read access to that entire state file
L. Providers and version constraints
a provider is identified by its registry source address; the local name is just a label you chose
>=, <=, !=, and the pessimistic ~> which allows the rightmost component to increase
.terraform.lock.hcl records the selected versions and their checksums, and belongs in version control
terraform init honors the lock file; only -upgrade re-selects newer versions within the declared constraints
alias configures the same provider twice, for example two regions, chosen per resource or passed to a module
required_version in the terraform block constrains the CLI itself, which is a separate concern from provider versions
M. The dependency graph
writing aws_subnet.a.id inside another resource is what orders the two; nothing else is needed
the graph must be acyclic, so two resources referencing each other is a hard error, not a slow resolution
independent nodes are visited concurrently, ten at a time by default, adjustable with -parallelism
destruction walks the graph backwards, so dependents are removed before the things they depend on
an ordering failure that only happens sometimes almost always means a real dependency has no reference expressing it
N. State
the mapping from each configuration address to a real object's ID, plus the attribute values last seen
without it Terraform cannot distinguish "never created" from "created and since changed"
every attribute including passwords is stored unencrypted, so the state file is itself a secret
the stored attributes are a refreshed cache for diffing and performance; the remote API is the source of truth
the format is internal and versioned; use the terraform state commands rather than a text editor
state in git means merge conflicts, leaked credentials, and no locking between collaborators
O. Backends and locking
the default local backend writes terraform.tfstate beside the configuration; any team needs a shared remote backend
the backend is configured before expressions are evaluated, so it cannot reference variables or locals
leave the credentials out of the block and supply them with -backend-config at init time
init -migrate-state copies existing state into a new backend, -reconfigure abandons the old association
a lock prevents two concurrent runs from writing conflicting state, and not every backend implements one
clears a lock stranded by a crashed run, and corrupts state if used while another run is genuinely alive
P. Workspaces
a workspace is an additional named state for the same configuration and the same backend
terraform.workspace yields the current name, which is how one configuration varies per environment
every configuration starts in a workspace named default, which cannot be deleted
same backend and same credentials for every workspace, which is why prod is usually a separate configuration instead
Q. Plan and apply
a plan first reconciles state against reality, then compares that result to the configuration
+ create, - destroy, ~ update in place, -/+ destroy then create, +/- create then destroy, <= read
the comment printed beside one attribute is what identifies which change caused the replacement
plan -out then applying that file guarantees you apply exactly the change you reviewed, not a re-planned one
a saved plan holds variable and attribute values in cleartext, so it is an artifact that needs protecting
-refresh-only records drift without changing infrastructure; -refresh=false skips reading the provider at all
-target deliberately skips part of the graph and is documented as an exceptional measure, not a workflow
"objects have changed outside of Terraform" reports out-of-band edits, which the next apply will revert unless ignored
R. Import and refactoring
a declarative import block adopts an existing object on the next apply and shows up in the plan for review
plan -generate-config-out writes starter HCL matching an imported object so you do not hand-transcribe it
an import only records state; if the configuration does not match, the very next plan proposes changes
renaming a resource or wrapping it in a module without a moved block plans a destroy and a create
drops a resource from state while leaving the real object alive, the reviewable form of state rm
the imperative equivalents, which rewrite state immediately with no plan, no review and no audit trail
S. Modules
the working directory is the root module; every module block calls a child module with its own variables and outputs
local path, registry, Git, HTTP archive and object storage, each with its own address syntax
the version argument works for registry sources; a Git source pins with a ref in the URL instead
changing a module's source or version requires terraform init before the next plan will see it
a reusable module should inherit or be passed providers; declaring its own makes it impossible to remove cleanly
a flat root wiring small modules together beats a deep tree threading variables down through layers
a module that only forwards arguments adds indirection without encoding a decision; not every repetition needs an abstraction
T. Provisioners
the docs' own position: provisioners are unmodelable side effects, to be used only when the provider offers nothing
a failed creation-time provisioner marks the object tainted, so the next apply replaces it
when = destroy runs before deletion and may only reference self, count.index and each.key
a provisioner cannot name its own parent resource, because that would be a self-reference; it uses self instead
Terraform has no model of what the script did, so an unchanged config will never re-run it to repair anything
U. Secrets and sensitive data
the marker suppresses console output; it does not encrypt state, the plan file, or provider logs
sensitivity spreads through every expression that touches the value, which is why an unrelated-looking output starts redacting
nonsensitive() strips the mark and is an explicit assertion that the derived value is safe to print
ephemeral resources, ephemeral variables and write-only arguments exist only during the run and are never persisted
reading a secret through a data source puts the plaintext in state; the fix is ephemeral or write-only, not a different data source
credentials belong in the environment or a secret manager, and secret-bearing *.tfvars belong in .gitignore
V. Validation, testing and policy
fmt rewrites files to canonical style; validate checks syntax, types and references without calling any provider API
validation runs against the installed providers' schemas, so it cannot run before terraform init
.tftest.hcl files hold run blocks, each a plan or an apply carrying assert conditions
mock_provider supplies fake attribute values so a test can assert on a plan without creating real infrastructure
a failed check assertion is a warning that does not stop the run, unlike a postcondition which fails it
Sentinel and OPA evaluate the JSON from terraform show -json, so the gate sits between plan and apply
W. CI/CD and collaboration
the pull request is where the plan gets reviewed, and the merge is what authorizes the apply
-input=false and -auto-approve belong in a pipeline and nowhere near an interactive terminal
plan -detailed-exitcode returns 0 for no changes, 1 for error and 2 for changes, which is how a pipeline branches
CI should assume a role through OIDC for the length of the run rather than store long-lived keys
one enormous state is slow to plan and dangerous to apply; split by rate of change and join the pieces with outputs
Keentune is not affiliated with or endorsed by the organizations whose documentation informs these maps.
All about Terraform & IaC practice
Also on your phone
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.
© 2026 SportaApp LLC