## 1. CLI Commands Cheatsheet ### `terraform init` > Initialize working directory — downloads providers, modules, sets up backend. **Always run first.** |Flag|Description| |---|---| |`-upgrade`|Upgrade providers and modules to the latest version allowed by constraints; also updates `.terraform.lock.hcl`| |`-backend=false`|Skip backend initialization (useful for `fmt`/`validate` in CI without credentials)| |`-backend-config=file.hcl`|Supply backend config values from an external file (partial config pattern for env-specific backends)| |`-backend-config="key=val"`|Supply individual backend key-value pairs on the CLI (can be repeated)| |`-reconfigure`|Discard existing backend config and re-initialize from scratch (use when changing backend type)| |`-migrate-state`|Migrate existing state from old backend to new backend during re-initialization| |`-get=false`|Skip downloading modules (providers still initialized)| |`-input=false`|Disable interactive prompts; fail if input is required (essential for CI)| |`-no-color`|Disable ANSI color codes in output (use in CI logs that don't render ANSI)| --- ### `terraform plan` > Compute and display the execution plan — what will be created, changed, or destroyed. **Always read before apply.** |Flag|Description| |---|---| |`-out=tfplan`|Save the plan to a binary file; pass to `apply` to guarantee exactly what was reviewed gets applied| |`-var='key=value'`|Set a single input variable inline; highest precedence of all variable sources| |`-var-file=prod.tfvars`|Load variables from a `.tfvars` or `.tfvars.json` file; can be repeated for multiple files| |`-target=resource_type.name`|Limit plan to a specific resource and its dependencies; use sparingly, not a regular workflow| |`-target=module.name`|Limit plan to an entire module and its dependencies| |`-refresh=false`|Skip state refresh (don't call cloud APIs to update state); faster but may miss real-world drift| |`-refresh-only`|Only refresh state from real infrastructure, don't compute resource changes; successor to `terraform refresh`| |`-detailed-exitcode`|Exit code 0 = no changes, 1 = error, 2 = changes present; essential for drift detection in CI scripts| |`-destroy`|Plan a full destroy instead of an apply; shows what `terraform destroy` would do| |`-replace=resource_type.name`|Force a specific resource to be destroyed and recreated even if no config change| |`-parallelism=N`|Number of resources to process in parallel (default: 10); increase for faster plans on large configs| |`-compact-warnings`|Show warnings in a condensed form (one line per warning type)| |`-no-color`|Disable ANSI color output; use in CI pipelines that log plain text| |`-input=false`|Do not ask for variable values interactively; fail if a required variable has no value| |`-lock=false`|Don't acquire state lock (dangerous — only for emergencies or read-only operations)| |`-lock-timeout=DURATION`|Retry acquiring state lock for up to DURATION (e.g., `10m`) before failing| |`-json`|Machine-readable JSON output; useful for parsing plan results in scripts or PR comment bots| --- ### `terraform apply` > Execute the plan and update real infrastructure. Always prefer applying a saved plan from `plan -out=tfplan`. |Flag|Description| |---|---| |`tfplan`|Apply an exact saved plan file from `terraform plan -out=tfplan`; guarantees reviewed plan is what runs| |`-auto-approve`|Skip the interactive "Do you want to perform these actions?" confirmation; required in CI| |`-var='key=value'`|Set input variable (only valid without a saved plan file)| |`-var-file=file.tfvars`|Load variable file (only valid without a saved plan file)| |`-target=resource_type.name`|Apply only a specific resource and its dependencies; use for emergency fixes or bootstrapping| |`-replace=resource_type.name`|Force destroy + recreate of a specific resource, even with no config change (replaces deprecated `taint`)| |`-parallelism=N`|Parallel resource operations (default: 10); increase for large applies, decrease if hitting API rate limits| |`-refresh=false`|Skip refresh before apply; faster but may miss drift (use only when state is known-good)| |`-refresh-only`|Only update state to match reality, don't make infrastructure changes| |`-compact-warnings`|Condense repeated warnings into a single summary line| |`-no-color`|Plain text output for CI log systems| |`-input=false`|Non-interactive mode; fail rather than prompt for missing variables| |`-lock=false`|Skip state locking (dangerous; only for read operations or emergencies)| |`-lock-timeout=DURATION`|Wait up to DURATION for state lock before failing (e.g., `5m`)| |`-json`|Structured JSON output for machine parsing| --- ### `terraform destroy` > Destroy all infrastructure managed by the current configuration. **Irreversible — use with extreme caution in production.** |Flag|Description| |---|---| |`-auto-approve`|Skip the destruction confirmation prompt; required in CI, dangerous in production| |`-target=resource_type.name`|Destroy only a specific resource and its dependents, not everything| |`-var='key=value'`|Set input variable needed to locate resources| |`-var-file=file.tfvars`|Load variable file| |`-parallelism=N`|Parallel destroy operations (default: 10)| |`-refresh=false`|Skip state refresh before destroy; use only if state is known-accurate| |`-no-color`|Disable color output| |`-lock=false`|Skip locking (dangerous)| > 💡 **Tip:** `terraform plan -destroy` shows what would be destroyed without actually doing it. Always run this first. --- ### `terraform fmt` > Reformat HCL files to canonical Terraform style. Run before every commit. |Flag|Description| |---|---| |`-recursive`|Also format files in subdirectories (recommended — formats all modules in a repo)| |`-check`|Check-only mode: exit code 0 if formatted, 1 if not; does NOT modify files; use in CI to enforce formatting| |`-diff`|Show a diff of changes that would be made (useful for reviewing what `fmt` will change)| |`-write=false`|Do not write formatted result to files; combine with `-diff` to preview without modifying| |`-list=false`|Don't list files that need formatting (suppress output, rely on exit code only)| |`[dir or file]`|Specify a directory or single file to format; defaults to current directory| --- ### `terraform validate` > Validate configuration syntax and schema correctness. Does not access any remote services. |Flag|Description| |---|---| |`-json`|Output validation results as JSON (useful for CI tools that parse structured output)| |`-no-color`|Disable color in output| > ⚠️ Requires `terraform init` to have been run first (providers must be downloaded for schema validation). --- ### `terraform show` > Display a human-readable view of the current state or a saved plan file. |Flag|Description| |---|---| |`-json`|Output in JSON format; machine-readable; use for parsing state or plan in scripts| |`-no-color`|Disable ANSI color codes| |`[path]`|Optional: path to a saved plan file (e.g., `terraform show tfplan`); without it, shows current state| --- ### `terraform output` > Read and display output values from the current state. |Flag|Description| |---|---| |`-json`|Output all values as a JSON object; use in scripts to extract multiple outputs at once| |`-raw`|Output a single value as a raw string without quotes (ideal for shell variable assignment)| |`-no-color`|Disable color output| |`[name]`|Name of a specific output to display; without it, shows all outputs| ```bash # Examples terraform output -json # all outputs as JSON terraform output -raw db_connection_string # raw string, no quotes DB_HOST=$(terraform output -raw db_host) # use in shell script ``` --- ### `terraform import` > Import existing real-world infrastructure into Terraform state without recreating it. |Flag|Description| |---|---| |`resource_type.name`|The Terraform resource address to import into (must already have HCL config written)| |`cloud_resource_id`|The actual cloud resource ID (format varies by resource type and provider)| |`-var='key=value'`|Set input variable needed for provider configuration| |`-var-file=file.tfvars`|Load variable file| |`-input=false`|Non-interactive mode| |`-no-color`|Disable color output| |`-allow-missing-config`|Import into state even if no matching HCL resource block exists yet| > 💡 **Terraform 1.5+:** Use `import {}` blocks in HCL instead of this CLI command for version-controlled, reviewable imports. Add `-generate-config-out=generated.tf` to auto-generate the HCL. ```bash # Classic CLI import terraform import azurerm_resource_group.main \ /subscriptions/xxx-yyy/resourceGroups/my-rg # Terraform 1.5+ — preferred (declarative, in HCL) # import { to = azurerm_resource_group.main; id = "/subscriptions/..." } # terraform plan -generate-config-out=generated.tf ``` --- ### `terraform state` > Inspect and manipulate the Terraform state file directly. Most subcommands are low-level — use carefully. |Subcommand|Flag|Description| |---|---|---| |`state list`||List all resources tracked in state| |`state list`|`'module.vpc.*'`|Filter by glob pattern| |`state show`|`resource_type.name`|Show all attributes of one resource as stored in state| |`state mv`|`old_addr new_addr`|Rename or move a resource in state without destroying it (use before `moved {}` block was available)| |`state rm`|`resource_type.name`|Remove a resource from state without destroying the real cloud resource (resource becomes unmanaged)| |`state pull`||Download remote state as JSON to stdout; use for backup or inspection| |`state push`|`terraform.tfstate`|Upload a local state file to the remote backend (dangerous — can overwrite)| |`state replace-provider`|`old new`|Update provider source references in state (useful after provider namespace migrations)| --- ### `terraform workspace` > Manage multiple named state files (workspaces) within the same backend. |Subcommand|Description| |---|---| |`workspace list`|List all workspaces; `*` marks the currently active one| |`workspace show`|Print the name of the currently active workspace| |`workspace new NAME`|Create a new workspace and switch to it immediately| |`workspace select NAME`|Switch to an existing workspace| |`workspace delete NAME`|Delete a workspace (must not be currently selected; state must be empty or use `-force`)| --- ### `terraform console` > Open an interactive REPL to evaluate Terraform expressions against the current state and variables. |Flag|Description| |---|---| |`-var='key=val'`|Set a variable for the console session| |`-var-file=file.tfvars`|Load a variable file for the console session| |`-plan=tfplan`|(1.8+) Evaluate expressions within the context of a saved plan| ```bash # Usage examples inside console > var.environment # inspect variable value > local.common_tags # inspect locals > cidrsubnet("10.0.0.0/16", 8, 3) # test CIDR functions > [for s in var.names : upper(s)] # test for expressions > jsondecode(file("config.json")) # test file + decode > Ctrl+D # exit ``` --- ### `terraform graph` > Output the dependency graph of resources in DOT format (Graphviz). |Flag|Description| |---|---| |`-type=plan`|Graph type: `plan`, `plan-destroy`, `apply`, `validate`, `input`, `refresh`| |`-draw-cycles`|Highlight any dependency cycles (useful for debugging cycle errors)| |`-plan=tfplan`|Use a saved plan file for the graph| ```bash terraform graph -type=plan | dot -Tsvg > graph.svg # render to SVG terraform graph -type=plan | dot -Tpng > graph.png # render to PNG ``` --- ### `terraform test` _(v1.6+)_ > Run HCL-based test files (`.tftest.hcl`) to validate module behavior. |Flag|Description| |---|---| |`-filter=file.tftest.hcl`|Run only the specified test file instead of all `*.tftest.hcl` files| |`-verbose`|Print each test assertion result, including passing ones| |`-json`|Output test results as JSON for CI integration| |`-var='key=val'`|Set a variable for the test run| |`-var-file=file.tfvars`|Load a variable file for the test run| --- ### `terraform force-unlock` > Manually release a stuck state lock. **Use only when you are certain no apply is currently running.** |Argument|Description| |---|---| |`LOCK_ID`|The lock ID shown in the lock error message (UUID format)| |`-force`|Skip the confirmation prompt| > ⚠️ **Danger:** Unlocking while an apply is running corrupts state. Only use when a process died mid-apply and left a stale lock. --- ### `terraform providers` > Show information about providers required by the current configuration. |Subcommand|Description| |---|---| |`providers`|List all required providers and the configuration files that require them| |`providers lock`|Update `.terraform.lock.hcl` with checksums for specified platforms| |`providers lock -platform=linux_amd64 -platform=darwin_arm64`|Lock for multiple platforms (useful when CI is Linux but devs use Mac)| |`providers mirror DIR`|Download provider binaries to a local directory for air-gapped installations| |`providers schema -json`|Output the full JSON schema for all providers (large output — pipe to file)| --- ## 2. Terraform Lifecycle ``` ┌─────────────────────────────────────────────────────────────────────┐ │ TERRAFORM WORKFLOW │ ├──────────┬──────────┬──────────┬──────────┬──────────┬─────────────┤ │ WRITE │ INIT │ PLAN │ REVIEW │ APPLY │ VERIFY │ │ │ │ │ │ │ │ │ .tf files│ Download │ Diff vs │ Read the │ Execute │ terraform │ │ HCL code │ providers│ state │ plan! │ changes │ output │ │ modules │ modules │ Show +/- │ Approve │ Update │ plan again │ │ │ backend │ -destroy │ or abort │ state │ (no changes)│ └──────────┴──────────┴──────────┴──────────┴──────────┴─────────────┘ ``` |Phase|Command|What happens|Output| |---|---|---|---| |**Write**|—|Author `.tf` files in HCL|`.tf` files, `variables.tf`, `outputs.tf`| |**Init**|`terraform init`|Downloads providers to `.terraform/`, sets up backend|`.terraform/`, `.terraform.lock.hcl`| |**Plan**|`terraform plan -out=tfplan`|Refresh state, build DAG, compute diff|Plan output: `+` create, `~` update, `-` destroy, `-/+` replace| |**Review**|Human / CI gate|Read every line of the plan, especially `-`|Approval or rejection| |**Apply**|`terraform apply tfplan`|Executes saved plan in dependency order|Updated `terraform.tfstate`| |**Destroy**|`terraform destroy`|Destroys all resources in reverse dependency order|Empty state| --- ## 3. Backend Comparison |Backend|State Storage|Locking|Encryption|Best For| |---|---|---|---|---| |**local**|`terraform.tfstate` on disk|None|No|Local dev only| |**s3**|AWS S3 bucket|DynamoDB table|SSE (S3 managed or KMS)|AWS-first teams| |**azurerm**|Azure Blob Storage|Blob lease (built-in)|Azure SSE + CMK|Azure-first teams| |**gcs**|Google Cloud Storage|GCS object lock|Google-managed or CMEK|GCP-first teams| |**http**|Any REST API endpoint|Optional LOCK/UNLOCK endpoints|Depends on server|GitLab built-in state, custom| |**kubernetes**|Kubernetes Secret|ConfigMap lease|K8s secret encryption|K8s-native setups| |**consul**|HashiCorp Consul KV|Consul sessions|Gossip encryption|HashiCorp stack| |**pg**|PostgreSQL table|Advisory locks|DB-level encryption|Self-hosted DB| |**Terraform Cloud**|TFC-managed|Built-in|AES-256|Enterprise, teams| ### S3 Backend Example ```yaml terraform { backend "s3" { bucket = "my-tfstate-bucket" key = "prod/terraform.tfstate" region = "eu-west-1" dynamodb_table = "terraform-locks" # locking encrypt = true # SSE-S3 kms_key_id = "arn:aws:kms:..." # optional CMK } } ``` ### Azure Backend Example ```yaml terraform { backend "azurerm" { resource_group_name = "rg-tfstate" storage_account_name = "satfstateprod001" container_name = "tfstate" key = "prod/terraform.tfstate" use_oidc = true # OIDC auth (recommended) } } ``` --- ## 4. Variable Precedence Highest priority wins. When the same variable is set in multiple places, the highest-priority source is used. |Priority|Source|Example| |---|---|---| |**1 (highest)**|`-var` CLI flag|`terraform apply -var='region=us-east-1'`| |**2**|`-var-file` CLI flag|`terraform apply -var-file=prod.tfvars`| |**3**|`*.auto.tfvars` files|`prod.auto.tfvars` (auto-loaded alphabetically)| |**4**|`terraform.tfvars`|Auto-loaded if present in working directory| |**5**|`TF_VAR_*` environment variables|`export TF_VAR_region=us-east-1`| |**6 (lowest)**|Default in `variable` block|`variable "region" { default = "eu-west-1" }`| ```yaml # variable block with all options variable "instance_type" { description = "EC2 instance type" type = string default = "t3.micro" sensitive = false nullable = false validation { condition = contains(["t3.micro", "t3.small", "t3.medium"], var.instance_type) error_message = "Must be t3.micro, t3.small, or t3.medium." } } ``` --- ## 5. Version Constraint Operators | Operator | Meaning | Example | Allows | | -------- | ------------------------------- | ----------- | ----------------------------- | | `=` | Exact version | | Only `3.90.0` | | `!=` | Not this version | `!= 3.85.0` | Any except `3.85.0` | | `>` | Greater than | `> 3.0` | `3.0.1`, `4.0`, etc. | | `>=` | Greater than or equal | `>= 3.0` | `3.0.0` and above | | `<` | Less than | `< 4.0` | Up to but not including `4.0` | | `<=` | Less than or equal | `<= 3.99` | Up to `3.99.x` | | `~>` | Pessimistic / allows patch | `~> 3.90` | `>= 3.90, < 4.0` | | `~>` | Pessimistic / allows patch only | `~> 3.9.0` | `>= 3.9.0, < 3.10.0` | **Multiple constraints** (AND logic): ```hcl version = ">= 3.0, < 4.0" # equivalent to ~> 3.0 version = ">= 1.5, != 1.6.0" # at least 1.5, skip the broken 1.6.0 ``` **Production recommendation:** `~> major.minor` (e.g., `~> 3.90`) + commit `.terraform.lock.hcl` --- ## 6. Resource Meta-Arguments Meta-arguments apply to **any** resource block regardless of provider. |Meta-Argument|Type|Description|Example| |---|---|---|---| |`count`|`number`|Create N identical instances|`count = 3` → `aws_instance.web[0]`| |`for_each`|`map` or `set(string)`|Create one per key, stable identity|`for_each = var.envs` → `aws_instance.web["prod"]`| |`provider`|provider reference|Override default provider for this resource|`provider = aws.eu`| |`depends_on`|list of references|Explicit dependency not inferable from attributes|`depends_on = [aws_iam_role_policy.this]`| |`lifecycle`|block|Control resource replacement and destroy behavior|See section 7| --- ## 7. Lifecycle Meta-Arguments ```yaml resource "aws_instance" "web" { # ... config ... lifecycle { create_before_destroy = true # create new before destroying old prevent_destroy = true # block terraform destroy for this resource ignore_changes = [tags, ami] # ignore drift on these attributes replace_triggered_by = [ # force replace when other resource changes aws_launch_template.main.id ] } } ``` |Argument|Type|Effect|Use Case| |---|---|---|---| |`create_before_destroy`|bool|New resource created first, then old destroyed|Zero-downtime replacement of stateless resources| |`prevent_destroy`|bool|`terraform destroy` errors on this resource|Production databases, stateful workloads| |`ignore_changes`|list of attrs|Ignore attribute drift (won't show in plan)|Tags managed externally, auto-scaled resources| |`ignore_changes = all`|keyword|Ignore all attribute drift|Resources mostly managed outside Terraform| |`replace_triggered_by`|list of refs|Force replacement when referenced resource changes|Rolling updates tied to config changes| |`precondition`|block (1.2+)|Validate before resource operations|Input validation with resource context| |`postcondition`|block (1.2+)|Validate after resource created/updated|Verify resource created as expected| --- ## 8. count vs for_each |Aspect|`count`|`for_each`| |---|---|---| |**Input type**|`number`|`map(any)` or `set(string)`| |**Instance key**|Integer index: `[0]`, `[1]`, `[2]`|Map key: `["prod"]`, `["dev"]`| |**Reference**|`aws_instance.web[0]`|`aws_instance.web["prod"]`| |**Remove middle item**|⚠️ Destroys and recreates all items with higher index|✅ Only removes the specific item| |**Conditional resource**|`count = var.enabled ? 1 : 0`|`for_each = var.enabled ? toset(["x"]) : toset([])`| |**Use when**|Truly identical instances (e.g., 3 identical workers)|Named/distinct resources that may be added/removed| |**`self` reference**|`count.index`|`each.key`, `each.value`| |**State path**|`resource_type.name[0]`|`resource_type.name["key"]`| ```hcl # ✅ for_each — stable, recommended resource "azurerm_storage_account" "sa" { for_each = toset(["logs", "backups", "archive"]) name = "sa${each.key}${var.env}" account_tier = "Standard" account_replication_type = "LRS" } # ⚠️ count — fragile ordering resource "azurerm_storage_account" "sa" { count = 3 name = "sa${count.index}${var.env}" } ``` --- ## 9. Built-in Functions Reference ### String Functions |Function|Signature|Example|Result| |---|---|---|---| |`format`|`format(spec, ...)`|`format("%s-%s", "web", "prod")`|`"web-prod"`| |`join`|`join(sep, list)`|`join(",", ["a","b","c"])`|`"a,b,c"`| |`split`|`split(sep, str)`|`split(",", "a,b,c")`|`["a","b","c"]`| |`trimspace`|`trimspace(str)`|`trimspace(" hello ")`|`"hello"`| |`upper`|`upper(str)`|`upper("hello")`|`"HELLO"`| |`lower`|`lower(str)`|`lower("HELLO")`|`"hello"`| |`replace`|`replace(str, old, new)`|`replace("a-b", "-", "_")`|`"a_b"`| |`substr`|`substr(str, offset, len)`|`substr("hello", 0, 3)`|`"hel"`| |`startswith`|`startswith(str, prefix)`|`startswith("ami-123", "ami-")`|`true`| |`endswith`|`endswith(str, suffix)`|`endswith("main.tf", ".tf")`|`true`| |`contains`|`contains(list, value)`|`contains(["a","b"], "a")`|`true`| |`regex`|`regex(pattern, str)`|`regex("^ami-", var.ami_id)`|match or error| |`can`|`can(expr)`|`can(regex("^ami-", var.id))`|`true` or `false`| ### Collection Functions |Function|Signature|Example|Result| |---|---|---|---| |`length`|`length(val)`|`length(["a","b"])`|`2`| |`flatten`|`flatten(list)`|`flatten([["a"],["b","c"]])`|`["a","b","c"]`| |`distinct`|`distinct(list)`|`distinct(["a","b","a"])`|`["a","b"]`| |`concat`|`concat(lists...)`|`concat(["a"],["b"])`|`["a","b"]`| |`merge`|`merge(maps...)`|`merge({a=1},{b=2})`|`{a=1,b=2}`| |`keys`|`keys(map)`|`keys({a=1,b=2})`|`["a","b"]`| |`values`|`values(map)`|`values({a=1,b=2})`|`[1,2]`| |`lookup`|`lookup(map, key, default)`|`lookup(var.m,"k","default")`|value or `"default"`| |`element`|`element(list, index)`|`element(["a","b","c"], 1)`|`"b"`| |`slice`|`slice(list, start, end)`|`slice(["a","b","c"], 0, 2)`|`["a","b"]`| |`toset`|`toset(list)`|`toset(["a","b","a"])`|`{"a","b"}`| |`tolist`|`tolist(set)`|`tolist(toset(["a"]))`|`["a"]`| |`tomap`|`tomap(obj)`|`tomap({a="x"})`|`{a="x"}`| |`zipmap`|`zipmap(keys, values)`|`zipmap(["a"],["1"])`|`{a="1"}`| |`transpose`|`transpose(map_of_lists)`|—|Inverts a map of lists| |`one`|`one(list)`|`one(["x"])`|`"x"` (errors if 0 or 2+)| |`compact`|`compact(list)`|`compact(["a","","b"])`|`["a","b"]`| |`chunklist`|`chunklist(list, size)`|`chunklist(["a","b","c"],2)`|`[["a","b"],["c"]]`| |`alltrue`|`alltrue(list)`|`alltrue([true,true])`|`true`| |`anytrue`|`anytrue(list)`|`anytrue([false,true])`|`true`| ### Numeric Functions |Function|Example|Result| |---|---|---| |`min(a,b,...)`|`min(3,1,5)`|`1`| |`max(a,b,...)`|`max(3,1,5)`|`5`| |`abs(n)`|`abs(-5)`|`5`| |`ceil(n)`|`ceil(1.2)`|`2`| |`floor(n)`|`floor(1.9)`|`1`| |`pow(base, exp)`|`pow(2,8)`|`256`| |`log(n, base)`|`log(16, 2)`|`4`| |`signum(n)`|`signum(-3)`|`-1`| |`parseint(str, base)`|`parseint("FF", 16)`|`255`| ### Encoding & Filesystem Functions |Function|Description|Example| |---|---|---| |`base64encode(str)`|Encode string to base64|`base64encode("hello")` → `"aGVsbG8="`| |`base64decode(str)`|Decode base64 string|`base64decode("aGVsbG8=")` → `"hello"`| |`jsonencode(val)`|Encode value to JSON string|`jsonencode({a=1})` → `"{\"a\":1}"`| |`jsondecode(str)`|Decode JSON string to value|`jsondecode("{\"a\":1}")` → `{a=1}`| |`yamlencode(val)`|Encode value to YAML|—| |`yamldecode(str)`|Decode YAML to value|`yamldecode(file("config.yaml"))`| |`urlencode(str)`|URL-encode a string|`urlencode("a b")` → `"a%20b"`| |`file(path)`|Read file contents as string|`file("${path.module}/script.sh")`| |`filebase64(path)`|Read file as base64|For binary files in user_data| |`fileexists(path)`|Check if file exists|`fileexists("${path.module}/optional.txt")`| |`templatefile(path, vars)`|Render template file with vars|`templatefile("init.sh.tpl", {host=var.db_host})`| ### IP / CIDR Functions |Function|Signature|Example|Result| |---|---|---|---| |`cidrsubnet`|`cidrsubnet(prefix, newbits, netnum)`|`cidrsubnet("10.0.0.0/16", 8, 1)`|`"10.0.1.0/24"`| |`cidrhost`|`cidrhost(prefix, hostnum)`|`cidrhost("10.0.1.0/24", 5)`|`"10.0.1.5"`| |`cidrnetmask`|`cidrnetmask(prefix)`|`cidrnetmask("10.0.1.0/24")`|`"255.255.255.0"`| |`cidrcontains`|`cidrcontains(prefix, ip)`|`cidrcontains("10.0.0.0/8", "10.1.2.3")`|`true`| --- ## 10. CI/CD Pipeline Stages ### Why CI/CD for Terraform Matters Running `terraform apply` from a developer's laptop is the single most common source of production incidents in IaC teams. The reasons are straightforward: no review, no audit trail, no consistency, and no protection against accidental destroys. A proper CI/CD pipeline solves all of these problems by making the **plan visible, the apply gated, and the state changes auditable**. The golden rule: **plan in CI on every PR, apply in CI only after human approval on merge to main**. The plan output becomes the artifact that the reviewer approves — not the code alone. This separation ensures that what was reviewed is exactly what gets applied. --- ### The Pipeline Philosophy ``` Feature branch push │ ▼ ┌───────────────────┐ │ Static Checks │ No cloud creds needed. Fast. Catch 80% of errors here. │ fmt → validate │ Run on every commit to every branch. │ tflint → tfsec │ Fail fast — no point running plan on broken code. └────────┬──────────┘ │ pass ▼ ┌───────────────────┐ │ Plan Stage │ Needs cloud credentials (read-only role is sufficient). │ terraform init │ Saves plan as CI artifact. Posts output to PR as comment. │ terraform plan │ The reviewer reads THIS output, not just the code diff. │ -out=tfplan │ Reviewer sees exactly: what will be created/changed/destroyed. └────────┬──────────┘ │ PR merged to main ▼ ┌───────────────────┐ │ Manual Approval │ Human gate. In Azure DevOps: Environment approvals. │ (gate) │ In GitHub: Environment protection rules. │ │ Approver confirms the plan matches intent. └────────┬──────────┘ │ approved ▼ ┌───────────────────┐ │ Apply Stage │ Uses the SAVED plan artifact from Plan stage. │ terraform apply │ NOT a new plan — the exact approved plan runs. │ tfplan │ This guarantees no surprises between review and apply. └───────────────────┘ │ ▼ ┌───────────────────┐ │ Drift Detection │ Scheduled job (nightly / hourly). │ (scheduled) │ Runs plan -detailed-exitcode. │ │ Exit 2 = drift → alert team, create ticket. └───────────────────┘ ``` --- ### Full Pipeline Stage Reference | Stage | Tool | Command | What It Checks | Fails On | Cloud Creds | When Runs | | ------------------- | -------------------- | ----------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | ---------------------------------- | -------------------------- | | **Format** | `terraform fmt` | `terraform fmt -recursive -check` | HCL code formatting matches canonical Terraform style. Tabs vs spaces, alignment, bracket placement. | Any unformatted `.tf` file | ❌ No | Every commit, every branch | | **Validate** | `terraform validate` | `terraform init -backend=false && terraform validate` | Syntax correctness, valid resource types, correct attribute names per provider schema. Does NOT check real infrastructure. | Invalid HCL, unknown attributes, wrong types | ❌ No (after `init -backend=false`) | Every commit, every branch | | **Lint** | `tflint` | `tflint --recursive` | Terraform best practices: unused variables, deprecated arguments, naming convention violations, invalid AMI formats. Goes beyond what `validate` checks. | Configurable — HIGH by default | ❌ No | Every commit, every branch | | **Security Scan** | `tfsec` | `tfsec . --minimum-severity HIGH` | Static security analysis of HCL: public S3 buckets, unencrypted volumes, open security groups, IAM wildcards. 150+ built-in checks. | Any HIGH or CRITICAL finding | ❌ No | Every commit, every branch | | **Security Scan** | `checkov` | `checkov -d . --framework terraform` | Policy compliance across multiple frameworks (Terraform, CloudFormation, K8s). 1000+ checks, CIS benchmarks. Complements tfsec. | Policy violations above threshold | ❌ No | Every commit, every branch | | **Cost Estimate** | `infracost` | `infracost diff --path . --format table` | Estimates monthly cost delta of the proposed changes. Posts cost breakdown to PR comment. Can fail if increase exceeds a configured threshold. | Cost increase > defined budget threshold | ❌ No (uses public pricing APIs) | PR branches only | | **Plan** | `terraform plan` | `terraform plan -out=tfplan -no-color 2>&1 \| tee plan.txt` | Connects to cloud APIs, refreshes state, computes full diff. Shows every resource that will be created (+), updated (~), or destroyed (-). Saves binary plan for apply. | Provider authentication errors, quota issues, missing permissions, invalid resource references | ✅ Yes (read access) | PR branches only | | **Plan Comment** | Bot / `gh` | Post `plan.txt` to PR | Makes plan output visible to reviewers without requiring them to run Terraform locally. Reviewer sees exact changes before approving. | — | ✅ GitHub/GitLab token | After plan, PR only | | **Manual Approval** | CI gate | Environment approval | A human reads the plan output and explicitly approves before apply can proceed. This is the critical safety gate — no automation bypasses it for production. | Reviewer rejects or times out | — | On merge to main | | **Apply** | `terraform apply` | `terraform apply tfplan` | Executes the **exact saved plan** from step 7 — not a new plan. This is critical: applying a saved plan guarantees no changes occurred between review and execution. | Cloud API errors, quota exceeded, insufficient permissions, race conditions | ✅ Yes (write access) | On main, after approval | | **Verify** | `terraform plan` | `terraform plan -detailed-exitcode` | Immediately after apply, run plan again. Should exit 0 (no changes). Exit 2 means apply was incomplete or a resource immediately changed after creation. | Exit code 2 after apply | ✅ Yes | After apply | | **Drift Detection** | `terraform plan` | `terraform plan -detailed-exitcode -refresh-only` | Scheduled check (nightly or hourly). Detects when real infrastructure diverges from state — someone made manual changes, auto-scaling modified resources, cloud policy applied tags, etc. | Exit code 2 (drift detected) → send alert | ✅ Yes | Scheduled cron job | --- ### Stage Descriptions — Deep Dive #### Stage 1 — `terraform fmt -check` **Purpose:** Enforce consistent code style across the entire team. Terraform has a single canonical formatting style — `fmt` applies it automatically. In CI, `-check` mode makes it a gate: the pipeline fails if any file is unformatted, forcing the developer to run `terraform fmt -recursive` locally before pushing. **Why it matters:** Inconsistent formatting leads to noisy diffs where style changes obscure actual logic changes. It also signals discipline — teams that don't enforce formatting often don't enforce other standards either. ```bash terraform fmt -recursive -check # Exit 0 → all files formatted ✅ # Exit 1 → unformatted files found ❌ (lists offending files) ``` --- #### Stage 2 — `terraform validate` **Purpose:** Validate that the HCL configuration is syntactically correct and references valid resource types and attributes according to provider schemas. This catches typos in resource names, wrong attribute types, missing required arguments, and circular references — all without touching real infrastructure. **Why it matters:** `validate` catches a class of errors that `fmt` misses — structural problems in the code. It's the fastest feedback loop that requires actual provider schemas (hence `init` first). ```bash terraform init -backend=false # download provider schemas, skip backend terraform validate # Exit 0 → valid ✅ # Exit 1 → invalid with error details ❌ ``` --- #### Stage 3 — `tflint` **Purpose:** A linter that goes beyond what Terraform's own `validate` checks. tflint uses provider-specific rules to catch issues like invalid AWS instance types, deprecated arguments that still parse correctly, missing variable descriptions, naming convention violations, and unused declared variables. **Why it matters:** `terraform validate` only checks if the HCL is structurally valid — it won't tell you that `t3.micros` is an invalid instance type until you actually try to apply. tflint catches these issues at lint time. ```bash # .tflint.hcl config file plugin "aws" { enabled = true; version = "0.30.0" } rule "terraform_naming_convention" { enabled = true } rule "terraform_unused_declarations" { enabled = true } tflint --recursive --minimum-failure-severity=warning ``` --- #### Stage 4 & 5 — `tfsec` / `checkov` **Purpose:** Static security analysis of Terraform HCL. These tools scan your configuration for security misconfigurations before any infrastructure is created. They catch things like: S3 buckets with public ACLs, security groups open to 0.0.0.0/0, unencrypted EBS volumes, IAM policies with `*` actions, missing audit logging. **Why it matters:** Finding a security misconfiguration in code review is free. Finding it after it's been applied to production costs a security incident report, a compliance violation, and potentially a breach. ```bash # tfsec — fast, focused on Terraform tfsec . --minimum-severity HIGH --format sarif --out results.sarif # checkov — broader, multi-framework checkov -d . --framework terraform --output sarif --output-file results.sarif \ --check CKV_AWS_20,CKV_AZURE_33 # specific checks --skip-check CKV2_AWS_6 # skip known false positives ``` --- #### Stage 6 — `infracost diff` **Purpose:** Estimate the monthly cost impact of the proposed Terraform changes. Infracost reads the plan and calculates cost deltas using cloud provider pricing APIs. The result is posted as a PR comment showing: resources added/removed and the estimated monthly cost change. **Why it matters:** Engineers often don't know the cost implications of what they're building. A `db.r6g.8xlarge` RDS instance sounds reasonable until Infracost tells you it's $2,400/month. Catching this in PR review is far better than in the next AWS bill. ```bash infracost diff \ --path . \ --format table \ --show-skipped # Post to PR comment (GitHub Actions) infracost comment github \ --path /tmp/infracost.json \ --repo $GITHUB_REPOSITORY \ --pull-request $PR_NUMBER \ --github-token $GITHUB_TOKEN \ --behavior update ``` --- #### Stage 7 — `terraform plan -out=tfplan` **Purpose:** The most important stage. Connects to real cloud APIs, refreshes the state (checks what actually exists), and computes the full execution plan. The `-out=tfplan` flag saves this plan as a binary artifact. This saved plan is then used by the apply stage — guaranteeing that what was reviewed is what gets applied. **Why `-out` is critical:** Without a saved plan, `terraform apply` recomputes the plan at apply time. Between review and apply, someone else might have pushed code changes, a resource might have drifted, or a race condition might produce different results. A saved plan eliminates all of this — apply is deterministic. ```bash # In CI — always use -out terraform plan \ -out=tfplan \ -no-color \ -input=false \ -var-file=environments/prod.tfvars \ 2>&1 | tee plan.txt # Publish as artifact for apply stage # (Azure DevOps: PublishPipelineArtifact, GitHub Actions: upload-artifact) ``` --- #### Stage 8 — Plan Comment on PR **Purpose:** Make the plan output visible to everyone reviewing the PR. Without this, reviewers see only code changes — they don't see what Terraform will actually do. With a plan comment, reviewers can see: "3 resources will be created, 1 will be updated, 0 will be destroyed" — and read the exact details. **Why it matters:** The PR comment is the bridge between "code that looks correct" and "infrastructure that will behave correctly." It surfaces unexpected destroys, unintended replacements, and large cost changes before they happen. ```bash # Simple approach — post plan.txt as PR comment # Works with GitHub CLI, GitLab API, or tools like terraform-pr-commenter # GitHub Actions example gh pr comment $PR_NUMBER \ --body "$(cat << 'EOF' ## Terraform Plan \`\`\` $(cat plan.txt | tail -50) \`\`\` EOF )" ``` --- #### Stage 9 — Manual Approval Gate **Purpose:** A human explicitly approves the apply before it proceeds. The approver reads the plan comment, confirms the changes match intent, and clicks Approve. No automation can bypass this gate for production. **Why it matters:** Every major Terraform-related outage traces back to an unapproved change reaching production. The approval gate is the last line of defense. It forces a human to read "will destroy 1 database" before clicking go. ```yaml # Azure DevOps — configure on the Environment environment: production # add Approvals & Checks in the Environment UI # GitHub Actions — environment protection rules environment: name: production # Add required reviewers in repo Settings → Environments ``` --- #### Stage 10 — `terraform apply tfplan` **Purpose:** Execute the exact saved plan from stage 7. Because the plan is pre-computed and saved, this apply has no surprises — it does precisely what was reviewed and approved. **Key rule:** Never run `terraform apply` without a saved plan in CI. Always pass the plan artifact: `terraform apply tfplan`. A bare `terraform apply -auto-approve` re-plans, which can apply different changes than what was reviewed. ```bash # Download the plan artifact from stage 7 # (Azure DevOps: DownloadPipelineArtifact, GitHub Actions: download-artifact) terraform apply \ -input=false \ -no-color \ tfplan # ← the saved binary plan, not a fresh plan ``` --- #### Stage 11 & 12 — Verify & Drift Detection **Purpose:** After apply, immediately run `plan` again — a clean apply produces zero changes. If plan shows changes after a fresh apply, something is wrong (a resource ignored Terraform, a cloud policy auto-modified something, or the apply was incomplete). Drift detection is a scheduled version of the same check. Running nightly (or hourly for critical systems), it detects when real infrastructure diverges from Terraform state — caused by manual portal changes, auto-scaling, cloud policy enforcement, or external automation. ```bash # Post-apply verification terraform plan -detailed-exitcode # Exit 0 → perfect ✅ # Exit 2 → something changed immediately after apply ⚠️ # Scheduled drift detection (cron job in CI) terraform plan \ -detailed-exitcode \ -refresh-only \ -no-color \ -input=false if [ $? -eq 2 ]; then echo "DRIFT DETECTED — sending alert" # Post to Slack, create Jira ticket, page on-call fi ``` --- ### Azure DevOps Complete Pipeline ```yaml # azure-pipelines.yml — Production Terraform Pipeline trigger: branches: include: [main] paths: include: ['terraform/**'] pr: branches: include: ['*'] paths: include: ['terraform/**'] variables: TF_VERSION: '1.7.5' TF_WORKING_DIR: 'terraform' ARM_USE_OIDC: true pool: vmImage: 'ubuntu-latest' stages: # ── STAGE 1: STATIC CHECKS ───────────────────────────────────────────────── # No cloud credentials needed. Fast. Run on every commit. - stage: StaticChecks displayName: '🔍 Static Checks (fmt · validate · lint · security)' jobs: - job: StaticChecks displayName: 'Format, Validate, Lint, Security' steps: - task: TerraformInstaller@1 displayName: 'Install Terraform $(TF_VERSION)' inputs: terraformVersion: $(TF_VERSION) - script: | echo "=== STEP 1: Format Check ===" # Fails if any .tf file is not in canonical format. # Developers must run 'terraform fmt -recursive' locally first. terraform fmt -recursive -check displayName: '1️⃣ terraform fmt -check' workingDirectory: $(TF_WORKING_DIR) - script: | echo "=== STEP 2: Validate ===" # -backend=false skips backend init — no cloud creds needed. # Downloads provider schemas to validate attribute names and types. terraform init -backend=false -input=false terraform validate displayName: '2️⃣ terraform validate' workingDirectory: $(TF_WORKING_DIR) - script: | echo "=== STEP 3: TFLint ===" # Install tflint and run provider-specific rules. curl -s https://raw.githubusercontent.com/terraform-linters/tflint/master/install_linux.sh | bash tflint --recursive --minimum-failure-severity=warning displayName: '3️⃣ tflint' workingDirectory: $(TF_WORKING_DIR) - script: | echo "=== STEP 4: tfsec Security Scan ===" # Scans HCL for security misconfigs. Fails on HIGH+. # No cloud credentials required — pure static analysis. docker run --rm \ -v "$(pwd):/src" \ aquasec/tfsec:latest /src \ --minimum-severity HIGH \ --format sarif \ --out /src/tfsec-results.sarif displayName: '4️⃣ tfsec security scan' workingDirectory: $(TF_WORKING_DIR) - script: | echo "=== STEP 5: Checkov ===" pip install checkov --quiet checkov -d . \ --framework terraform \ --output sarif \ --output-file checkov-results.sarif \ --soft-fail-on MEDIUM \ --hard-fail-on HIGH,CRITICAL displayName: '5️⃣ checkov policy scan' workingDirectory: $(TF_WORKING_DIR) # ── STAGE 2: PLAN ─────────────────────────────────────────────────────────── # Requires cloud credentials (read access). Runs on PR branches. - stage: Plan displayName: '📋 Plan (cost estimate + terraform plan)' dependsOn: StaticChecks condition: succeeded() jobs: - job: CostEstimate displayName: 'Infracost Cost Delta' steps: - script: | echo "=== Infracost Cost Estimate ===" # Estimates monthly cost impact without cloud credentials. # Posts breakdown to PR comment. curl -fsSL https://raw.githubusercontent.com/infracost/infracost/master/scripts/install.sh | sh infracost diff \ --path $(TF_WORKING_DIR) \ --format table \ --show-skipped displayName: '6️⃣ infracost diff' env: INFRACOST_API_KEY: $(INFRACOST_API_KEY) - job: TerraformPlan displayName: 'Terraform Plan' dependsOn: CostEstimate steps: - task: TerraformInstaller@1 inputs: { terraformVersion: $(TF_VERSION) } - script: | echo "=== STEP 7: Terraform Init ===" # Full init with backend — needs credentials for state access. terraform init -input=false -no-color displayName: '7️⃣ terraform init' workingDirectory: $(TF_WORKING_DIR) env: ARM_CLIENT_ID: $(ARM_CLIENT_ID) ARM_TENANT_ID: $(ARM_TENANT_ID) ARM_SUBSCRIPTION_ID: $(ARM_SUBSCRIPTION_ID) ARM_USE_OIDC: true - script: | echo "=== STEP 8: Terraform Plan ===" # -out=tfplan: saves plan binary for deterministic apply # -no-color: clean output for logs and PR comments # tee: capture output for PR comment AND show in CI logs terraform plan \ -out=tfplan \ -no-color \ -input=false \ 2>&1 | tee plan.txt displayName: '8️⃣ terraform plan' workingDirectory: $(TF_WORKING_DIR) env: ARM_CLIENT_ID: $(ARM_CLIENT_ID) ARM_TENANT_ID: $(ARM_TENANT_ID) ARM_SUBSCRIPTION_ID: $(ARM_SUBSCRIPTION_ID) ARM_USE_OIDC: true - script: | echo "=== STEP 9: Post Plan to PR ===" # Post the plan output as a PR comment so reviewers # see exactly what will change without running Terraform. # (Implement with Azure DevOps REST API or a PR comment task) echo "Plan output saved to plan.txt — post to PR comment" head -100 plan.txt displayName: '9️⃣ plan comment on PR' # Save plan binary as pipeline artifact for apply stage # This is the KEY artifact — apply uses this exact plan - task: PublishPipelineArtifact@1 displayName: '📦 Publish plan artifact' inputs: targetPath: '$(TF_WORKING_DIR)/tfplan' artifactName: 'terraform-plan' # ── STAGE 3: MANUAL APPROVAL ──────────────────────────────────────────────── # Human gate — reviewer reads plan comment and clicks Approve. # Configure: Azure DevOps → Environments → production → Approvals & Checks - stage: WaitForApproval displayName: '✋ Manual Approval Gate' dependsOn: Plan condition: | and( succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/main') ) jobs: - deployment: WaitForApproval displayName: 'Pending human approval — review the plan comment' environment: production # ← Approval & Checks configured on this Environment # ── STAGE 4: APPLY ────────────────────────────────────────────────────────── # Applies EXACTLY the saved plan from Stage 2. No re-planning. - stage: Apply displayName: '🚀 Apply' dependsOn: WaitForApproval condition: succeeded() jobs: - deployment: TerraformApply displayName: 'terraform apply' environment: production strategy: runOnce: deploy: steps: - task: TerraformInstaller@1 inputs: { terraformVersion: $(TF_VERSION) } - task: DownloadPipelineArtifact@2 displayName: '📥 Download saved plan artifact' inputs: artifactName: 'terraform-plan' targetPath: '$(TF_WORKING_DIR)' - script: | echo "=== STEP 10: Terraform Apply ===" # Apply the EXACT saved plan — not a new plan. # This guarantees what was reviewed is what runs. terraform init -input=false -no-color terraform apply \ -input=false \ -no-color \ tfplan # ← saved plan artifact displayName: '🔟 terraform apply tfplan' workingDirectory: $(TF_WORKING_DIR) env: ARM_CLIENT_ID: $(ARM_CLIENT_ID) ARM_TENANT_ID: $(ARM_TENANT_ID) ARM_SUBSCRIPTION_ID: $(ARM_SUBSCRIPTION_ID) ARM_USE_OIDC: true - script: | echo "=== STEP 11: Post-Apply Verification ===" # Run plan immediately after apply. # Should show 0 changes if apply was successful. # Exit 2 means something changed unexpectedly. terraform plan \ -detailed-exitcode \ -no-color \ -input=false EXIT_CODE=$? if [ $EXIT_CODE -eq 2 ]; then echo "⚠️ WARNING: Plan shows changes after apply — drift or incomplete apply" exit 1 fi echo "✅ Post-apply verification passed — no unexpected changes" displayName: '✅ Post-apply verification' workingDirectory: $(TF_WORKING_DIR) env: ARM_CLIENT_ID: $(ARM_CLIENT_ID) ARM_TENANT_ID: $(ARM_TENANT_ID) ARM_SUBSCRIPTION_ID: $(ARM_SUBSCRIPTION_ID) ARM_USE_OIDC: true ``` --- ### Scheduled Drift Detection Job ```yaml # drift-detection.yml — runs nightly at 02:00 UTC schedules: - cron: '0 2 * * *' displayName: 'Nightly Drift Detection' branches: include: [main] always: true # run even if no code changes stages: - stage: DriftDetection displayName: '🔄 Drift Detection' jobs: - job: CheckDrift steps: - task: TerraformInstaller@1 inputs: { terraformVersion: $(TF_VERSION) } - script: | terraform init -input=false -no-color echo "=== STEP 12: Drift Detection ===" # -refresh-only: update state from reality, don't plan resource changes # -detailed-exitcode: 0=no drift, 1=error, 2=drift detected terraform plan \ -detailed-exitcode \ -refresh-only \ -no-color \ -input=false EXIT_CODE=$? if [ $EXIT_CODE -eq 0 ]; then echo "✅ No drift — infrastructure matches Terraform state" elif [ $EXIT_CODE -eq 2 ]; then echo "🚨 DRIFT DETECTED — real infrastructure diverged from state" echo "Possible causes: manual portal change, cloud policy, auto-scaling" # Add: send Slack alert, create Jira ticket, page on-call exit 1 fi displayName: '🔍 Drift check' env: ARM_CLIENT_ID: $(ARM_CLIENT_ID) ARM_TENANT_ID: $(ARM_TENANT_ID) ARM_SUBSCRIPTION_ID: $(ARM_SUBSCRIPTION_ID) ARM_USE_OIDC: true ``` --- ## 10b. Terraform + Ansible Integration ### Why Use Both? Terraform and Ansible solve **different problems** and are designed to complement each other — not compete. Understanding where one ends and the other begins is a common interview topic. ``` ┌─────────────────────────────────────────────────────────────┐ │ INFRASTRUCTURE LIFECYCLE │ ├──────────────────────────┬──────────────────────────────────┤ │ TERRAFORM │ ANSIBLE │ │ (What infrastructure │ (What runs on the │ │ exists) │ infrastructure) │ │ │ │ │ • VM created │ • Nginx installed │ │ • Network configured │ • Config files deployed │ │ • DNS record exists │ • Services started │ │ • Firewall rules set │ • Users created │ │ • Load balancer up │ • Packages updated │ │ • Database provisioned │ • Application deployed │ │ │ │ │ Declarative, idempotent │ Procedural, agentless │ │ State file │ No state, push-based │ │ Day 0 operations │ Day 1+ operations │ └──────────────────────────┴──────────────────────────────────┘ ``` **The handoff point:** Terraform provisions the VM and outputs its IP address → Ansible connects via SSH and configures the OS and application. --- ### Integration Patterns |Pattern|Description|Best For| |---|---|---| |**Sequential**|Terraform applies → CI writes inventory → Ansible runs|Simple setups, single-environment| |**Terraform `local-exec`**|Terraform runs `ansible-playbook` via provisioner after VM creation|Quick integration, not recommended for complex setups| |**Dynamic Inventory**|Ansible reads Terraform state or cloud provider API for hosts|Multiple envs, auto-scaling groups| |**Separate pipelines**|Terraform pipeline and Ansible pipeline are independent, linked by tags/outputs|Enterprise, large teams| |**Packer + Terraform**|Packer bakes an AMI with Ansible, Terraform deploys the AMI|Immutable infrastructure, fastest boot| --- ### Simple Example: Terraform Provisions VM → Ansible Configures It This is the most common real-world integration pattern. Terraform creates the VM and writes its IP to a file. Ansible reads that file and configures the server. #### Step 1 — Terraform: Create VM and Output IP ```shell # main.tf — Terraform provisions the Azure VM resource "azurerm_linux_virtual_machine" "web" { name = "vm-web-prod-001" resource_group_name = azurerm_resource_group.main.name location = azurerm_resource_group.main.location size = "Standard_B2s" admin_username = "azureuser" # SSH key for Ansible to connect admin_ssh_key { username = "azureuser" public_key = file("~/.ssh/id_rsa.pub") } network_interface_ids = [azurerm_network_interface.web.id] os_disk { caching = "ReadWrite" storage_account_type = "Standard_LRS" } source_image_reference { publisher = "Canonical" offer = "0001-com-ubuntu-server-jammy" sku = "22_04-lts" version = "latest" } tags = { managed_by = "terraform", configured_by = "ansible" } } # Public IP so Ansible can reach the VM resource "azurerm_public_ip" "web" { name = "pip-web-prod-001" resource_group_name = azurerm_resource_group.main.name location = azurerm_resource_group.main.location allocation_method = "Static" } # Output the IP — Ansible will read this output "web_server_ip" { description = "Public IP of the web server — used by Ansible inventory" value = azurerm_public_ip.web.ip_address } # Write inventory file for Ansible automatically resource "local_file" "ansible_inventory" { content = <<-EOT [web_servers] ${azurerm_public_ip.web.ip_address} ansible_user=azureuser ansible_ssh_private_key_file=~/.ssh/id_rsa EOT filename = "${path.module}/../ansible/inventory/hosts" } ``` #### Step 2 — Ansible: Configure the VM ```yaml # ansible/playbook.yml — Ansible configures the web server --- - name: Configure web server hosts: web_servers become: true # sudo vars: app_port: 8080 nginx_version: "1.24.*" tasks: - name: Update apt cache apt: update_cache: true cache_valid_time: 3600 - name: Install Nginx apt: name: "nginx={{ nginx_version }}" state: present - name: Deploy Nginx config template: src: templates/nginx.conf.j2 dest: /etc/nginx/sites-available/default owner: root group: root mode: '0644' notify: Restart Nginx - name: Start and enable Nginx systemd: name: nginx state: started enabled: true - name: Create app directory file: path: /var/www/app state: directory owner: www-data group: www-data mode: '0755' handlers: - name: Restart Nginx systemd: name: nginx state: restarted ``` ```ini # ansible/inventory/hosts — generated by Terraform local_file resource [web_servers] 20.123.45.67 ansible_user=azureuser ansible_ssh_private_key_file=~/.ssh/id_rsa ``` #### Step 3 — CI Pipeline: Run Both in Sequence ```yaml # Pipeline stage order: Terraform → Ansible # Stage 1-4: Terraform (fmt, validate, plan, apply) — as above # Stage 5: Ansible Configuration - stage: Configure displayName: '⚙️ Ansible Configuration' dependsOn: Apply condition: succeeded() jobs: - job: AnsiblePlaybook displayName: 'Run Ansible Playbook' steps: - script: | pip install ansible --quiet # Read the web server IP from Terraform output WEB_IP=$(terraform output -raw web_server_ip) echo "Configuring server at: $WEB_IP" # Wait for SSH to become available (VM just booted) echo "Waiting for SSH..." until ssh -o StrictHostKeyChecking=no \ -o ConnectTimeout=5 \ -i ~/.ssh/id_rsa \ azureuser@$WEB_IP 'echo ready' 2>/dev/null; do echo "SSH not ready, retrying in 10s..." sleep 10 done # Run the playbook # inventory/hosts was written by Terraform local_file resource ansible-playbook \ --inventory ansible/inventory/hosts \ --private-key ~/.ssh/id_rsa \ --user azureuser \ ansible/playbook.yml echo "✅ Server configured successfully" displayName: '▶️ ansible-playbook' workingDirectory: terraform/ env: ANSIBLE_HOST_KEY_CHECKING: 'False' ANSIBLE_STDOUT_CALLBACK: 'yaml' ``` --- ### Using Terraform Output as Ansible Dynamic Inventory For more complex setups (multiple VMs, auto-scaling groups), use Ansible's dynamic inventory instead of a static file: ```python # ansible/inventory/terraform_inventory.py # Dynamic inventory script that reads Terraform state import subprocess import json import sys def get_terraform_outputs(): result = subprocess.run( ['terraform', 'output', '-json'], capture_output=True, text=True, cwd='../terraform' ) return json.loads(result.stdout) def build_inventory(): outputs = get_terraform_outputs() inventory = { "web_servers": { "hosts": outputs.get('web_server_ips', {}).get('value', []), "vars": { "ansible_user": "azureuser", "ansible_ssh_private_key_file": "~/.ssh/id_rsa" } }, "_meta": { "hostvars": {} } } return inventory if __name__ == '__main__': print(json.dumps(build_inventory(), indent=2)) ``` ```bash # Run with dynamic inventory chmod +x ansible/inventory/terraform_inventory.py ansible-playbook \ --inventory ansible/inventory/terraform_inventory.py \ ansible/playbook.yml ``` --- ### Key Rules for Terraform + Ansible Integration |Rule|Reason| |---|---| |**Terraform first, Ansible second**|Infrastructure must exist before you can configure it| |**Wait for SSH readiness**|VMs take 30–120 seconds to boot after Terraform apply completes| |**Use `local_file` for static inventory**|Simple, version-controlled, works for fixed-count VMs| |**Use dynamic inventory for auto-scaling**|Static files don't work when VM count changes dynamically| |**Don't use `remote-exec` provisioner**|Fragile, breaks idempotency, hard to debug — use Ansible instead| |**Tag VMs with `configured_by = "ansible"`**|Makes it clear in the console which tool owns configuration| |**Use `ansible_host_key_checking = False` in CI**|First SSH connection to new VMs fails host key verification| |**Store SSH private key in CI secrets**|Never commit private keys — use pipeline secret variables| |**Separate Terraform and Ansible repos**|Different cadences: infra changes rarely, app config changes often| --- ## 11. Security Scanning Tools |Tool|Type|Language|Key Features|Config File| |---|---|---|---|---| |**tfsec**|Static analysis|Go|150+ checks, fast, inline ignores, custom checks|`.tfsec/config.yml`| |**checkov**|Policy scanner|Python|Multi-framework (TF/CF/K8s/Docker), 1000+ checks, SARIF output|`.checkov.yaml`| |**terrascan**|Policy as code|Go|OPA/Rego policies, 500+ checks, CI-friendly|`config.toml`| |**Snyk IaC**|Commercial|—|Jira integration, PR blocking, remediation advice|`.snyk`| |**KICS**|Open source|Go|2400+ queries, multi-IaC, SARIF|`config.json`| |**Trivy**|Multi-purpose scanner|Go|IaC + container + SBOMs, fast|`.trivyignore`| |**Semgrep**|Static analysis|—|Custom rules, multi-language, SAST + IaC|`.semgrep.yml`| |**Infracost**|Cost analysis|Go|Cost delta per PR, budget alerts, Slack/GitHub integration|`infracost.yml`| |**Sentinel**|Policy enforcement|Go (HCL-like)|TFE/TFC only, runs between plan and apply|`.sentinel` files| |**OPA/Conftest**|Policy evaluation|Go + Rego|Works on plan JSON, any CI, highly flexible|`policy/*.rego`| ### Severity Classification |Level|tfsec|checkov|Action in CI| |---|---|---|---| |**CRITICAL**|`CRITICAL`|`CRITICAL`|Block PR, immediate fix required| |**HIGH**|`HIGH`|`HIGH`|Block PR, fix before merge| |**MEDIUM**|`MEDIUM`|`MEDIUM`|Warn only, track in backlog| |**LOW**|`LOW`|`LOW`|Informational, optional| --- ## 12. State Commands Reference |Command|Description|⚠️ Risk| |---|---|---| |`terraform state list`|List all resources in state|None| |`terraform state list 'module.vpc.*'`|List resources matching pattern|None| |`terraform state show resource_type.name`|Show full details of one resource|None| |`terraform state mv old_addr new_addr`|Rename/move resource in state|Medium — back up first| |`terraform state mv resource.name 'module.mod.resource.name'`|Move into module|Medium| |`terraform state rm resource_type.name`|Remove from state (no destroy)|Medium — resource becomes unmanaged| |`terraform state pull`|Download state as JSON to stdout|None (read-only)| |`terraform state push terraform.tfstate`|Upload state file to backend|High — can overwrite| |`terraform state replace-provider old new`|Replace provider source in state|Medium| |`terraform force-unlock LOCK_ID`|Release stuck state lock|High — use only if certain no apply is running| |`terraform import resource_type.name cloud_id`|Import existing resource|Low| ```bash # Always backup before state surgery terraform state pull > backup-$(date +%Y%m%d-%H%M%S).tfstate # Safe rename workflow terraform state mv azurerm_resource_group.old azurerm_resource_group.new terraform plan # verify: should show 0 changes ``` --- ## 13. TF_LOG Levels |Level|Verbosity|What It Shows|Use Case| |---|---|---|---| |`TRACE`|Maximum|Every API request + response body, all provider calls, raw HTTP|Deep provider debugging, API call tracing| |`DEBUG`|Very high|Provider logic, CRUD calls, state read/write operations|Provider behavior debugging| |`INFO`|Medium|High-level operational messages, init steps|General operational visibility| |`WARN`|Low|Non-fatal warnings, deprecation notices|Catch deprecations before they become errors| |`ERROR`|Minimal|Only errors|Production log aggregation| |`OFF`|None|Nothing|Disable all logging| ```bash # Basic usage TF_LOG=DEBUG terraform apply # Save to file (critical for sharing logs) TF_LOG=DEBUG TF_LOG_PATH=./terraform-debug.log terraform apply # Separate core and provider logs (Terraform 1.1+) TF_LOG_CORE=INFO TF_LOG_PROVIDER=DEBUG terraform plan # ⚠️ TRACE logs contain sensitive values — never commit or share publicly ``` --- ## 14. Environment Variables |Variable|Description|Example| |---|---|---| |`TF_VAR_name`|Set a Terraform variable|`TF_VAR_region=eu-west-1`| |`TF_LOG`|Log verbosity level|`TF_LOG=DEBUG`| |`TF_LOG_CORE`|Core log level (1.1+)|`TF_LOG_CORE=INFO`| |`TF_LOG_PROVIDER`|Provider log level (1.1+)|`TF_LOG_PROVIDER=TRACE`| |`TF_LOG_PATH`|Write logs to file|`TF_LOG_PATH=./debug.log`| |`TF_CLI_ARGS`|Extra args for all commands|`TF_CLI_ARGS="-no-color"`| |`TF_CLI_ARGS_plan`|Extra args for plan only|`TF_CLI_ARGS_plan="-parallelism=20"`| |`TF_CLI_ARGS_apply`|Extra args for apply only|`TF_CLI_ARGS_apply="-auto-approve"`| |`TF_DATA_DIR`|Override `.terraform` directory|`TF_DATA_DIR=/tmp/.terraform`| |`TF_WORKSPACE`|Select workspace|`TF_WORKSPACE=staging`| |`TF_IN_AUTOMATION`|Suppress interactive prompts|`TF_IN_AUTOMATION=true`| |`TF_INPUT`|Disable interactive input|`TF_INPUT=false`| |`TF_REATTACH_PROVIDERS`|Debug provider in-process|(advanced debugging)| |**AWS**||| |`AWS_ACCESS_KEY_ID`|AWS access key|—| |`AWS_SECRET_ACCESS_KEY`|AWS secret key|—| |`AWS_REGION`|Default AWS region|`AWS_REGION=eu-west-1`| |`AWS_PROFILE`|Named AWS profile|`AWS_PROFILE=prod`| |**Azure**||| |`ARM_CLIENT_ID`|Service principal app ID|—| |`ARM_CLIENT_SECRET`|Service principal secret|—| |`ARM_TENANT_ID`|Azure AD tenant ID|—| |`ARM_SUBSCRIPTION_ID`|Azure subscription ID|—| |`ARM_USE_OIDC`|Use OIDC (Workload Identity)|`ARM_USE_OIDC=true`| |`ARM_USE_MSI`|Use Managed Identity|`ARM_USE_MSI=true`| |**GCP**||| |`GOOGLE_APPLICATION_CREDENTIALS`|Path to service account JSON|`/path/to/sa.json`| |`GOOGLE_PROJECT`|Default GCP project|—| --- ## 15. Terraform vs Alternatives |Tool|Type|Language|Approach|Mutable?|Best For| |---|---|---|---|---|---| |**Terraform / OpenTofu**|IaC provisioning|HCL|Declarative, desired state|No (immutable)|Multi-cloud resource provisioning| |**Pulumi**|IaC provisioning|TypeScript, Python, Go, C#|Declarative via real code|No|Developers who prefer real languages| |**AWS CloudFormation**|IaC provisioning|JSON / YAML|Declarative|No|AWS-only, native service integration| |**Azure Bicep**|IaC provisioning|DSL|Declarative|No|Azure-only, Microsoft-native| |**AWS CDK**|IaC provisioning|TypeScript, Python, Java|Imperative → CF template|No|AWS developers, complex logic| |**Ansible**|Config management|YAML (Playbooks)|Procedural|Yes|Software installation, OS config| |**Chef**|Config management|Ruby (DSL)|Procedural + recipes|Yes|Enterprise config management| |**Puppet**|Config management|Puppet DSL|Declarative|Yes|Large-scale OS compliance| |**Crossplane**|K8s-native IaC|YAML (CRDs)|Declarative, K8s native|No|Platform teams using K8s as control plane| |**Terragrunt**|Terraform wrapper|HCL|DRY Terraform|No|Multi-env, DRY backend configs| |**Atlantis**|GitOps for Terraform|—|PR automation|No|PR-based plan/apply workflows| --- ## 16. Provider Authentication Methods ### Azure (`azurerm`) |Method|Best For|How| |---|---|---| |**OIDC (Workload Identity Federation)**|CI/CD pipelines|`ARM_USE_OIDC=true` + federated credential on App Registration| |**Managed Identity**|Azure-hosted runners|`ARM_USE_MSI=true`| |**Service Principal + Secret**|Traditional CI|`ARM_CLIENT_ID` + `ARM_CLIENT_SECRET` env vars| |**Service Principal + Cert**|High security|`ARM_CLIENT_CERTIFICATE_PATH`| |**Azure CLI**|Local development|`az login` → provider uses CLI token| |**Environment variables**|CI/CD|`ARM_*` env vars| ### AWS (`aws`) |Method|Best For|How| |---|---|---| |**OIDC (IAM OIDC provider)**|GitHub Actions, GitLab|`assume_role_with_web_identity`| |**Instance Role (EC2/ECS)**|AWS-hosted CI|Automatic via metadata service| |**Named profile**|Local dev|`AWS_PROFILE=prod` or `profile = "prod"` in provider| |**Access key + secret**|Traditional CI|`AWS_ACCESS_KEY_ID` + `AWS_SECRET_ACCESS_KEY`| |**AWS SSO**|Enterprise local dev|`aws sso login --profile prod`| ### GCP (`google`) |Method|Best For|How| |---|---|---| |**Workload Identity Federation**|CI/CD|`GOOGLE_APPLICATION_CREDENTIALS` with WIF config| |**Service Account JSON**|Traditional CI|`GOOGLE_APPLICATION_CREDENTIALS=/path/to/sa.json`| |**Application Default Credentials**|Local dev|`gcloud auth application-default login`| |**Compute Engine SA**|GCP-hosted CI|Automatic via metadata server| --- ## 17. Module Sources |Source Type|Syntax|Version Pinning| |---|---|---| |**Terraform Registry (public)**|`source = "hashicorp/consul/aws"`|`version = "~> 0.5"`| |**Terraform Registry (namespace)**|`source = "terraform-aws-modules/vpc/aws"`|`version = "~> 5.0"`| |**GitHub (HTTPS)**|`source = "github.com/org/repo"`|`?ref=v1.2.0`| |**GitHub (SSH)**|`source = "[email protected]:org/repo.git"`|`?ref=v1.2.0`| |**Generic Git (HTTPS)**|`source = "git::https://example.com/repo.git"`|`?ref=v1.0.0`| |**Generic Git (SSH)**|`source = "git::ssh://[email protected]/repo.git"`|`?ref=main`| |**Bitbucket**|`source = "bitbucket.org/org/repo"`|`?ref=v1.0`| |**Local path**|`source = "./modules/network"`|None (always current)| |**Local path (parent)**|`source = "../shared-modules/vpc"`|None| |**S3 bucket**|`source = "s3::https://s3.amazonaws.com/bucket/module.zip"`|Hash in URL| |**GCS bucket**|`source = "gcs::https://www.googleapis.com/storage/v1/bucket/module.zip"`|Hash in URL| |**Terraform Cloud private**|`source = "app.terraform.io/org/module/provider"`|`version = "~> 1.0"`| |**HTTP archive**|`source = "https://example.com/module.zip"`|Hash in URL| ```hcl module "vpc" { source = "terraform-aws-modules/vpc/aws" version = "~> 5.0" name = "my-vpc" cidr = "10.0.0.0/16" } ``` --- ## 18. Expression Types & Syntax |Expression Type|Syntax|Example| |---|---|---| |**String literal**|`"value"`|`name = "my-resource"`| |**String interpolation**|`"${expr}"`|`name = "app-${var.env}"`| |**Heredoc**|`<<-EOF ... EOF`|Multi-line strings| |**Number**|Integer or float|`count = 3` or `ttl = 60.5`| |**Bool**|`true` or `false`|`enabled = true`| |**null**|`null`|`value = null` (unset optional)| |**List/Tuple**|`[val, val, ...]`|`cidrs = ["10.0.0.0/24", "10.0.1.0/24"]`| |**Map/Object**|`{key = val, ...}`|`tags = { env = "prod" }`| |**Conditional**|`cond ? true_val : false_val`|`size = var.large ? "XL" : "S"`| |**For list**|`[for i in list : transform]`|`[for s in names : upper(s)]`| |**For map**|`{for k, v in map : k => transform}`|`{for k, v in tags : k => upper(v)}`| |**For with filter**|`[for i in list : i if condition]`|`[for s in names : s if length(s) > 3]`| |**Splat**|`list[*].attr`|`aws_instance.web[*].id`| |**Dynamic block**|`dynamic "name" { for_each = ... }`|Repeat nested blocks| |**Sensitive**|`sensitive(value)`|`sensitive(var.secret)`| |**Type conversion**|`tostring()`, `tonumber()`, `tobool()`|`tostring(3)` → `"3"`| |**Can**|`can(expr)`|`can(regex("^ami-", var.id))` → bool| |**Try**|`try(expr, fallback)`|`try(var.obj.key, "default")`| --- ## 19. Plan Exit Codes Used with `terraform plan -detailed-exitcode` in CI for drift detection and scripting. |Exit Code|Meaning|CI Action| |---|---|---| |`0`|Success, no changes (infra matches config)|✅ All good, no drift| |`1`|Error (configuration or provider error)|❌ Fix the error| |`2`|Success, changes present (diff detected)|⚠️ Drift! Alert or auto-apply| ```bash #!/bin/bash terraform plan -detailed-exitcode -out=tfplan exit_code=$? case $exit_code in 0) echo "✅ No changes — infrastructure is up to date" ;; 1) echo "❌ Error during plan" ; exit 1 ;; 2) echo "⚠️ Changes detected — review tfplan" ;; esac ``` --- ## 20. Common Errors & Fixes |Error Message|Root Cause|Fix| |---|---|---| |`Error acquiring the state lock`|Another apply is running (or lock stuck)|Wait for other apply, or `terraform force-unlock LOCK_ID`| |`No valid credential sources found`|Missing cloud credentials|Set `AWS_*` / `ARM_*` env vars or configure local credentials| |`Error: Provider produced inconsistent result after apply`|Provider bug or race condition|Upgrade provider, run apply again| |`Error: Unsupported argument`|Attribute not in current provider version|Upgrade provider or remove unsupported attribute| |`Error: cycle`|Circular dependency between resources|Restructure — use `data` source or intermediate resource| |`Error: Reference to undeclared resource`|Typo in resource reference|Fix the resource address (type.name)| |`FailedMount: hostPath type check failed`|Mounting non-existent socket/file|Fix `hostPath.path` to existing path, correct `type`| |`Error: Already exists`|Resource already created outside Terraform|`terraform import` the resource into state| |`Error: Error modifying ... (StatefulResourceRequiresNoChange)`|Attempted to change immutable attribute|Destroy and recreate (add `create_before_destroy`)| |`Warning: Deprecated`|Using deprecated argument/resource|Migrate to replacement per provider upgrade guide| |`Error: Invalid count argument`|`count` uses unknown value (computed at apply)|Use `-target` to pre-create dependency, or use `for_each`| |`Error: This object does not have an attribute named "..."`|Referencing missing output from module|Add `output` block to the module| |`Error: state snapshot was created by Terraform vX`|State file created by newer Terraform version|Upgrade Terraform CLI to match or newer| |`Error: Backend configuration changed`|Backend config in code differs from state|Run `terraform init -reconfigure`| |`Error: Variables not allowed`|Using variable in backend block|Use partial config: `terraform init -backend-config=file.hcl`| --- ## 21. Workspace Commands |Command|Description| |---|---| |`terraform workspace list`|List all workspaces (`*` marks current)| |`terraform workspace show`|Show current workspace name| |`terraform workspace new dev`|Create and switch to new workspace `dev`| |`terraform workspace select prod`|Switch to existing workspace `prod`| |`terraform workspace delete dev`|Delete workspace `dev` (must not be current)| ```shell # Reference workspace name in resources resource "azurerm_resource_group" "main" { name = "rg-${terraform.workspace}-westeurope" location = "West Europe" } # Use workspace for conditional logic locals { is_prod = terraform.workspace == "prod" instance_count = local.is_prod ? 3 : 1 } ``` --- ## 22. Data Types |Type|Syntax|Example|Notes| |---|---|---|---| |`string`|`type = string`|`"hello"`|Default type for unspecified| |`number`|`type = number`|`42` or `3.14`|Int or float| |`bool`|`type = bool`|`true` or `false`|| |`list(TYPE)`|`type = list(string)`|`["a", "b"]`|Ordered, indexed by int| |`set(TYPE)`|`type = set(string)`|`toset(["a", "b"])`|Unordered, unique values| |`map(TYPE)`|`type = map(string)`|`{a = "x", b = "y"}`|String keys, uniform value type| |`object({...})`|`type = object({name=string, count=number})`|`{name="x", count=1}`|Structured, mixed types| |`tuple([...])`|`type = tuple([string, number, bool])`|`["x", 1, true]`|Fixed-length, mixed types| |`any`|`type = any`|Anything|Opt out of type checking| ```shell # Complex variable with optional() — Terraform 1.3+ variable "app_config" { type = object({ name = string replicas = optional(number, 1) environment = optional(map(string), {}) tags = optional(set(string), []) }) } ``` --- ## 23. Terraform vs OpenTofu |Aspect|Terraform (HashiCorp)|OpenTofu (Linux Foundation)| |---|---|---| |**License**|BSL 1.1 (non-OSI, commercial restrictions)|MPL 2.0 (open source, OSI approved)| |**Fork basis**|Terraform 1.5.5 (last MPL version)|Forked from Terraform 1.5.5| |**Governance**|HashiCorp (IBM)|Linux Foundation, community-driven| |**HCL compatibility**|✅ Reference implementation|✅ Fully compatible| |**State format**|Same format|Same format (interoperable)| |**Provider compatibility**|✅ All providers work|✅ All providers work| |**TFC/TFE**|✅ Native integration|❌ Must use alternative (Spacelift, Scalr)| |**Sentinel**|✅ TFE/TFC only|❌ Not supported (use OPA)| |**State encryption**|❌ Not built-in (backend handles)|✅ Built-in at-rest encryption (1.7+)| |**Provider-defined functions**|Limited|✅ Enhanced in 1.7+| |**Registry**|registry.terraform.io|registry.opentofu.org| |**Release cadence**|HashiCorp-controlled|Community-driven, often faster| |**Migration effort**|—|Usually zero: rename binary, same config| ```bash # Migration from Terraform to OpenTofu is trivial # Just replace the binary — same config, same state which terraform # /usr/local/bin/terraform tofu version # OpenTofu v1.7.x tofu init # same as terraform init tofu plan # same as terraform plan ``` --- ## 24. Sentinel vs OPA vs tfsec vs Checkov |Tool|Runs When|Policy Language|Where|Scope| |---|---|---|---|---| |**Sentinel**|Between plan and apply|HCL-like Sentinel language|TFE/TFC only|Terraform-specific| |**OPA / Conftest**|On plan JSON (pre-apply)|Rego|Any CI|Multi-tool (TF, K8s, Docker)| |**tfsec**|On HCL source (pre-plan)|Built-in + custom YAML/JSON|Any CI|Terraform HCL only| |**Checkov**|On HCL source (pre-plan)|Built-in + custom Python/YAML|Any CI|Multi-IaC + containers| |**Terrascan**|On HCL source (pre-plan)|OPA Rego|Any CI|Multi-IaC| |**KICS**|On HCL source (pre-plan)|Built-in queries|Any CI|Multi-IaC| ### When to Use Each ``` HCL written → tfsec/checkov (fast, no cloud creds, catches config errors) ↓ terraform plan → plan.json → OPA/Conftest (business logic, naming, tagging) ↓ [TFE/TFC only] → Sentinel (hard policy gates, advisory policies) ↓ terraform apply ``` --- ## 25. Anti-Patterns Quick Reference |Anti-Pattern|Why It's Bad|Fix| |---|---|---| |Local state|No sharing, no locking, no backup|Remote backend (S3, Azure Blob, TFC)| |Hardcoded account IDs / resource IDs|Breaks in other environments|Use `data` sources or variables| |Hardcoded secrets in `.tfvars` (committed)|Secret exposure|`TF_VAR_*` env vars or secrets manager| |`count` for distinct resources|Index shift on removal = destroy+recreate|`for_each` with stable keys| |Giant root module (100+ resources)|Slow plans, huge blast radius|Split by domain or team| |No provider version pinning|`init` can get breaking provider|`version = "~> 3.90"` + lock file| |Not committing `.terraform.lock.hcl`|Non-reproducible provider installs|Commit the lock file| |`terraform apply` locally to prod|Bypasses all controls and audit trail|CI/CD pipeline only| |No approval gate before apply|Unreviewed changes go live|Manual approval stage in CI| |Not reading the plan|Unexpected destroys slip through|Read every `+`, `~`, `-` line| |Deeply nested modules (4+ levels)|Undebuggable, complex state paths|Max 2-3 levels of nesting| |`ignore_changes = all` overuse|Resource becomes effectively unmanaged|Target specific attributes only| |`depends_on` everywhere|Hides real dependency issues|Fix references to create implicit deps| |No `prevent_destroy` on stateful resources|`terraform destroy` deletes databases|Add lifecycle rule to critical resources| |Using `terraform taint` (deprecated)|Old workflow|Use `terraform apply -replace=resource`| --- ## 26. Multi-Environment Strategies |Strategy|Structure|Pros|Cons|Best For| |---|---|---|---|---| |**Separate directories**|`envs/dev/`, `envs/staging/`, `envs/prod/`|Clear isolation, separate state|Code duplication risk|Small-medium teams| |**Workspaces**|Same dir, `terraform workspace new prod`|Simple, single codebase|Shared code = shared blast radius|Feature envs, not prod/staging split| |**Separate repos**|`infra-dev`, `infra-prod` repos|Maximum isolation|Hard to keep in sync|Strict compliance requirements| |**Terragrunt**|Single modules/, env-specific `terragrunt.hcl`|DRY, auto backend config, dependency management|Extra tool to learn|Multi-env at scale| |**Terraform Cloud**|Workspaces per env in TFC|Built-in, UI, RBAC|Cost, vendor lock-in|Enterprise teams| ### Terragrunt Structure (Recommended for Scale) ``` infrastructure/ ├── modules/ │ ├── networking/ │ ├── compute/ │ └── database/ ├── terragrunt.hcl ← root: backend + common inputs ├── dev/ │ ├── terragrunt.hcl ← env inputs │ ├── networking/ │ │ └── terragrunt.hcl ← module call │ └── compute/ │ └── terragrunt.hcl └── prod/ ├── terragrunt.hcl ├── networking/ └── compute/ ``` --- ## 27. Azure-Specific Resource Mapping |Azure Concept|Terraform Resource|Common Attributes| |---|---|---| |Resource Group|`azurerm_resource_group`|`name`, `location`, `tags`| |Virtual Network|`azurerm_virtual_network`|`address_space`, `resource_group_name`| |Subnet|`azurerm_subnet`|`address_prefixes`, `virtual_network_name`| |Network Security Group|`azurerm_network_security_group`|`security_rule {}`| |NSG Association|`azurerm_subnet_network_security_group_association`|`subnet_id`, `network_security_group_id`| |Public IP|`azurerm_public_ip`|`allocation_method`, `sku`| |Load Balancer|`azurerm_lb`|`frontend_ip_configuration {}`| |AKS Cluster|`azurerm_kubernetes_cluster`|`default_node_pool {}`, `identity {}`| |AKS Node Pool|`azurerm_kubernetes_cluster_node_pool`|`kubernetes_cluster_id`, `vm_size`| |App Service Plan|`azurerm_service_plan`|`os_type`, `sku_name`| |App Service|`azurerm_linux_web_app`|`service_plan_id`, `site_config {}`| |Function App|`azurerm_linux_function_app`|`storage_account_name`, `functions_extension_version`| |Storage Account|`azurerm_storage_account`|`account_tier`, `account_replication_type`| |Key Vault|`azurerm_key_vault`|`tenant_id`, `sku_name`, `access_policy {}`| |Key Vault Secret|`azurerm_key_vault_secret`|`name`, `value`, `key_vault_id`| |SQL Server|`azurerm_mssql_server`|`administrator_login`, `version`| |SQL Database|`azurerm_mssql_database`|`server_id`, `sku_name`, `max_size_gb`| |PostgreSQL Flexible|`azurerm_postgresql_flexible_server`|`sku_name`, `storage_mb`, `zone`| |Container Registry|`azurerm_container_registry`|`sku`, `admin_enabled`| |Role Assignment|`azurerm_role_assignment`|`scope`, `role_definition_name`, `principal_id`| |Service Principal|`azuread_service_principal`|`application_id`| |App Registration|`azuread_application`|`display_name`| |DNS Zone|`azurerm_dns_zone`|`name`, `resource_group_name`| |Private Endpoint|`azurerm_private_endpoint`|`subnet_id`, `private_service_connection {}`| |Log Analytics|`azurerm_log_analytics_workspace`|`sku`, `retention_in_days`| |Monitor Diagnostic|`azurerm_monitor_diagnostic_setting`|`target_resource_id`, `log_analytics_workspace_id`| --- ## 28. AWS-Specific Resource Mapping |AWS Concept|Terraform Resource|Common Attributes| |---|---|---| |VPC|`aws_vpc`|`cidr_block`, `enable_dns_hostnames`| |Subnet|`aws_subnet`|`vpc_id`, `cidr_block`, `availability_zone`| |Internet Gateway|`aws_internet_gateway`|`vpc_id`| |Route Table|`aws_route_table`|`vpc_id`, `route {}`| |Security Group|`aws_security_group`|`vpc_id`, `ingress {}`, `egress {}`| |EC2 Instance|`aws_instance`|`ami`, `instance_type`, `subnet_id`| |Auto Scaling Group|`aws_autoscaling_group`|`launch_template {}`, `min/max/desired_capacity`| |ALB|`aws_lb`|`internal`, `load_balancer_type`, `subnets`| |EKS Cluster|`aws_eks_cluster`|`role_arn`, `vpc_config {}`| |EKS Node Group|`aws_eks_node_group`|`cluster_name`, `node_role_arn`, `scaling_config {}`| |Lambda|`aws_lambda_function`|`function_name`, `runtime`, `handler`, `filename`| |API Gateway|`aws_apigatewayv2_api`|`protocol_type`, `route_selection_expression`| |S3 Bucket|`aws_s3_bucket`|`bucket`, `force_destroy`| |S3 Public Access Block|`aws_s3_bucket_public_access_block`|`bucket`, all four `block_*` flags| |RDS Instance|`aws_db_instance`|`engine`, `instance_class`, `db_subnet_group_name`| |RDS Cluster (Aurora)|`aws_rds_cluster`|`engine`, `engine_mode`, `master_username`| |ElastiCache|`aws_elasticache_cluster`|`engine`, `node_type`, `num_cache_nodes`| |IAM Role|`aws_iam_role`|`assume_role_policy`| |IAM Policy|`aws_iam_policy`|`policy` (JSON)| |IAM Role Attachment|`aws_iam_role_policy_attachment`|`role`, `policy_arn`| |Route53 Zone|`aws_route53_zone`|`name`| |Route53 Record|`aws_route53_record`|`zone_id`, `name`, `type`, `ttl`, `records`| |CloudFront|`aws_cloudfront_distribution`|`origin {}`, `default_cache_behavior {}`| |ACM Certificate|`aws_acm_certificate`|`domain_name`, `validation_method`| |Secrets Manager|`aws_secretsmanager_secret`|`name`, `recovery_window_in_days`| |SSM Parameter|`aws_ssm_parameter`|`name`, `type`, `value`| |SQS Queue|`aws_sqs_queue`|`name`, `fifo_queue`, `visibility_timeout_seconds`| |SNS Topic|`aws_sns_topic`|`name`, `fifo_topic`| --- ## 29. Kubernetes Provider Reference |K8s Resource|Terraform Resource|Notes| |---|---|---| |Namespace|`kubernetes_namespace`|`metadata { name = "app" }`| |Deployment|`kubernetes_deployment`|`spec { template { spec { container {} } } }`| |Service|`kubernetes_service`|`spec { type = "LoadBalancer" }`| |ConfigMap|`kubernetes_config_map`|`data = { key = "value" }`| |Secret|`kubernetes_secret`|`data = { key = base64encode("val") }`| |Persistent Volume Claim|`kubernetes_persistent_volume_claim`|`spec { access_modes = ["ReadWriteOnce"] }`| |Ingress|`kubernetes_ingress_v1`|`spec { rule {} }`| |Service Account|`kubernetes_service_account`|`metadata { namespace = "default" }`| |Role|`kubernetes_role`|`rule {}`| |RoleBinding|`kubernetes_role_binding`|`subject {}`, `role_ref {}`| |HelmRelease|`helm_release`|`chart`, `repository`, `version`, `values`| |HelmRelease (values)|`helm_release`|`set { name = "key"; value = "val" }`| ``` # Provider configuration for AKS provider "kubernetes" { host = azurerm_kubernetes_cluster.main.kube_config.0.host client_certificate = base64decode(azurerm_kubernetes_cluster.main.kube_config.0.client_certificate) client_key = base64decode(azurerm_kubernetes_cluster.main.kube_config.0.client_key) cluster_ca_certificate = base64decode(azurerm_kubernetes_cluster.main.kube_config.0.cluster_ca_certificate) } ``` --- ### Key Numbers to Know |Metric|Value| |---|---| |Default parallelism|`10` parallel resource operations| |State lock timeout|Backend-specific (DynamoDB: immediate)| |Max var name length|No hard limit, but keep practical| |Max resource count before splitting|~100-200 (performance degrades)| |Terraform Cloud free tier|Up to 500 resources per workspace| |Typical plan time (100 resources)|30-120 seconds depending on refresh| |`-detailed-exitcode` 0|No changes| |`-detailed-exitcode` 2|Changes present| ## Quick Syntax Reference ```yaml # ── RESOURCE ───────────────────────────────────────────────────── resource "aws_instance" "web" { ami = data.aws_ami.ubuntu.id instance_type = var.instance_type count = 3 # or for_each = var.map depends_on = [aws_iam_role_policy.this] provider = aws.eu lifecycle { create_before_destroy = true prevent_destroy = false ignore_changes = [tags] } } # ── DATA SOURCE ────────────────────────────────────────────────── data "aws_ami" "ubuntu" { most_recent = true owners = ["099720109477"] filter { name = "name"; values = ["ubuntu/images/hvm-ssd/ubuntu-*-22.04-amd64*"] } } # ── MODULE ─────────────────────────────────────────────────────── module "vpc" { source = "terraform-aws-modules/vpc/aws" version = "~> 5.0" name = "my-vpc" cidr = "10.0.0.0/16" } # ── VARIABLE ───────────────────────────────────────────────────── variable "environment" { description = "Deployment environment" type = string default = "dev" sensitive = false nullable = false validation { condition = contains(["dev","staging","prod"], var.environment) error_message = "Must be dev, staging, or prod." } } # ── OUTPUT ─────────────────────────────────────────────────────── output "instance_ip" { description = "Public IP of the web instance" value = aws_instance.web[0].public_ip sensitive = false depends_on = [aws_eip.web] } # ── LOCAL ──────────────────────────────────────────────────────── locals { env_prefix = "${var.environment}-${var.region}" common_tags = { environment = var.environment, managed_by = "terraform" } is_production = var.environment == "prod" } # ── PROVIDER ───────────────────────────────────────────────────── terraform { required_version = ">= 1.5.0" required_providers { aws = { source = "hashicorp/aws"; version = "~> 5.0" } azurerm = { source = "hashicorp/azurerm"; version = "~> 3.90" } } } provider "aws" { region = var.aws_region profile = var.aws_profile } provider "aws" { alias = "eu" region = "eu-west-1" } # ── MOVED BLOCK (1.1+) ─────────────────────────────────────────── moved { from = aws_instance.old_name to = aws_instance.new_name } # ── IMPORT BLOCK (1.5+) ────────────────────────────────────────── import { to = azurerm_resource_group.main id = "/subscriptions/xxx/resourceGroups/my-rg" } # ── CHECK BLOCK (1.5+) ─────────────────────────────────────────── check "app_health" { data "http" "health" { url = "https://${aws_lb.main.dns_name}/health" } assert { condition = data.http.health.status_code == 200 error_message = "Health check failed: ${data.http.health.status_code}" } } ```