## 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}"
}
}
```