Infrastructure as Code
Infrastructure as code turns a cloud console into reviewed text files. This page builds Terraform from the ground up: the declarative idea and the plan-apply loop, the HCL language, state and the S3 backend (including the current locking options), modules and environments, how the dependency graph and lifecycle rules behave, a real AWS example that was validated and tested, how to handle drift, imports and refactors, how to run it all safely in CI, and how Terraform compares with OpenTofu, CloudFormation and the CDK.
Why Infrastructure as Code, and Declarative vs Imperative
Clicking through a cloud console is fine for learning. It is a disaster for running a business. Nobody remembers which boxes were ticked, the staging environment slowly differs from production, rebuilding after a mistake means guesswork, and the only audit trail is "someone changed something on Tuesday". Infrastructure as Code (IaC) replaces the clicking with text files that describe the infrastructure, kept in Git and applied by a tool.
| Without IaC | With IaC |
|---|---|
| State of the environment lives in people's heads and the console | State is described in files, reviewable and diffable |
| Environments drift apart (staging is not like production) | The same code creates every environment; differences are explicit variables |
| Rebuilding is a manual, error-prone project | Rebuilding is "apply the code again" |
| No review of infrastructure changes | Pull request, review, plan output, approval |
| No history | git log and git blame answer who changed what and why |
| Disaster recovery is a hope | Recreate the environment from code (data excluded: back that up separately) |
An imperative tool is a script of steps: "create a VPC, then create a subnet, then attach a gateway". Running it twice is your problem: it may fail, or create duplicates. A declarative tool is given the desired end state: "there is a VPC with this CIDR and two subnets". The tool compares that to what exists and works out the steps itself. Running it twice changes nothing the second time. That property is called idempotence.
| Imperative (shell, SDK scripts) | Declarative (Terraform, CloudFormation) | |
|---|---|---|
| You describe | The steps | The end state |
| Second run | Repeats the steps (may fail or duplicate) | No-op if nothing changed |
| Deleting something | You must write the delete step | Remove it from the code and the tool deletes it |
| Preview of changes | None unless you build it | Built in (terraform plan) |
| Knows what it owns | No | Yes, via recorded state |
| Good for | One-off tasks, glue, data migrations | Long-lived infrastructure |
Terraform is HashiCorp's declarative IaC tool. You write configuration in HCL (HashiCorp Configuration Language). Terraform talks to clouds and services through providers (plugins), including AWS, Azure, Google Cloud, Kubernetes and many SaaS products. The same workflow then applies to all of them. Chapter 11 compares it with OpenTofu, CloudFormation and the AWS CDK.
Examples use AWS because the rest of this guide does. Everything about the workflow, state, modules and CI applies to any provider. Page 05 covered pipelines in general; chapter 10 here applies them to Terraform.
init, validate, plan, apply, destroy
Everything Terraform does fits in a short loop. Learn the loop first, then the language.
| Command | What it does | Changes real infrastructure? |
|---|---|---|
terraform init | Downloads providers and modules, configures the backend, writes .terraform/ and the lock file | No |
terraform fmt | Rewrites files in the canonical style (-check fails if changes are needed) | No |
terraform validate | Checks syntax and internal consistency (types, references) without contacting any API | No |
terraform plan | Computes the difference between configuration, state and reality, and prints the actions | No (but it reads, and locks the state) |
terraform apply | Plans (or takes a saved plan), asks for approval, executes, updates state | Yes |
terraform destroy | Plans and executes deletion of everything in the state | Yes |
terraform output | Prints the root module's outputs | No |
terraform state list/show | Inspects what is in the state | No |
terraform plan -out=tfplan saves the plan, and terraform apply tfplan executes exactly that plan, with no second guess. This is the safe pattern for pipelines: the plan that was reviewed is the plan that runs. Running a bare terraform apply computes a new plan and asks for confirmation; -auto-approve skips the question.
| Symbol | Meaning |
|---|---|
+ | Create |
- | Destroy |
~ | Update in place |
-/+ | Destroy and recreate (replacement). Look for the "forces replacement" comment, and ask whether that is acceptable for this resource |
- The last line (
Plan: X to add, Y to change, Z to destroy) is the first thing to read. A surprise "destroy" on a database is the reason plans exist. -detailed-exitcodemakesplanexit with code 2 when there are changes, which pipelines can use to detect drift.-replace=ADDRESSforces recreation of one resource; it superseded the olderterraform taint.-refresh-onlyproduces a plan that only updates the state to match reality (chapter 9).
- The plan is the safety net, and it only works if a human reads it. A plan that says "1 to destroy" on a resource you did not touch means something else changed (a rename, a provider upgrade, drift). Understand it before approving.
HCL: Blocks, Arguments, Expressions, Variables, Locals, Outputs and Data
HCL is a small configuration language. A file is a collection of blocks; a block has a type, zero or more labels and a body of arguments (name = value) and nested blocks. Terraform reads every *.tf file in a directory as one module, so how you split the files is only for humans (the convention is main.tf, variables.tf, outputs.tf, versions.tf).
| Block | Purpose | Example |
|---|---|---|
terraform | Settings: required Terraform version, required providers, backend | required_version = ">= 1.10" |
provider | Configures a provider (region, credentials, default tags) | provider "aws" { region = var.region } |
resource | A thing Terraform creates and manages | resource "aws_vpc" "main" { ... } |
data | Reads something that already exists (read-only) | data "aws_caller_identity" "current" {} |
variable | An input to the module | variable "environment" { type = string } |
locals | Named intermediate values (private to the module) | locals { name = "${var.project}-${var.environment}" } |
output | A value the module exposes to its caller or to the CLI | output "vpc_id" { value = aws_vpc.main.id } |
module | Calls another module (chapter 6) | module "network" { source = "..." } |
A resource address such as aws_s3_bucket.artifacts is TYPE.NAME; it identifies the resource in the configuration and in the state. Referencing another resource's attribute (aws_vpc.main.id) is how you wire things together, and it also tells Terraform the order (chapter 7).
typeconstrains the value (string,number,bool,list(string),map(string),object({...})).validationrejects bad input at plan time with your own message. Since Terraform 1.9 a validation can refer to other variables and data sources, not just the variable itself.sensitive = truehides the value in CLI output. It does not keep it out of the state (chapter 5).defaultmakes a variable optional; without it Terraform asks for a value.
Values arrive from several sources. If the same variable is set in several places, the last of these wins (lowest to highest precedence): the default, environment variables named TF_VAR_name, terraform.tfvars, terraform.tfvars.json, *.auto.tfvars and *.auto.tfvars.json in lexical order, and finally -var / -var-file on the command line (in the order given). Order checked against the Terraform variables documentation.
locals give names to expressions so you do not repeat them. The conditional expression is condition ? a : b, and merge() combines maps. A common idiom is count = condition ? 1 : 0 to create a resource only in some environments.
| Feature | Example | Notes |
|---|---|---|
| String interpolation | "${var.project}-${var.environment}" | Plain references need no interpolation: bucket = aws_s3_bucket.x.id |
for expression | [for s in aws_subnet.private : s.id] | Builds a list; { for k, v in m : k => v } builds a map |
| Splat | aws_subnet.list[*].id | Shorthand for a simple for over a list |
cidrsubnet(prefix, newbits, num) | cidrsubnet("10.20.0.0/16", 8, 1) is 10.20.1.0/24 | Carve subnets out of a VPC range |
slice, length, contains, toset, merge, lookup, try, can | contains(["dev","stg"], var.env) | Collection helpers; can() is handy in validations |
jsonencode / file / templatefile | policy = jsonencode({...}) | Build JSON (IAM policies) from HCL objects rather than hand-written strings |
terraform console opens a prompt where you can type cidrsubnet("10.20.0.0/16", 8, 1) or [for n in ["a","b"] : upper(n)] and see the result. It is the quickest way to learn the functions.
A data block reads something that exists outside this configuration: an AMI, the current account id, the list of availability zones, a VPC created by another team. Nothing is created or destroyed. A data source whose arguments depend on resources that do not exist yet is read during apply, not plan.
count vs for_each, dynamic Blocks, Preconditions and Checks
Infrastructure repeats: three queues, one subnet per availability zone, a rule per port. Terraform has two meta-arguments to create several instances of a resource, and the choice between them matters a lot.
count | for_each | |
|---|---|---|
| Takes | A number | A map or a set of strings |
| Instance identity | Position: aws_sqs_queue.by_count[0] | Key: aws_sqs_queue.by_key["a"] |
| Remove the middle item | Everything after it shifts index, so Terraform wants to destroy and recreate or rename those | Only that key is removed |
| Instances differ in settings | Awkward (index lookups) | Natural: each.key, each.value |
| Good for | On/off switches (count = var.enabled ? 1 : 0) and truly identical copies | Almost everything else |
| Limitation | - | Keys must be known at plan time (not derived from a resource that is not created yet) |
- With
count, instance 2 is "the third item of the list". Delete the second item and the third becomes instance 1, so Terraform plans to change or destroy a resource you never touched. Preferfor_eachkeyed on a stable name. Keepcountfor the 0-or-1 switch.
Some resources have repeatable nested blocks (security-group ingress, IAM statement, an S3 lifecycle rule). You cannot put for_each on a nested block, so use a dynamic block: it generates one nested block per element.
Use dynamic sparingly. It is harder to read than plain repeated blocks. If you have two fixed ports, write two ingress blocks.
validation guards inputs. For assumptions about resources, a lifecycle precondition or postcondition stops the run with a clear message, and a top-level **check block runs assertions after plan/apply and reports a warning** without blocking (useful for "the bucket still has versioning on"). Both forms were validated for this page.
Providers, the Lock File, State and Remote Backends
Terraform core knows nothing about AWS. A provider is a plugin that translates resources into API calls. You declare which providers you need (and acceptable versions) in required_providers; terraform init downloads them from a registry.
version = "~> 6.0"means "6.x, but not 7". It allows minor and patch updates and blocks a major release that may break your code.terraform initrecords the exact version selected, and checksums of the packages, in **.terraform.lock.hcl. Commit this file.** It makes every machine and pipeline use the same provider build, and changes to it show up in code review.terraform init -upgradedeliberately moves to the newest version allowed by your constraints and rewrites the lock file.- Provider upgrades can change plans. Upgrade in its own pull request, read the plan, and watch the provider's changelog.
Terraform needs to know which real objects belong to which resource blocks. That mapping is the state: a JSON document recording, for every managed resource, its address, its provider and all its attributes as last seen. When you rename a resource in code, Terraform cannot tell it is "the same" unless you tell it (chapter 9); when you delete a block, state tells Terraform what to delete.
- State is how Terraform maps
aws_s3_bucket.artifactsto the actual bucket, caches attributes, and records dependencies. - By default state is a local file
terraform.tfstate. That works for one person and breaks for teams: no sharing, no locking, easy to lose. - State contains secrets. Terraform documentation is explicit: state is stored as plaintext and includes any secret values you defined in the configuration (database passwords, generated keys, tokens).
sensitive = trueonly hides values in CLI output. Treat the state, and saved plan files, as sensitive data: keep them out of Git, encrypt them, restrict access. - Do not edit state by hand. Use
terraform state list,show,mv,rmand the config-driven blocksmovedandimportinstead.
A backend decides where state is stored. For AWS teams the common choice is the s3 backend. It stores one object per state, and supports state locking so two runs cannot modify the same state concurrently.
| Locking option | Mechanism | Status |
|---|---|---|
**use_lockfile = true** | Terraform writes a lock object next to the state (<key>.tflock) in the same S3 bucket, using S3 conditional writes. No extra service needed | Experimental in Terraform 1.10, generally available in 1.11 (changelog) |
**dynamodb_table** | A DynamoDB table with a LockID key holds the lock | Deprecated since 1.11. Still works; both can be enabled at once during migration |
- Terraform documentation for the S3 backend recommends enabling bucket versioning, so you can recover a previous state after an accidental deletion or a bad apply.
- Set
encrypt = true(and optionally a KMS key), block public access, and give state access only to the roles that run Terraform. Withuse_lockfile, the role also needss3:GetObject,s3:PutObjectands3:DeleteObjecton the.tflockobject (S3 backend docs). - The bucket and its settings are infrastructure too, but chicken-and-egg: create the state bucket once with a small bootstrap configuration (local state, or by hand) and then never manage it from the stacks that use it.
- If a run crashes and leaves a stale lock,
terraform force-unlock LOCK_IDreleases it. Do it only after you are sure no run is active. - Changing backend settings later:
terraform init -migrate-statecopies existing state to the new location;-reconfigureignores the saved configuration without migrating.
use_lockfile needs Terraform 1.10 or newer, and 1.11 or newer for the generally-available behaviour, so the example sets required_version = ">= 1.10". If you must support older versions, use dynamodb_table. New setups should choose the lockfile approach, because the DynamoDB arguments are deprecated.
- Anything that is in
terraform.tfstatecan contain passwords and keys, and a committed file is in history forever. Add.terraform/,*.tfstate,*.tfstate.*and*.tfplanto.gitignore, use a remote backend, and prefer designs where secrets are not created by Terraform at all (read them from a secrets manager at runtime). Terraform 1.10 and later also offer ephemeral values and write-only arguments for keeping some values out of state; see the HashiCorp documentation on sensitive data.
Modules and Environments: Workspaces vs Separate State
A module is a directory of .tf files. Every configuration is already one (the root module). A child module is one you call from another with a module block, passing inputs and reading outputs. Modules are Terraform's unit of reuse: one "network" module used by every environment, instead of copied code.
| Part | Convention | Why |
|---|---|---|
variables.tf | The module's inputs, with types, descriptions and validation | The module's public API |
outputs.tf | Values callers need (ids, ARNs) | The only way to read from inside a module |
main.tf (and others) | Resources | Split by concern once it grows |
versions.tf | required_version and required_providers (without provider blocks) | Provider configuration belongs to the root module, not to reusable modules |
README / examples | How to call it | Modules are products with users |
- Sources: the public Terraform Registry (
namespace/name/provider), a Git repository (git::https://...?ref=v1.2.0), or a local path (./modules/network). - Always version modules from a registry or Git (
version = "~> 6.0", or?ref=a tag). An unpinned module means the nextinit -upgradecan change your infrastructure. - Read third-party modules before you adopt them: a popular community module is convenient, but it is also a large amount of code that you now depend on.
- Keep modules small and focused (network, database, service), pass what varies as variables, and avoid modules that wrap a single resource without adding anything.
- Treat moving a resource into or out of a module as a refactor that changes its address: use
movedblocks (chapter 9).
The same configuration usually has to exist as dev, staging and production. Terraform offers workspaces: several named states for one configuration and backend. With the S3 backend the default workspace keeps the state at the configured key, and other workspaces are stored under <workspace_key_prefix>/<workspace>/<key> (the default prefix is env:).
| Separate directories / state keys per environment | Workspaces | |
|---|---|---|
| Isolation | Strong: own state, own backend key, own credentials and roles | Weak: same backend and credentials, only the state name changes |
| Risk of applying to the wrong environment | Low: you are in a directory called prod | Higher: depends on the selected workspace (terraform workspace show) |
| Different configuration per environment | Easy (different files, modules and variables) | Awkward: conditionals on terraform.workspace everywhere |
| Code duplication | Small if environments call shared modules | None |
| Good for | Dev / staging / prod with different accounts or approvals | Temporary copies of the same stack: feature branches, test runs |
A small root configuration per environment (often one AWS account each) that calls shared modules with environment-specific variables, and its own state key and its own pipeline role. Use workspaces for short-lived copies, not as the boundary between production and everything else. (The HashiCorp documentation likewise says workspaces alone are not a good fit for strong isolation of deployment environments with different credentials.)
The Dependency Graph and the lifecycle Block
You never write "create the VPC first". When a resource refers to another (vpc_id = aws_vpc.main.id), Terraform records an implicit dependency and builds a directed acyclic graph of all resources. It walks the graph, creating independent resources in parallel, and deletes in reverse order.
- An implicit dependency comes from a reference. This is almost always what you want.
depends_on = [aws_iam_role_policy.x]declares an explicit dependency when there is no reference but there is a real ordering need (for example a permission that must exist before a service starts). Use sparingly; it also makes plans more conservative.terraform graphprints the graph in DOT format for Graphviz.- Parallelism is limited by
-parallelism(default 10 concurrent operations); lower it if an API rate-limits you. - Cycles (A needs B, B needs A) are an error. Break them by splitting a resource (for example a security group and separate rule resources).
lifecycle changes how Terraform treats a resource when it plans changes. It is a nested block valid on every resource.
| Argument | Effect | Typical use | Risk |
|---|---|---|---|
prevent_destroy = true | Any plan that would destroy this resource fails with an error | Databases, state buckets, anything that holds data | It blocks terraform destroy for the whole stack too. It does not protect against someone removing the block, or against destruction outside Terraform |
create_before_destroy = true | When replacement is needed, create the new one first and delete the old one afterwards | Things that must not have downtime when replaced (launch templates, certificates, some IAM) | Names must not collide (use name_prefix or random suffixes); some resources cannot coexist |
ignore_changes = [attr] | Ignore changes to the listed attributes made outside Terraform | Attributes owned by something else (an autoscaler changing desired capacity, a pipeline changing an AMI id) | Hides real drift for that attribute; document why |
replace_triggered_by = [...] | Force replacement when another resource or attribute changes | Roll a resource when a dependency changes | Replacement can cause downtime |
precondition / postcondition | Assertions that stop the run with your message (chapter 4) | Document and enforce assumptions | - |
The bucket cannot be destroyed by a plan while prevent_destroy is set. The launch template is created before the old one is removed, and Terraform ignores a changed image_id, because in this scenario another process rotates AMIs.
prevent_destroyis a guard rail in the plan, not a lock on the resource. Combine it with IAM permissions that do not allow deletion, backups, deletion protection on the resource itself (for RDS,deletion_protection), and review of plans.
A Real AWS Example: VPC, S3 Bucket and IAM Role
This example creates a VPC with one private subnet per availability zone, a versioned private S3 bucket and an IAM role that an ECS task could use to read and write that bucket. It is deliberately small and was validated for this page: terraform init, terraform validate and terraform fmt -check all succeeded with Terraform v1.17.1 and AWS provider 6.71.0 (the version init selected for the ~> 6.0 constraint on 2026-10-31). It was not applied to a real AWS account, so no plan output is shown as real.
default_tags in the provider block puts the same tags on every taggable resource, which keeps cost allocation and ownership consistent without repeating tags everywhere. variables.tf is the file shown in chapter 3.
- **
for_eachover a map built from aforexpression** gives each subnet a stable key (the availability zone name).cidrsubnet(var.vpc_cidr, 8, index)carves a /24 per zone out of the /16. - Account id in the bucket name makes the globally-unique S3 name predictable and avoids collisions.
- S3 settings are separate resources (
aws_s3_bucket_versioning,aws_s3_bucket_public_access_block): the provider moved them out of the bucket resource, so each setting has its own address and lifecycle. - **
aws_iam_policy_documentdata sources** build IAM JSON from HCL, which gives syntax checking and references (the bucket ARN) instead of hand-assembled strings. - The role has a least-privilege inline policy on exactly one bucket. Wildcard
*actions or resources are what IaC security scanners flag first.
The Success! line is real output from the validation run described above; the commands that need AWS are shown without output on purpose.
Terraform 1.6 made terraform test generally available: test files end in .tftest.hcl, live next to the configuration or in a tests/ directory, and contain run blocks that execute plan or apply and then check assert conditions. Terraform 1.7 added mock providers, so a test can run with no cloud account at all. The test below mocks the AWS provider and checks two things.
command = planevaluates the plan only, so the tests are fast and need no credentials when the provider is mocked.expect_failures = [var.environment]asserts that the validation rule rejects a bad value. A test that proves a guard rail works is valuable.command = apply(the default) would create real resources and destroy them at the end: use it for integration-style tests in a sandbox account.- Static checks complement tests: **
terraform validate(syntax and types), TFLint (a pluggable Terraform linter: provider-specific mistakes such as invalid instance types), and Checkov** (static analysis of IaC for security misconfiguration, such as public buckets or unencrypted storage). Chapter 10 puts them in CI.
- Account ids, AMI ids, availability zone names and ARNs pasted into the code break the moment you create a second environment or region. Use variables,
datasources (aws_caller_identity,aws_availability_zones) and references.
Drift, Import, moved Blocks and Refactoring Safely
Drift is any difference between what Terraform believes exists and what actually exists, caused by changes made outside Terraform: someone edits a security group in the console, an autoscaler changes a count, another tool tags resources. Every terraform plan first refreshes (reads current values through the provider) and then compares, so ordinary plans already reveal drift as proposed changes.
| Situation | What you want | How |
|---|---|---|
| Console change that should be undone | Reality back to the code | terraform apply (the plan reverts it) |
| Console change that should be kept | Code and state updated | Edit the configuration to match, then plan until clean |
| See drift without proposing configuration changes | Just detect | terraform plan -refresh-only shows only differences between state and reality; apply -refresh-only accepts them into the state |
| Scheduled detection | Alert when the environment drifts | A nightly CI job running terraform plan -detailed-exitcode (exit code 2 means changes are pending) |
| Attribute owned by something else | Stop fighting it | lifecycle { ignore_changes = [...] } (chapter 7) |
The cultural fix matters more than the tooling: if anyone can change production in the console, drift is a certainty. Give people read access, give pipelines write access, and keep a break-glass role that triggers a follow-up to reconcile the code.
Resources created by hand before IaC can be imported: you write the matching resource block and tell Terraform which real object it represents. Since Terraform 1.5 you can do this declaratively with an import block, which goes through the normal plan and review flow; the older terraform import command still exists but changes state immediately and is not reviewable.
- Run
terraform plan: it shows "1 to import". Differences between your block and the real object show as changes; edit the block until the plan shows no changes (or only intended ones), then apply. terraform plan -generate-config-out=generated.tf(experimental in Terraform 1.17.1'splan -help) can draft the resource block for you. Review and clean the result.- Import brings one object into state. It does not discover dependencies or write a perfect configuration; large estates need patience and tools.
Terraform identifies a resource by its address. If you rename aws_s3_bucket.artifacts_old to aws_s3_bucket.artifacts in the code, Terraform sees "delete the old one, create a new one", which for a bucket or database is a catastrophe. A **moved block (Terraform 1.1+) records the rename in code, so the plan shows a move** instead of a destroy and create. The moved block in the file above does exactly that.
| Refactor | How to keep the real object |
|---|---|
| Rename a resource | moved { from = aws_x.old to = aws_x.new } |
| Move a resource into a module | moved { from = aws_x.a to = module.app.aws_x.a } |
Change count to for_each | moved from aws_x.a[0] to aws_x.a["name"] |
| Stop managing a resource without deleting it | A removed block with destroy = false (Terraform 1.7+), or terraform state rm (immediate, not reviewable) |
| Split one big state in two | terraform state mv / import into the new state, in a planned, reviewed migration |
Make the refactor in its own pull request with no functional changes. A good plan says "Plan: 0 to add, 0 to change, 0 to destroy" plus the moves. If it shows any destroy, stop and fix the moved blocks before applying. Keep moved blocks in the code for a while so that every environment migrates, not only the first one that applied.
- The plan will propose destroying the old resource and creating a new one with the new name. For stateless resources that is noise; for a database or bucket it is data loss. Always check that a rename produces a "has moved" line and no destroy.
Running Terraform in CI: Plan on Pull Request, Apply on Merge
Applying from laptops means the state of production depends on who ran what last, with whose credentials. The standard answer is a pipeline (page 05) that holds the credentials and is the only thing allowed to apply.
| Event | Pipeline does | Why |
|---|---|---|
Pull request touching infra/ | fmt -check, validate, lint and security scan, plan, and post the plan to the pull request | Reviewers see exactly what will change before merging |
| Merge to main | init, then apply (ideally the saved plan, or a re-plan with a required approval for production) | The reviewed change is applied by an audited system |
| Schedule (nightly) | plan -detailed-exitcode and alert on exit code 2 | Detect drift |
The file parses as YAML and the action versions exist (hashicorp/setup-terraform@v4, aws-actions/configure-aws-credentials@v6, actions/checkout@v6, checked via the GitHub API on 2026-10-31), but the workflow was not run against a real repository. In a real pipeline pin the actions to commit SHAs (page 05).
- OIDC, not stored keys.
permissions: id-token: writelets the job request a token, andconfigure-aws-credentialsexchanges it for short-lived credentials (page 05, chapter 06). Use two roles: a read-mostly role for pull-request plans (trusting pull request subjects) and a write role for apply that trusts onlymainor a protected GitHub environment. - **The apply job uses an
environment**, so a required reviewer can gate production, as in any delivery pipeline. - Plan and apply must use the same state and the same code. Saving the plan (
-out) and applying that file, or re-planning inside the apply job with a human approval, avoids "the plan I reviewed is not what ran". - One run per state at a time: state locking protects the file, and a CI
concurrencygroup stops two pipelines racing and failing on the lock. Use-lock-timeout=5mso a run waits briefly instead of failing instantly. - Non-interactive flags:
-input=falsemakes a missing variable an error instead of a hang. - Plan files are sensitive. A saved plan can contain secret values; store it as a short-lived, restricted artifact, never publicly.
| Tool | What it checks | Notes |
|---|---|---|
terraform fmt -check | Formatting | Zero-config, fast |
terraform validate | Syntax, references, types | Needs init; no cloud access |
| TFLint | A pluggable Terraform linter: provider-aware mistakes, deprecated syntax, naming rules | Rule sets (for example for AWS) are plugins you enable |
| Checkov | Static analysis of Terraform (and other IaC) for security and compliance misconfiguration, also able to scan plan output | Maintained by Prisma Cloud |
terraform test | Your own assertions about the configuration | Chapter 8 |
Run these before plan, because they fail in seconds and need no credentials, and let security findings block the merge request the same way a failing unit test does.
- Policy as code (for example Open Policy Agent / Conftest checks against
terraform show -json tfplan, or Sentinel in HashiCorp's commercial offerings) can reject plans that violate rules, such as public buckets or disallowed regions. - Cost estimation tools can read the plan and show the monthly cost change on the pull request.
- Protect production state: restrict who can read it, enable bucket versioning and keep cross-account access to the minimum.
Terraform vs OpenTofu vs CloudFormation vs CDK, and Common Mistakes
| Terraform | OpenTofu | CloudFormation | AWS CDK | |
|---|---|---|---|---|
| What it is | HashiCorp's IaC tool; HCL; many providers | A fork of Terraform; same HCL and provider model | AWS's native IaC service; JSON/YAML templates | Write infrastructure in a programming language (TypeScript, Java, Python, ...) |
| Clouds | Any with a provider (AWS, Azure, GCP, SaaS) | Same, using the provider ecosystem | AWS only | AWS (it generates CloudFormation) |
| State | You manage it (remote backend) | You manage it (remote backend) | AWS manages it (stack state) | CloudFormation stack state |
| Preview | plan | plan | Change sets | cdk diff (then CloudFormation change set) |
| Rollback on failure | No automatic rollback: a failed apply leaves a partial result, fix and re-apply | Same | Stacks roll back automatically on a failed create or update | Same as CloudFormation |
| Drift detection | plan / -refresh-only | Same | Built-in drift detection feature | Via CloudFormation |
| Strength | Multi-cloud, huge ecosystem, readable | Open-source governance, Terraform-compatible workflow | No state to host, deep AWS integration | Loops, types and tests in a real language |
| Cost to watch | License terms (below) | Different feature roadmap over time | Verbose templates, AWS-only | Two layers (code and generated template) |
License facts, as verified on 2026-10-31. Terraform versions up to 1.5.x are under the Mozilla Public License 2.0 (the LICENSE file of the v1.5 tag says so). The Terraform repository's current LICENSE is the Business Source License, with the Licensed Work stated as "Terraform Version 1.6.0 or later", and with IBM named as licensor. Terraform 1.6.0 was released in October 2023. OpenTofu began as a fork in response to the license change and is described in its own FAQ as given to the Linux Foundation; its repository carries the MPL 2.0 license. The BSL is a source-available license that restricts some commercial uses (read the license text, and ask your legal team, before assuming it affects or does not affect your use). OpenTofu's current release as of this check was 1.13.2.
For a team using Terraform to manage its own infrastructure the day-to-day experience is the same either way, because OpenTofu keeps the HCL language and the plan/apply workflow. Decide on license policy, tool support (CI integrations, scanners) and roadmap, then pick one per repository and do not mix the two against the same state without checking compatibility for your versions.
- **Committing state or
.tfvarswith secrets** to Git. State is plaintext and holds secret values. - Manual console changes next to Terraform: drift, surprise reverts, and "who changed this?".
- One giant state for everything. Slow plans, large blast radius, and a lock that blocks every team. Split by lifecycle and ownership (network, data, services) and by environment.
- **
terraform applywithout reading the plan,** or approving a plan that differs from the reviewed one. - Unpinned providers and modules (no lock file committed, no
version), so a routine run upgrades something. - **
countover an edited list,** causing reshuffled instances. Usefor_each. - **Renaming or moving resources without
movedblocks.** - Local state in a team, with no backend, no lock and no backup.
- Using workspaces as the only prod/dev boundary, then applying to the wrong one.
- Giving the pipeline admin rights everywhere. Scope roles per environment and per purpose (plan vs apply).
- **Treating
-targetas a normal tool.** It bypasses the graph and leaves the rest out of sync; it is for emergencies.
Questions You Should Be Able to Answer
Crisp answers for the questions that come up in interviews on this topic.
- Q: What is Infrastructure as Code and why use it?
Describing infrastructure in versioned files applied by a tool. It gives repeatable environments, review through pull requests, history, fast rebuilds and less drift than console changes. - Q: Declarative vs imperative?
Declarative describes the desired end state and the tool computes the steps (idempotent, has a plan); imperative lists the steps and you handle ordering and repeat runs. Terraform and CloudFormation are declarative. - Q: Explain the Terraform workflow.
initdownloads providers and configures the backend;validatechecks the configuration;plancompares configuration, state and real resources and shows the actions;applyexecutes (preferably a saved plan) and updates state;destroyremoves everything managed. - Q: What is Terraform state and why does it matter?
A mapping between resource addresses in the code and real objects, plus their attributes. It enables diffs, deletions and dependency tracking. It must be shared (remote backend), locked, protected and treated as sensitive because it contains secret values in plaintext. - Q: How do you configure remote state on AWS with locking?
Thes3backend with a versioned, encrypted, private bucket, anduse_lockfile = true(Terraform 1.10 experimental, 1.11 GA) so a lock object is written in S3 with a conditional write. The older approach is a DynamoDB table viadynamodb_table, now deprecated. - Q: count vs for_each?
countidentifies instances by index, so removing an element in the middle shifts the others;for_eachidentifies them by key, so only the removed key changes. Usefor_eachfor collections andcountfor 0-or-1 switches. - Q: What is a module and how do you version one?
A directory of Terraform files called with amoduleblock, with inputs (variables) and outputs. Version registry modules withversionconstraints and Git modules with a?ref=tag, so upgrades are deliberate. - Q: Workspaces or separate state per environment?
Separate state (and ideally separate credentials) per environment gives strong isolation and easier per-environment differences; workspaces share backend and credentials and suit temporary copies of one stack. - Q: What is drift and how do you handle it?
Reality differing from state because of changes outside Terraform.planshows it after refresh; fix by applying (revert) or by updating the code;plan -refresh-onlyinspects it, and a scheduledplan -detailed-exitcodedetects it. Reduce it by restricting console write access. - Q: How do you bring an existing resource under Terraform?
Write the resource block and animportblock (Terraform 1.5+) with the resource id, run plan to review the diff, adjust until clean, apply. The olderterraform importcommand changes state immediately without review. - Q: How do you rename a resource without destroying it?
Add amovedblock from the old address to the new one (Terraform 1.1+). The plan then shows a move, not a destroy and create.terraform state mvdoes the same immediately but is not reviewable. - Q: What do prevent_destroy, create_before_destroy and ignore_changes do?
Fail any plan that would destroy the resource; create the replacement before deleting the old one; and ignore changes to listed attributes made outside Terraform. Each has trade-offs: prevent_destroy is only a plan guard, create_before_destroy needs non-colliding names, ignore_changes hides drift. - Q: How does Terraform decide the order of operations?
From references between resources, which form a dependency graph. Independent resources run in parallel, deletion runs in reverse order, anddepends_onadds explicit edges where a reference does not exist. - Q: How do you test Terraform code?
fmtandvalidate, linters such as TFLint, security scanners such as Checkov,terraform test(Terraform 1.6+, with mock providers from 1.7) for assertions, and plans reviewed in CI. Real apply tests go in a sandbox account. - Q: How would you run Terraform in CI safely?
Plan on pull request and post it for review; apply only from main by the pipeline with a reviewed or saved plan; OIDC to AWS with separate roles for plan and apply; state locking and a concurrency group; plan files and state treated as sensitive. - Q: Terraform vs CloudFormation?
Terraform is multi-cloud with providers, a plan step, and externally managed state. CloudFormation is AWS-only, AWS manages the state, has change sets, automatic rollback and built-in drift detection. Terraform failures leave partial changes you must re-apply. - Q: What happened with the Terraform license, and what is OpenTofu?
Terraform 1.6.0 and later are under the Business Source License (earlier versions are MPL 2.0). OpenTofu is a fork created in response, now under the Linux Foundation, which keeps HCL and the plan/apply workflow under MPL 2.0.
Cheat Sheet and Recap
| Topic | Remember | Detail |
|---|---|---|
| Workflow | init, validate, plan, apply, destroy | plan -out=tfplan then apply tfplan |
| Declarative | Describe the end state, tool computes steps | Idempotent; plan shows the diff |
| Variables | type, validation, sensitive | Precedence: default, env, tfvars, auto.tfvars, -var (last wins) |
| Repetition | for_each over count | count only for 0-or-1 |
| Provider versions | ~> constraint + committed .terraform.lock.hcl | init -upgrade to move deliberately |
| State | Plaintext, contains secrets, never commit | Remote backend, encrypted, versioned, locked |
| S3 locking | use_lockfile = true | GA in 1.11; dynamodb_table deprecated |
| Modules | Small, versioned, provider config in root | Registry version or Git ?ref= |
| Environments | Separate state per environment | Workspaces for temporary copies |
| Graph | References create order; independent = parallel | depends_on only when needed |
| lifecycle | prevent_destroy, create_before_destroy, ignore_changes | Each has caveats |
| Refactor | moved blocks (1.1), import blocks (1.5) | Plan must show moves, not destroys |
| Testing | validate, TFLint, Checkov, terraform test | Mock providers since 1.7 |
| CI | Plan on PR, apply on merge, OIDC, two roles | Saved plan or approved re-plan |
| License | Terraform 1.6.0+ is BSL; OpenTofu is MPL 2.0 fork | Check legal needs yourself |
- IaC replaces console clicks with reviewed, versioned, repeatable code. Declarative tools compute the steps from the desired end state.
- The plan is the safety net. Read it, save it, and apply exactly what was reviewed.
- State is the mapping and the secret store. Remote, locked, encrypted, versioned and never committed.
- Choose stable identities:
for_eachkeys andmovedblocks protect real resources during refactors. - Pin everything: provider versions with a committed lock file, module versions, Terraform version.
- Isolate environments with separate state and credentials; use workspaces only for temporary copies.
- Automate with a pipeline: plan on pull request, apply on merge, OIDC instead of stored keys, scanners and tests before the plan.
- Detect and prevent drift by restricting console writes and running scheduled plans.
Checked on 2026-10-31. Executed: terraform init, validate, fmt -check and terraform test (with a mocked AWS provider) on the example in chapter 8 and the snippets of chapters 3, 4, 6, 7 and 9, with Terraform v1.17.1 (the hashicorp/terraform:latest image) and hashicorp/aws 6.71.0; the module snippet was initialised against the registry (terraform-aws-modules/vpc/aws, 6.x). Nothing was applied to AWS. From documentation and changelogs: S3 backend use_lockfile (experimental in 1.10, generally available in 1.11, DynamoDB arguments deprecated), state plaintext and sensitive-data guidance, variable precedence, dependency lock file behaviour, moved (1.1), import blocks (1.5), terraform test GA (1.6) with mocks (1.7), Terraform repository licenses (MPL 2.0 through 1.5, BSL for 1.6.0 and later) and the OpenTofu FAQ. Not verified here: the exact date HashiCorp announced the license change, HashiCorp's statements about third-party use under the BSL, and any CloudFormation or CDK behaviour beyond the general characteristics listed in chapter 11. Re-check against the docs for your Terraform version.
- 05 CI/CD Pipelines - the pipeline concepts that chapter 10 reuses: OIDC, approvals, environments, supply-chain security.
- 03 Kubernetes Core - what Terraform often provisions (a cluster) and what GitOps then runs on it.
- AWS - the services these examples create.
- DevOps Map - the whole section.