Skip to main content

loom-terraform

Infrastructure as Code with Terraform/OpenTofu. Not for: app/UI state — see loom-react.

Source facts

Repository
cosmix/loom
Last source activity
September 19, 2026 at 08:01
Detected SKILL.md language
English
Stars
61
Forks
4

Install options

The review-first prompt is selected by default. You can switch to a direct command or download a local copy.

Review the source files

Read SKILL.md and any companion files shown by SkillsMP before deciding whether to install.

Showing SKILL.md

SKILL.md
Source instructions · Read-only preview
name
loom-terraform
description
Infrastructure as Code with Terraform/OpenTofu. Not for: app/UI state — see loom-react.
allowed-tools
["Read","Grep","Glob","Edit","Write","Bash"]
triggers
["terraform","tf","opentofu","tofu","infrastructure","IaC","infrastructure as code","provision","cloud","aws","azure","gcp","kubernetes","k8s","module","provider","backend","plan","apply","workspace","resource","data source","output","variable","locals","import","taint","destroy"]
# Terraform / OpenTofu ## Overview Terraform/OpenTofu infrastructure: module design, state/backends, multi-environment strategy, provider config, refactoring/import. **Prefer `tofu` over `terraform`** — OpenTofu is the Linux Foundation fork, config-compatible, and has capabilities OSS Terraform lacks (client-side state encryption). Command mapping table is at the end. The single highest-value section is **Expert Practices** below — the anti-patterns/gotchas there are where naive-but-working configs go wrong. ## Module & Configuration Design Canonical layout — shared `modules/`, one root module per environment, lock file committed: ```text terraform/ ├── modules/{vpc,eks,rds}/ # each: main.tf variables.tf outputs.tf versions.tf ├── environments/{dev,staging,prod}/ # each: main.tf backend.tf terraform.tfvars .terraform.lock.hcl └── .terraform.lock.hcl ``` Principles: reusable modules with typed, validated, described inputs; providers pinned in `required_providers`; remote state with locking; `locals` for computed/DRY values; documented outputs; `lifecycle` rules where deletion/replacement risk exists. **Variable validation** — validate at the boundary; `contains()` for enums, length/regex for shape: ```hcl variable "environment" { type = string validation { condition = contains(["dev", "staging", "prod"], var.environment) error_message = "Environment must be dev, staging, or prod." } } ``` **Module composition** — wire flat modules via input/output, never nest deep (see Anti-Patterns). Version external modules with `~>`; pin internal git modules by tag `ref=`: ```hcl module "vpc" { source = "terraform-aws-modules/vpc/aws", version = "~> 5.0" } module "internal" { source = "git::https://github.com/org/tf-modules.git//vpc?ref=v1.2.3" } ``` ## State & Backends **S3 with native locking** (no DynamoDB): ```hcl terraform { backend "s3" { bucket = "myorg-terraform-state" key = "prod/eks/terraform.tfstate" region = "us-west-2" encrypt = true kms_key_id = "arn:aws:kms:us-west-2:123456789012:key/..." use_lockfile = true # native S3 conditional-write lock; no DynamoDB table/IAM } } ``` - `use_lockfile = true` locks via an S3 conditional `PutObject` of `<key>.tflock`. In **Terraform**, `dynamodb_table` locking is deprecated (future removal); migrate by keeping BOTH until all CI/operators cut over, then drop `dynamodb_table`. **OpenTofu has NOT deprecated DynamoDB locking** — this is Terraform-only. - **`backend` blocks cannot reference `var.*`/`local.*`/`data.*`** — evaluated before variables exist; any reference errors at `init`. Use *partial configuration*: omit dynamic args, supply via `-backend-config=KEY=VALUE` or a `.tfbackend` file. - **NEVER pass credentials via `-backend-config`** (persisted into `.terraform/` and plan files) or hardcode them — supply backend creds only through env vars (`AWS_ACCESS_KEY_ID`, etc.). - `lifecycle` is a resource meta-argument — **invalid inside a `backend` block**. Protect the state bucket via `prevent_destroy` on its own `aws_s3_bucket` resource + versioning/object-lock. ```bash tofu init -backend-config="bucket=$TF_STATE_BUCKET" -backend-config="key=prod/app.tfstate" ``` **State operations** — prefer the version-controlled block form (`moved`/`removed`/`import`, see Idioms) over these imperative commands: | Command | Purpose | Prefer | | --- | --- | --- | | `tofu state list` / `show <addr>` | inspect | — | | `tofu state mv A B` | rename/move | `moved` block | | `tofu state rm <addr>` | drop from state (no destroy) | `removed { lifecycle { destroy = false } }` | | `tofu import <addr> <id>` | adopt existing resource | `import` block (+ `-generate-config-out`) | | `tofu state pull > s.json` / `push s.json` | raw read/write (push is DANGEROUS) | — | | `tofu state replace-provider OLD NEW` | after provider migration | — | | `tofu force-unlock <LOCK_ID>` | clear stuck lock (verify nothing running first) | — | **State migration** — configure new backend, then `tofu init -migrate-state` (add `-backend-config=...` for target). Always encrypt remote state; never commit state files. ## Multi-Environment **Strategy 1 (RECOMMENDED): directory-per-env.** Each env is a separate root module with its OWN backend bucket and IAM role — switching envs requires a deliberate `cd`, so a dev shell physically cannot target prod. Right choice for dev/staging/prod. **Strategy 2: workspaces — NOT environment isolation.** Official docs: workspaces "are not appropriate for system decomposition or deployments requiring separate credentials and access controls." All workspaces share ONE backend, ONE auth context, ONE provider config — no prod/dev boundary, and a mistyped `tofu workspace select prod && tofu apply` hits prod with no config guard. Use ONLY for short-lived variants within a single access boundary (PR-preview/ephemeral test envs). **Strategy 3: tfvars files** — one root module, per-env var files. Flexible but shares backend/creds like workspaces unless combined with Strategy 1: ```bash tofu apply -var-file="environments/prod.tfvars" ``` ## Security Compact, non-obvious flags (the surrounding IAM/SG HCL is standard): ```hcl # KMS: rotation + generous deletion window resource "aws_kms_key" "data" { enable_key_rotation = true deletion_window_in_days = 30 } # S3 SSE-KMS with bucket keys (cuts KMS API cost ~99% on high-volume buckets) resource "aws_s3_bucket_server_side_encryption_configuration" "data" { bucket = aws_s3_bucket.data.id rule { apply_server_side_encryption_by_default { kms_master_key_id = aws_kms_key.data.arn, sse_algorithm = "aws:kms" } bucket_key_enabled = true } } # RDS encryption is create-time only — cannot be toggled on an existing instance resource "aws_db_instance" "main" { storage_encrypted = true, kms_key_id = aws_kms_key.data.arn } ``` - **Define SG rules as separate `aws_security_group_rule` (or `aws_vpc_security_group_ingress_rule`) resources**, not inline `ingress`/`egress` blocks, when two SGs reference each other — inline blocks force a dependency cycle; separate rule resources break it. - Store credentials in Secrets Manager/SSM and pass only ARNs; mark any secret-bearing output `sensitive = true` (but see Security below — that does NOT keep it out of state). - IAM: scope `Action`/`Resource` to exact ARNs; avoid `"*"` outside the KMS root key policy. ## Providers ```hcl terraform { # Floor = primitives this config uses. 1.5 import/check blocks; 1.9 cross-object validation. # Raise: >=1.10 ephemeral values; >=1.11 write-only args + native S3 locking. # OpenTofu: >=1.7 state encryption; >=1.10 S3 locking. required_version = ">= 1.9.0" required_providers { aws = { source = "hashicorp/aws", version = "~> 5.0" } # 5.x, not 6.0 kubernetes = { source = "hashicorp/kubernetes", version = "~> 2.23" } } } provider "aws" { region = var.region default_tags { tags = { Environment = var.environment, ManagedBy = "terraform" } } } # Aliases for multi-region/account — resources/modules select via `provider =` / `providers = {}` provider "aws" { alias = "replica", region = "us-east-1" } resource "aws_s3_bucket" "replica" { provider = aws.replica, bucket = "${var.name}-replica" } ``` `default_tags` auto-applies to every taggable resource — no per-resource `merge()` needed for org-wide tags. ## Core Patterns **Dynamic blocks** — generate repeatable nested blocks from a collection: ```hcl dynamic "ingress" { for_each = var.ingress_rules content { from_port = ingress.value.from_port to_port = ingress.value.to_port protocol = ingress.value.protocol cidr_blocks = ingress.value.cidr_blocks } } ``` **`for` expressions** — build maps/lists; filter with `if`: ```hcl subnet_by_az = { for s in aws_subnet.private : s.availability_zone => s.id } prod_instances = { for k, v in var.instances : k => v if v.environment == "production" } ``` `count` vs `for_each`, `lifecycle` (`create_before_destroy`/`prevent_destroy`/`ignore_changes`), and `depends_on` are covered in depth under **Expert Practices** — read the Gotchas there before using any of them. ## Troubleshooting | Symptom | Fix | | --- | --- | | State lock timeout | Verify nothing running, then `tofu force-unlock <LOCK_ID>` | | Corrupt provider cache | `rm -rf .terraform/ && tofu init` — do NOT delete `.terraform.lock.hcl` | | Resource exists but not in state | `import` block (or `tofu import`), then reconcile HCL | | Circular dependency | Extract the coupling into a standalone resource (e.g. `aws_security_group_rule`) or make deps unidirectional | | Force replacement | `tofu apply -replace="aws_x.y"` — `taint` is DEPRECATED (mutates state with no plan preview) | | Drift from console change | `tofu plan -refresh-only` to detect; `tofu apply` to restore code definition | | Module source/version changed | `tofu init -upgrade` | | Long-running create/update/delete | resource `timeouts { create = "60m" ... }` block | **Regenerate (never just delete) a corrupt lock file** across all target platforms — deleting it discards validated checksum pins and can pull a different version next `init`: ```bash tofu providers lock -platform=linux_amd64 -platform=darwin_arm64 -platform=windows_amd64 ``` **Sensitive data in state:** `sensitive = true` only redacts UI output — the value is still plaintext in state. Real fixes: write-only args (TF ≥1.11) + ephemeral resources (≥1.10) never persist; OpenTofu ≥1.7 `encryption` block; or external store passing only ARNs. See Expert Practices → Security. ## Testing & Validation ```bash tofu fmt -recursive # format (run before every commit) tofu validate # config validity (no cloud calls) tfsec . # or trivy/checkov — static security scan infracost breakdown --path . # cost estimate ``` **Plan review checklist:** all changes expected · no unintended `-/+` (destroy/replace) · sensitive values not in outputs · encryption enabled · IAM least-privilege · SGs restrictive · tags present. ## Expert Practices: Idioms, Anti-Patterns & Gotchas High-signal guidance from the official docs. The *why* matters as much as the rule. ### Anti-Patterns **Never put a `provider` block in a reusable child module.** Docs: "A module intended to be called by one or more other modules must not contain any provider blocks," and "a module with its own provider configurations is not compatible with for_each, count, or depends_on" on the call. Mechanism: a provider config and the resources it manages must be destroyed together, but the graph cannot guarantee that ordering once the call is multiplied/ordered. Declare `provider` blocks only in root modules; pass aliased providers explicitly via `providers`. **Aliased providers are NEVER inherited** — forgetting to pass one silently falls back to the default (wrong region/account). Child modules declare `required_providers` for version constraints only — no provider block, no `backend` block. ```hcl provider "aws" { alias = "usw2", region = "us-west-2" } module "app" { source = "./modules/app" providers = { aws = aws.usw2 } for_each = var.environments # works only because the module has no provider block } ``` **Keep module composition flat (single level of child modules).** Modules take inputs, emit outputs, and know nothing about where state lives. Compose flat at the root and wire via input/output rather than nesting (root → A → B → C), which obscures dependency flow, complicates provider passing, and makes refactoring hazardous. ### Idioms **Don't repeat the resource type in the name label.** `aws_security_group.app` already carries the type; `aws_security_group.app_security_group` says the noun twice. Short role-descriptive nouns, underscores not dashes (`aws_instance.web`, not `aws_instance.ec2-web-server`). The `this`/`main` singleton convention is a *community* idiom, NOT in the official style guide. **Use `moved`/`removed`/`import` blocks, not the imperative CLI.** `state mv`/`import`/`taint` are imperative: every operator must run them, they aren't version-controlled or reproducible. Block forms are validated, reviewable in PRs, and run in CI with no manual steps. - `moved` (≥1.1) encodes renames. **Never delete a published `moved` block** — it makes configs referencing the old address plan a *delete* instead of a move. Chain renames across successive moves; retain all historical blocks. - `removed` (≥1.7) with `lifecycle { destroy = false }` drops a resource from management WITHOUT destroying real infra. - `import` (≥1.5) blocks make import plannable; pair with `-generate-config-out=FILE` to auto-generate HCL for brownfield adoption. OpenTofu 1.7+ adds `for_each` for bulk imports. Unlike `moved`, import blocks may be deleted after they succeed. ```hcl moved { from = aws_instance.server, to = aws_instance.app_server } # chain, never prune removed { from = aws_db_instance.legacy, lifecycle { destroy = false } } # keep real infra import { to = aws_instance.app, id = "i-1234567890abcdef0" } # then -generate-config-out ``` **Commit `.terraform.lock.hcl`, pre-populate multi-platform checksums.** `init` records checksums only for the platform it ran on, so a teammate/CI on another OS/arch hits "no matching checksum". The lock pins PROVIDERS only — module pinning lives in the `version` constraint. A CI `init -upgrade` re-resolves from constraints and overwrites lock selections, silently unpinning. ```bash tofu providers lock -platform=linux_amd64 -platform=darwin_amd64 \ -platform=darwin_arm64 -platform=windows_amd64 ``` ### Gotchas **`count` index shift silently recreates the WRONG resources — prefer `for_each`.** Removing a non-tail element of a `count` list reindexes everything after it: removing `[1]` of three makes old-`[2]` become `[1]`, so Terraform destroys & recreates the resource you never touched — a data-loss footgun for RDS/EBS/subnets. Key `for_each` over a map/set by STABLE identity. Caveats: `toset()` on a list silently dedupes duplicates (dropping instances), and a `moved` block migrates existing count-indexed state to for_each keys without recreation. ```hcl # BAD — count: removing var.names[1] reindexes [2]→[1], recreating a survivor resource "aws_instance" "app" { count = length(var.names), ami = var.ami } # GOOD — for_each: address keyed by stable name, deletes only the removed key resource "aws_instance" "app" { for_each = toset(var.names), ami = var.ami, tags = { Name = each.value } } ```
View on GitHub
This SKILL.md is very large, so SkillsMP previews the first section here. View on GitHub