Terraform Skill
Anton BabenkoCommunityCreated by Anton Babenko
- Skill
- Tool integration
- Cloud and DevOps
- Apache-2.0
- Medium risk
AutoGineer summary
Written by AutoGineer from the skill’s own files, not by the author
This skill provides a structured workflow for managing Terraform and OpenTofu configurations. It emphasizes a diagnose-before-generate approach, requiring explicit context such as runtime versions, provider details, and environment criticality. It includes a response contract to ensure risk-aware planning, validation, and rollback procedures, while offering specific guidance on module hierarchy, state management, security, and testing strategies.
What it does
- Diagnoses infrastructure failure modes like identity churn, state corruption, and secret exposure.
- Enforces a response contract requiring version floors, risk assessment, and validation plans.
- Provides patterns for module development, directory layout, and naming conventions.
- Offers guidance on CI/CD pipeline stages, including plan artifact review and drift prevention.
- Facilitates safe state management, including backend selection and locking protocols.
- Supports semantic code navigation using terraform-ls or fallback search tools.
When to use it
- When writing or reviewing Terraform/OpenTofu modules and configurations.
- When debugging CI/CD pipeline failures or infrastructure drift.
- When planning complex refactors or state migrations.
- When configuring remote state backends or implementing security compliance checks.
Advantages
- Reduces risk of destructive changes through mandatory plan-destroy protocols.
- Improves code maintainability via standardized module and variable contracts.
- Ensures consistent security practices through integrated policy and secret handling.
- Provides clear version-aware guidance to prevent common configuration errors.
Usage examples
- “I am refactoring a module and worried about resource identity churn. How should I proceed?”
- The agent will guide you through using 'moved' blocks, comparing 'count' vs 'for_each' patterns, and validating the plan to ensure no unexpected resource destruction occurs.
- “How do I securely manage secrets in my Terraform configuration?”
- The agent will advise on using cloud-native secret managers, 'sensitive' variable flags, or 'write_only' arguments, and will check your implementation against security best practices.
- “Help me set up a CI/CD pipeline for my infrastructure.”
- The agent will provide a structured pipeline template including validation, testing, plan artifact generation, and environment-protected apply stages.
- “My state file is locked and I cannot run any operations. What should I do?”
- The agent will diagnose the state backend, verify locking mechanisms, and provide recovery steps based on your specific provider and backend configuration.
Install
Download the skill as a ZIP:
https://mp-staging.autogineer.com/agent-skills/antonbabenko/terraform-skill/downloadUnzip it into the cross-client skills folder of your project:
mkdir -p .agents/skills && unzip terraform-skill.zip -d .agents/skills
Documentation by Anton Babenko
Terraform Skill for Claude
Diagnose-first guidance for Terraform and OpenTofu. Core file is a workflow; depth lives in references loaded on demand.
Response Contract
Every Terraform/OpenTofu response must include:
Assumptions & version floor — runtime (
terraformortofu), exact version, providers, state backend, execution path (local/CI/Cloud/Atlantis), environment criticality. State assumptions explicitly if the user did not provide them.Risk category addressed — one or more of: identity churn, secret exposure, blast radius, CI drift, compliance gaps, state corruption, provider upgrade risk, testing blind spots.
Chosen remediation & tradeoffs — what was chosen, what was traded off, why.
Validation plan — exact commands (
fmt -check,validate,plan -out, policy check) tailored to runtime and risk tier.Rollback notes — for any destructive or state-mutating change: how to undo, what evidence to keep.
Never recommend direct production apply without a reviewed plan artifact and approval.
Never run terraform destroy (targeted or full) without first running terraform plan -destroy and showing the user every resource that will be deleted — including implicit dependents pulled in via locals or for_each. Get explicit confirmation before proceeding. Never use -auto-approve on destroy.
Workflow
Capture execution context — runtime+version, provider(s), backend, execution path, environment criticality.
Diagnose failure mode(s) using the routing table below. If intent spans categories, load both references.
Load only the matching reference file(s) — do not preload depth the task does not need.
Propose fix with risk controls — why this addresses the mode, what could still go wrong, guardrails (tests/approvals/rollback).
Generate artifacts — HCL, migration blocks (
moved,import), CI changes, policy rules.Validate before finalizing — run validation commands tailored to risk tier.
Emit the Response Contract at the end.
Diagnose Before You Generate
| Failure category | Symptoms | Primary references |
|---|---|---|
| Identity churn | Resource addresses shift after refactor, count index churn, missing moved blocks | Code Patterns: count vs for_each, Code Patterns: moved blocks, Code Patterns: LLM mistakes |
| Secret exposure | Secrets in defaults, state, logs, CI artifacts | Security & Compliance, Code Patterns: write-only, State Management |
| Blast radius | Oversized stacks, shared prod/non-prod state, unsafe applies | State Management, Module Patterns |
| Destroy cascade | Targeted destroy deletes more than expected; locals referencing a targeted resource make all for_each consumers implicit dependents | Response Contract: plan-destroy first; State Management: Safe Destroy |
| CI drift | Local plan ≠ CI plan, apply without reviewed artifact, unpinned versions | CI/CD Workflows, Code Patterns: versions |
| Compliance gaps | Missing policy stage, no approval model, no evidence retention | Security & Compliance, CI/CD Workflows |
| Testing blind spots | Plan-only validation of computed values, set-type indexing, mock/real confusion | Testing Frameworks |
| State corruption / recovery | Stuck lock, backend migration, drift reconciliation | State Management |
| Provider upgrade risk | Breaking-change provider bump, unpinned modules | Code Patterns: versions, Module Patterns |
| Provider lifecycle | Removing a provider with resources still in state, orphaned resources, removed block usage | State Management: Provider Removal |
| Bootstrap / orchestration misuse | null_resource + local-exec for bootstrap, remote-exec for setup scripts, provisioner stdout leaking secrets in CI logs | Code Patterns: Provisioners as Last Resort |
| Navigation / safe-rename blind spots | Cannot locate symbol defs/refs semantically, value-symbol rename done as blind text replace, grep-only refactor missing refs, hallucinated rg shim | Code Intelligence |
| Cross-cloud / provider mapping | "What's the Azure/GCP equivalent of X", picking a backend/auth model per cloud | State Management: Cross-cloud equivalents |
When to Use This Skill
Activate when: creating or reviewing Terraform/OpenTofu configurations or modules, setting up or debugging tests, structuring multi-environment deployments, implementing IaC CI/CD, choosing module patterns or state organization, configuring or migrating remote state backends.
Don't use for: basic HCL syntax questions Claude already knows, provider API reference (link to docs), cloud-platform questions unrelated to Terraform/OpenTofu.
Core Principles
Module Hierarchy
| Type | When to Use | Scope |
|---|---|---|
| Resource module | Single logical group of connected resources | VPC + subnets, SG + rules |
| Infrastructure module | Collection of resource modules for a purpose | Multiple resource modules in one region/account |
| Composition | Complete infrastructure | Spans multiple regions/accounts |
Flow: resource → resource module → infrastructure module → composition.
Directory Layout
environments/ # prod/ staging/ dev/ — per-env configurations
modules/ # networking/ compute/ data/ — reusable modules
examples/ # minimal/ complete/ — docs + integration fixturesSeparate environments from modules. Use examples/ as both documentation and test fixtures. Keep modules small and single-responsibility.
See Module Patterns for architecture principles, naming conventions, variable/output contracts.
Naming Conventions (summary)
Descriptive resource names (
aws_instance.web_server, notaws_instance.main)Reserve
thisfor genuine singleton resources onlyPrefix variables with context (
vpc_cidr_block, notcidr)Standard files:
main.tf,variables.tf,outputs.tf,versions.tf
See Module Patterns: Variable Naming and Code Patterns: Block Ordering for examples.
Block Ordering (summary)
Resource blocks: count/for_each first → arguments → tags → depends_on → lifecycle.
Variable blocks: description → type → default → validation → nullable → sensitive.
See Code Patterns: Block Ordering & Structure for the full rules and examples.
Testing Strategy
Decision Matrix: Which Testing Approach?
| Situation | Approach | Tools | Cost |
|---|---|---|---|
| Quick syntax check | Static analysis | validate, fmt | Free |
| Pre-commit validation | Static + lint | validate, tflint, trivy, checkov | Free |
| Terraform 1.6+, simple logic | Native test framework | terraform test | Free-Low |
| Pre-1.6, or Go expertise | Integration testing | Terratest | Low-Med |
| Security/compliance focus | Policy as code | OPA, Sentinel | Free |
| Cost-sensitive workflow | Mock providers (1.7+) | Native tests + mocks | Free |
| Multi-cloud, complex | Full integration | Terratest + real infra | Med-High |
Native Test Rules (1.6+)
Before writing test code: validate resource schemas via Terraform MCP so assertions target real attributes.
command = plan— fast, for input-derived values onlycommand = apply— required for computed values (ARNs, generated names) and set-type nested blocksSet-type blocks cannot be indexed with
[0]— useforexpressions or materialize viacommand = applyCommon set types: S3 encryption rules, lifecycle transitions, IAM policy statements
See Testing Frameworks for static-analysis pipelines, native-test patterns, Terratest integration, mock providers, and the full LLM-mistake checklist.
Count vs For_Each — Quick Rule
| Scenario | Use | Why |
|---|---|---|
| Boolean condition (create / don't) | count = condition ? 1 : 0 | Optional singleton toggle |
| Items may be reordered or removed | for_each = toset(list) | Stable resource addresses |
| Reference by key | for_each = map | Named access |
| Multiple named resources | for_each | Better identity stability |
Never use list index as long-lived identity — removing a middle element reshuffles every address after it. For the decision matrix, safe migration playbook, moved block patterns, and known-at-plan failure cases, see Code Patterns: count vs for_each.
Locals for Dependency Management
Using try() in a local to prefer a conditional resource's attribute over its parent is a specialized but high-value pattern — it forces correct deletion order without explicit depends_on. Common use: VPC + secondary CIDR associations + subnets.
See Code Patterns: Locals for Dependency Management for the full pattern and worked example.
Module Development
Standard layout:
my-module/
├── README.md # Usage documentation
├── main.tf # Primary resources
├── variables.tf # Typed inputs with descriptions
├── outputs.tf # Output values
├── versions.tf # required_version + required_providers
├── examples/
│ ├── minimal/
│ └── complete/
└── tests/
└── module_test.tftest.hcl # or Go for TerratestVariable contracts: always description, always explicit type, use validation for complex constraints, use sensitive = true for secrets, prefer optional() with typed defaults (1.3+) over untyped map(any).
Output contracts: always description, mark sensitive outputs, expose stable subsets (not whole provider objects).
See Module Patterns for the full contract patterns, module release checklist, and LLM-mistake checklist.
CI/CD
Pipeline stages: validate → test → plan → apply (with environment protection).
Cost control: mock providers on PR validation, real-cloud integration only on main or scheduled, tag test resources, auto-cleanup.
Drift prevention: pin runtime and providers, commit .terraform.lock.hcl, apply the reviewed plan artifact from the plan stage (do not re-run plan inside the apply job), run policy/security stage on every path to apply.
See CI/CD Workflows for GitHub Actions, GitLab CI, and Atlantis templates plus the LLM-mistake checklist.
Security & Compliance
Essential checks:
trivy config .
checkov -d .Don't: store secrets in variables or .tfvars, use default VPC, skip encryption, open security groups to 0.0.0.0/0, use inline ingress/egress blocks in aws_security_group.
Do: source secrets from a cloud secret manager (AWS Secrets Manager / Azure Key Vault / GCP Secret Manager) or use write_only arguments on 1.11+, create dedicated VPCs, enforce encryption at rest and TLS, least-privilege SGs, use separate aws_vpc_security_group_{ingress,egress}_rule resources (e.g. AWS provider v5+).
Marking a variable sensitive = true masks display only — the value still lives in state. Use write_only / *_wo on 1.11+, or keep secret material out of Terraform entirely via runtime lookups.
See Security & Compliance for trivy/checkov pipelines, state-file hardening, compliance mappings, and the LLM-mistake checklist.
State Management
Never use local state in teams or production. Remote backends provide automatic locking, encryption, versioning, audit logging, and safe collaboration.
Choosing a Remote Backend
AWS example (Azure azurerm / GCP gcs / TF Cloud syntax: see State Management: Choosing a Remote Backend):
terraform {
backend "s3" {
bucket = "my-terraform-state"
key = "prod/vpc/terraform.tfstate"
region = "us-east-1"
encrypt = true
use_lockfile = true # Native S3 locking, 1.10+
}
}On Terraform < 1.10, use dynamodb_table = "terraform-state-lock" instead of use_lockfile. Azure Storage, GCS, and Terraform Cloud all offer built-in locking - see the State Management reference for syntax. For choosing among backends and their locking models, see Choosing a Remote Backend.
State Organization
| Pattern | Use When | Example Path |
|---|---|---|
| Per environment | Different teams per env | prod/terraform.tfstate, staging/... |
| Per component | Independent lifecycles | prod/vpc/, prod/eks/, prod/rds/ |
| Hybrid (recommended) | Both benefits | prod/networking/, prod/compute/, staging/networking/ |
Split state when: different teams, different update cadences, or >500 resources. Combine when: tightly coupled resources, <100 resources, same lifecycle.
See State Management for locking, migration, multi-team isolation, disaster recovery, and the LLM-mistake checklist.
Version Management
| Component | Strategy | Example |
|---|---|---|
| Terraform runtime | Pin minor | required_version = "~> 1.9" |
| Providers | Pin major | version = "~> 5.0" |
| Modules (prod) | Pin exact | version = "5.1.2" |
| Modules (dev) | Allow patch | version = "~> 5.1" |
Commit .terraform.lock.hcl intentionally. Keep provider/runtime upgrades in a separate PR from functional changes. See Code Patterns: Version Management for constraint syntax and upgrade workflow.
Modern Terraform Features (1.0+)
| Feature | Min version | Common use |
|---|---|---|
try() | 0.13+ | Safe fallbacks, replaces element(concat()) |
nullable = false | 1.1+ | Prevent null silently overriding defaults |
moved blocks | 1.1+ | Refactor without destroy/recreate |
optional() with defaults | 1.3+ | Typed object attributes |
import blocks | 1.5+ | Declarative imports, reviewable in VCS |
check blocks | 1.5+ | Runtime assertions |
Native terraform test | 1.6+ | Built-in test framework |
| Mock providers | 1.7+ | Cost-free unit testing |
removed blocks | 1.7+ | Declarative resource removal |
| Provider-defined functions | 1.8+ | Provider-specific transformations (requires provider to declare functions) |
| Cross-variable validation | 1.9+ | Reference other var.* in validation blocks |
write_only arguments | 1.11+ | Secrets never stored in state |
| S3 native lock-file | 1.10+ | State locking without DynamoDB |
Before emitting a feature, verify the runtime floor. See Code Patterns: Feature Guard Table for the full table with common LLM error patterns per feature.
Runtime-Specific Guidance
Terraform 1.0-1.5 (OpenTofu starts at 1.6): Terratest for integration, static analysis + plan validation only (no native tests).
1.6+: native
terraform test/tofu testavailable — migrate simple unit tests, keep Terratest for complex integration.1.7+: mock providers cut test cost — mock for unit tests, real runs for final integration.
1.10+: S3 native lock-file (
use_lockfile) is the correct default for new configurations — DynamoDB locking is no longer required.1.11+:
write_onlyarguments for secret handling keep credentials out of state.Terraform vs OpenTofu: both supported. For licensing, governance, and feature delta, see Quick Reference: Terraform vs OpenTofu.
Code Intelligence (terraform-ls)
Semantic navigation for HCL. terraform-ls is optional; without it every row below degrades to a disclosed rg + Read fallback.
Self-contained terraform-ls layer of a generic code-intelligence discipline - apply the rows below directly. Recommended companion: the code-intelligence plugin (same antonbabenko/agent-plugins marketplace) carries the generic discipline (position anchoring, degradation gate, disclosure format, anti-phantom-shim) and ships /code-intelligence:doctor for readiness. If it is installed, defer to its generic protocol; this skill stays fully self-contained without it.
| Goal | Use | Tradeoff |
|---|---|---|
| Find definition / all references | terraform-ls goToDefinition / findReferences | Needs init + a position anchor |
| Rename value symbol (var/local/output/provider alias) | Manual: findReferences -> per-file fresh Read -> edit -> validate | No rename provider |
| Rename resource/module address | moved block + plan shows 0 destroy | Text rename forces destroy/recreate |
Exact text / known name / .tfvars / non-HCL | rg + Read | No semantic scope |
✅ Supported: goToDefinition, findReferences, documentSymbol, hover, workspaceSymbol.
❌ Unsupported: goToImplementation, call hierarchy, rename provider. Do not call these then report their absence as a finding.
✅ Prereq: local
terraform/tofuon PATH,terraform initrun; cold start may need one retry.✅ LSP calls are position-anchored (
file:line:character) - anchor withrgfirst, never symbol-name-only.❌ Do not claim "LSP broken, using rg" until the Degradation Gate passes; disclose any tool substitution on the first line.
Depth: Code Intelligence.
Reference Files
Progressive disclosure — essentials here, depth on demand:
Testing Frameworks — static analysis, native tests, Terratest, mock providers
Module Patterns — structure, variable/output contracts,
terraform_remote_staterules, release checklistCI/CD Workflows — GitHub Actions, GitLab CI, Atlantis, cost control
Security & Compliance — trivy/checkov, secrets handling, compliance mappings
State Management — backends, locking, migration, multi-team, recovery
Code Patterns — block ordering,
count/for_eachdeep dive, modern features, version management, localsCode Intelligence - terraform-ls capabilities, position-anchored calls, manual rename, degradation gate
Quick Reference — command cheat sheets, flowcharts, troubleshooting
License
Apache License 2.0. See LICENSE for full terms.
Copyright © 2026 Anton Babenko
Files
- ATTRIBUTION.mdAdded by AutoGineer946 B
- LICENSELicence file added by AutoGineer10.6 KiB
- SKILL.md19.9 KiB
- references/ci-cd-workflows.md13.8 KiB
- references/code-intelligence-lsp.md6.6 KiB
- references/code-patterns.md30.9 KiB
- references/module-patterns.md22.9 KiB
- references/quick-reference.md19.4 KiB
- references/security-compliance.md18.6 KiB
- references/state-management.md45.1 KiB
- references/testing-frameworks.md13.7 KiB
references/code-patterns.md
Code Patterns & Structure
Part of: terraform-skill Purpose: Comprehensive patterns for Terraform/OpenTofu code structure and modern features
This document provides detailed code patterns, structure guidelines, and modern Terraform features. For high-level principles, see the main skill file.
Table of Contents
Block Ordering & Structure
Count vs For_Each Deep Dive
Modern Terraform Features (1.0+)
Version Management
Refactoring Patterns
Locals for Dependency Management
Block Ordering & Structure
Resource Block Structure
Strict argument ordering:
countorfor_eachFIRST (blank line after)Other arguments (alphabetical or logical grouping)
tagsas last real argumentdepends_onafter tags (if needed)lifecycleat the very end (if needed)
# ✅ GOOD - Correct ordering
resource "aws_nat_gateway" "this" {
count = var.create_nat_gateway ? 1 : 0
allocation_id = aws_eip.this[0].id
subnet_id = aws_subnet.public[0].id
tags = {
Name = "${var.name}-nat"
Environment = var.environment
}
depends_on = [aws_internet_gateway.this]
lifecycle {
create_before_destroy = true
}
}
# ❌ BAD - Wrong ordering
resource "aws_nat_gateway" "this" {
allocation_id = aws_eip.this[0].id
tags = { Name = "nat" }
count = var.create_nat_gateway ? 1 : 0 # Should be first
subnet_id = aws_subnet.public[0].id
lifecycle {
create_before_destroy = true
}
depends_on = [aws_internet_gateway.this] # Should be after tags
}Pattern applies identically on Azure/GCP; for resource equivalents see Module Patterns: Cross-cloud resource map.
Variable Definition Structure
Variable block ordering:
description(ALWAYS required)typedefaultsensitive(when setting to true)nullable(when setting to false)validation
# ✅ GOOD - Correct ordering and structure
variable "environment" {
description = "Environment name for resource tagging"
type = string
default = "dev"
nullable = false
validation {
condition = contains(["dev", "staging", "prod"], var.environment)
error_message = "Environment must be one of: dev, staging, prod."
}
}Variable Type Preferences
Prefer simple types (
string,number,list(),map()) overobject()unless strict validation neededUse
optional()for optional object attributes (Terraform 1.3+)Use
anyto disable validation at certain depths or support multiple types
Modern variable patterns (Terraform 1.3+):
# ✅ GOOD - Using optional() for object attributes
variable "database_config" {
description = "Database configuration with optional parameters"
type = object({
name = string
engine = string
instance_class = string
backup_retention = optional(number, 7) # Default: 7
monitoring_enabled = optional(bool, true) # Default: true
tags = optional(map(string), {}) # Default: {}
})
}
# Usage - only required fields needed
database_config = {
name = "mydb"
engine = "mysql"
instance_class = "db.t3.micro"
# Optional fields use defaults
}Complex type example:
# For lists/maps of same type
variable "subnet_configs" {
description = "Map of subnet configurations"
type = map(map(string)) # All values are maps of strings
}
# When types vary, use any
variable "mixed_config" {
description = "Configuration with varying types"
type = any
}Output Structure
Pattern: {name}_{type}_{attribute}
# ✅ GOOD
output "security_group_id" { # "this_" should be omitted
description = "The ID of the security group"
value = try(aws_security_group.this[0].id, "")
}
output "private_subnet_ids" { # Plural for list
description = "List of private subnet IDs"
value = aws_subnet.private[*].id
}
# ❌ BAD
output "this_security_group_id" { # Don't prefix with "this_"
value = aws_security_group.this[0].id
}
output "subnet_id" { # Should be plural "subnet_ids"
value = aws_subnet.private[*].id # Returns list
}Count vs For_Each Deep Dive
When to use count
✓ Simple numeric replication:
resource "aws_subnet" "public" {
count = 3
cidr_block = cidrsubnet(var.vpc_cidr, 8, count.index)
}✓ Boolean conditions (create or don't):
# ✅ GOOD - Boolean condition
resource "aws_nat_gateway" "this" {
count = var.create_nat_gateway ? 1 : 0
}
# Less preferred - length check
resource "aws_nat_gateway" "this" {
count = length(var.public_subnets) > 0 ? 1 : 0
}✓ When order doesn't matter and items won't change
When to use for_each
✓ Reference resources by key:
resource "aws_subnet" "private" {
for_each = toset(var.availability_zones)
vpc_id = aws_vpc.this.id
availability_zone = each.key
cidr_block = cidrsubnet(var.vpc_cidr, 4, index(var.availability_zones, each.key))
}
# Reference by key: aws_subnet.private["us-east-1a"]✓ Items may be added/removed from middle:
# ❌ BAD with count - removing middle item recreates all subsequent resources
resource "aws_subnet" "private" {
count = length(var.availability_zones)
availability_zone = var.availability_zones[count.index]
# If var.availability_zones[1] removed, all resources after recreated!
}
# ✅ GOOD with for_each - removal only affects that one resource
resource "aws_subnet" "private" {
for_each = toset(var.availability_zones)
availability_zone = each.key
# Removing one AZ only destroys that subnet
}✓ Creating multiple named resources:
variable "environments" {
default = {
dev = {
instance_type = "t3.micro"
}
prod = {
instance_type = "t3.large"
}
}
}
resource "aws_instance" "app" {
for_each = var.environments
instance_type = each.value.instance_type
tags = {
Environment = each.key # "dev" or "prod"
}
}Count to For_Each Migration
When to migrate: When you need stable resource addressing or items might be added/removed from middle of list.
Migration steps:
Add
for_eachto resourceUse
movedblocks to preserve existing resourcesRemove
countafter verifying withterraform plan
Complete example:
# Before (using count)
variable "availability_zones" {
default = ["us-east-1a", "us-east-1b", "us-east-1c"]
}
resource "aws_subnet" "private" {
count = length(var.availability_zones)
vpc_id = aws_vpc.this.id
cidr_block = cidrsubnet(var.vpc_cidr, 8, count.index)
availability_zone = var.availability_zones[count.index]
tags = {
Name = "private-${var.availability_zones[count.index]}"
}
}
# Reference: aws_subnet.private[0].id
# After (using for_each)
resource "aws_subnet" "private" {
for_each = toset(var.availability_zones)
vpc_id = aws_vpc.this.id
cidr_block = cidrsubnet(var.vpc_cidr, 8, index(var.availability_zones, each.key))
availability_zone = each.key
tags = {
Name = "private-${each.key}"
}
}
# Reference: aws_subnet.private["us-east-1a"].id
# Migration blocks (prevents resource recreation)
moved {
from = aws_subnet.private[0]
to = aws_subnet.private["us-east-1a"]
}
moved {
from = aws_subnet.private[1]
to = aws_subnet.private["us-east-1b"]
}
moved {
from = aws_subnet.private[2]
to = aws_subnet.private["us-east-1c"]
}
# Verify migration:
# terraform plan should show "moved" operations, not destroy/createAfter migration: removing us-east-1b destroys only that subnet; adding an AZ does not churn existing resources; addresses are stable by AZ name.
for_each keys must be known at plan time
for_each (0.12+) requires its key set resolvable during plan.
| Case | Use | Why |
|---|---|---|
| stable key set known at plan | for_each over static map/var | avoids count index churn on insert/remove |
| key set unknowable at plan | count = bool ? 1 : 0 for singleton | keys derived from values unknown until apply |
❌
depends_ondoes NOT fixInvalid for_each argument— it orders applies, not plan-time value resolution❌ deriving
for_eachkeys from another resource's computed attrs (IDs, ARNs)✅ drive
for_eachfrom user-supplied variables or static locals
# ❌ BAD - keys derived from computed IDs; plan fails
resource "aws_eip" "web" {
for_each = toset([for i in aws_instance.web : i.id])
instance = each.key
}
# ✅ GOOD - drive for_each from user-supplied keys
variable "instances" {
type = map(object({ instance_type = string }))
}
resource "aws_instance" "web" {
for_each = var.instances
ami = "ami-0123"
instance_type = each.value.instance_type
}
resource "aws_eip" "web" {
for_each = var.instances
instance = aws_instance.web[each.key].id
}
# ✅ GOOD - singleton when exact ID not known at plan
resource "aws_eip" "bastion" {
count = var.create_bastion ? 1 : 0
instance = aws_instance.bastion[0].id
}Modern Terraform Features (1.0+)
Feature Guard Table — Version Floor & Common LLM Errors
Before emitting a feature, verify the runtime floor. Each feature here is also a known hallucination surface — the error pattern column names the mistake to avoid.
| Feature | Min version | Common LLM error pattern |
|---|---|---|
for_each over count for stable identities | 0.12+ | defaults to count for every collection, causing index churn |
try() function | 0.12.20+ | falls back to element(concat()) legacy pattern |
nonsensitive() function | 0.15+ | used to 'unwrap' sensitive outputs into plan artifacts, effectively laundering secrets into logs |
nullable = false | 1.1+ | omits it, letting null silently override defaults |
moved blocks | 1.1+ | omitted during refactor, causing destroy/create |
optional() with defaults | 1.3+ | emits wrapper variables and loose map(any) contracts |
declarative import blocks | 1.5+ | recommends ad-hoc CLI terraform import only |
check blocks | 1.5+ | ignores runtime assertions entirely |
native terraform test | 1.6+ | treats mocked-provider tests as full integration coverage |
| mock providers | 1.7+ | asserts computed values in command = plan mode |
removed blocks | 1.7+ | deletes resources with no lifecycle transition |
| provider-defined functions | 1.8+ | overuses data sources for simple transformations |
| cross-variable validation | 1.9+ | pushes checks into postconditions only |
| S3 native lock-file | 1.10+ | recommends DynamoDB lock table even on 1.10+ |
ephemeral values | 1.10+ | treats as interchangeable with sensitive; ephemeral values are scrubbed from state, sensitive only masks display |
write_only arguments | 1.11+ | uses sensitive = true and assumes state is safe |
If target runtime is below a feature floor, emit the pre-floor fallback explicitly instead of silently downgrading.
try() Function (Terraform 0.12.20+)
Use try() instead of element(concat()):
# ✅ GOOD - Modern try() function
output "security_group_id" {
description = "The ID of the security group"
value = try(aws_security_group.this[0].id, "")
}
output "first_subnet_id" {
description = "ID of first subnet with multiple fallbacks"
value = try(
aws_subnet.public[0].id,
aws_subnet.private[0].id,
""
)
}
# ❌ BAD - Legacy pattern
output "security_group_id" {
value = element(concat(aws_security_group.this[*].id, [""]), 0)
}nullable = false (Terraform 1.1+)
Set nullable = false for non-null variables:
# ✅ GOOD (Terraform 1.1+)
variable "vpc_cidr" {
description = "CIDR block for VPC"
type = string
nullable = false # Passing null uses default, not null
default = "10.0.0.0/16"
}optional() with Defaults (Terraform 1.3+)
Use optional() for object attributes:
# ✅ GOOD - Using optional() for object attributes
variable "database_config" {
description = "Database configuration with optional parameters"
type = object({
name = string
engine = string
instance_class = string
backup_retention = optional(number, 7) # Default: 7
monitoring_enabled = optional(bool, true) # Default: true
tags = optional(map(string), {}) # Default: {}
})
}
# Usage - only required fields needed
database_config = {
name = "mydb"
engine = "mysql"
instance_class = "db.t3.micro"
# Optional fields use defaults
}Moved Blocks (Terraform 1.1+)
Rename resources without destroy/recreate. Omitting moved during a refactor is one of the most common LLM mistakes — the model renames the address and silently turns the rename into destroy/create. Always emit moved in the same change as the rename, then verify terraform plan shows a move operation, not replacement.
# Rename a resource
moved {
from = aws_instance.web_server
to = aws_instance.web
}
# Rename a module
moved {
from = module.old_module_name
to = module.new_module_name
}
# Move resource into for_each
moved {
from = aws_subnet.private[0]
to = aws_subnet.private["us-east-1a"]
}Limits of moved (1.1+):
| Limit | Can moved cross this? | Alternative |
|---|---|---|
| Provider boundary | No | use removed (1.7+) + import (1.5+) |
| State file / backend key | No | state mv across backends + pre-migration backup |
| Module removal (module deleted from config) | moved block inside removed module silently stops working | add moved in the parent, not the removed module |
ignore_changes (Lifecycle Escape Hatch)
✅ attribute-level
ignore_changes = [tags["X"]]with a comment naming the external system❌
ignore_changes = all— hides real drift, turns every attribute unmanaged❌ use
ignore_changesto silence noisy plans instead of diagnosing root cause
# ❌ BAD - blanket ignore hides all drift
resource "aws_db_instance" "this" {
lifecycle {
ignore_changes = all
}
}
# ✅ GOOD - narrow ignore with justification
resource "aws_db_instance" "this" {
lifecycle {
# External compliance scanner rewrites this tag hourly
ignore_changes = [tags["LastScanned"]]
}
}Provider-Defined Functions (Terraform 1.8+)
Use provider-specific functions for data transformation:
# AWS provider function example
locals {
# provider::aws::arn_build(partition, service, region, account_id, resource)
# S3 ARNs are global: region and account_id are empty strings.
bucket_arn = provider::aws::arn_build("aws", "s3", "", "", "my-bucket")
}
# Check provider documentation for available functions
# Common providers adding functions: AWS, Azure, Google CloudCross-Variable Validation (Terraform 1.9+)
Reference other variables in validation blocks:
variable "instance_type" {
description = "EC2 instance type"
type = string
}
variable "storage_size" {
description = "Storage size in GB"
type = number
validation {
# Can reference var.instance_type in Terraform 1.9+
condition = !(
var.instance_type == "db.t3.micro" &&
var.storage_size > 1000
)
error_message = "Micro instances cannot have storage > 1000 GB"
}
}
variable "environment" {
description = "Environment name"
type = string
}
variable "backup_retention" {
description = "Backup retention period in days"
type = number
validation {
# Production requires longer retention
condition = (
var.environment == "prod" ? var.backup_retention >= 7 : true
)
error_message = "Production environment requires backup_retention >= 7 days"
}
}Validation Mechanism Timing
Four mechanisms look similar and are routinely confused. Only three actually gate apply.
| Mechanism | When it runs | Can reference | Blocks apply? |
|---|---|---|---|
validation (in variable) | var evaluation, before plan | the variable's own value; other vars on 1.9+ | yes |
precondition (in lifecycle) | before resource create/update | other resources, data sources, vars | yes |
postcondition (in lifecycle) | after apply | the resource's own computed attrs | yes |
check block (1.5+) | every plan + apply | anything | NO — advisory only, warnings not errors |
Write-Only Arguments (Terraform 1.11+)
Always use write-only arguments or external secret management. A common LLM mistake is to mark a variable sensitive = true and assume the value is kept out of state — it is not. sensitive only masks display; write-only arguments (or external secret lookups at runtime) are what actually keep material out of state. Verify on 1.11+: prefer *_wo arguments for credentials; on older runtimes, source secrets from a secret manager and never store them in variables or tfvars.
# ✅ GOOD - External secret with write-only argument
data "aws_secretsmanager_secret" "db_password" {
name = "prod-database-password"
}
data "aws_secretsmanager_secret_version" "db_password" {
secret_id = data.aws_secretsmanager_secret.db_password.id
}
resource "aws_db_instance" "this" {
engine = "mysql"
instance_class = "db.t3.micro"
username = "admin"
# password_wo keeps the resource argument out of state (1.11+),
# but the data source still reads secret_string into state on refresh.
# For true state exclusion: use ephemeral (1.10+), manage_master_user_password,
# or inject via CI env var outside Terraform.
password_wo = data.aws_secretsmanager_secret_version.db_password.secret_string
}
# ❌ BAD - Secret ends up in state file
resource "random_password" "db" {
length = 16
}
resource "aws_db_instance" "this" {
password = random_password.db.result # Stored in state!
}
# ❌ BAD - Variable secret stored in state
resource "aws_db_instance" "this" {
password = var.db_password # Ends up in state file
}nonsensitive() and ephemeral (Terraform 0.15+ / 1.10+)
| Goal | Use | Tradeoff |
|---|---|---|
| derived non-secret incorrectly inferred as sensitive | nonsensitive() (0.15+) | only safe when provably not secret; value enters plan |
| short-lived credential that must never persist | ephemeral (1.10+) | never in state or plan; provider/resource must support it |
| value must persist but not display | sensitive = true | still in state; masks terminal only |
# ✅ GOOD - ephemeral keeps short-lived creds out of state (1.10+)
# requires random provider >= 3.7.0
ephemeral "random_password" "session" {
length = 32
}
# ❌ BAD - unwrapping a real secret to silence a warning
output "db_endpoint" {
value = nonsensitive(aws_db_instance.this.password)
}Dynamic Blocks — Iterator Shadowing + Set Ordering
| Gotcha | Cause | Fix |
|---|---|---|
outer each.* inside nested dynamic | block-name iterator shadows each | iterator = rule rename |
| non-deterministic block order | for_each = toset([...]) on a map/object | use map keyed by stable field |
❌ bare
dynamic "ingress"inside outerfor_each—ingress.valueshadowseach.value✅ rename inner iterator with
iterator = rule; reference outer viaeach.*
# ✅ GOOD - explicit iterator rename removes ambiguity
resource "aws_security_group" "this" {
for_each = var.security_groups
name = each.key
dynamic "ingress" {
for_each = each.value.rules
iterator = rule
content {
from_port = rule.value.from_port
to_port = rule.value.to_port
protocol = rule.value.protocol
description = each.value.description # outer iterator clear
}
}
}Provisioners as Last Resort
| Goal | Use |
|---|---|
| Instance bootstrap | user_data + cloud-init via templatefile() |
| Orchestration with explicit re-run (1.4+) | terraform_data + triggers_replace (list; null_resource uses triggers map) |
| Ongoing OS config | External: Ansible / SSM Run Command / SSM State Manager |
| Last-resort one-shot | terraform_data + provisioner (1.4+) or null_resource (pre-1.4) |
Provisioner costs (local-exec + remote-exec):
❌ Non-idempotent — re-runs duplicate side effects
❌ Create-only — updates don't re-run;
when = destroyis fragile❌
remote-execneeds SSH/WinRM from runner to target❌ No drift detection — Terraform can't observe what scripts changed
❌ Script stdout/stderr leaks to CI logs;
sensitivewon't redact it
❌ DON'T — null_resource for bootstrap on 1.4+:
resource "null_resource" "bootstrap" {
provisioner "local-exec" {
command = "ssh ec2-user@${aws_instance.web.public_ip} 'bash setup.sh'"
}
}✅ DO — bootstrap via user_data + cloud-init:
resource "aws_instance" "web" {
ami = data.aws_ami.al2023.id
instance_type = "t3.small"
user_data = templatefile("${path.module}/cloud-init.yaml", {
app_version = var.app_version
})
user_data_replace_on_change = true
}✅ DO — declarative orchestration on 1.4+:
resource "terraform_data" "migration" {
triggers_replace = [aws_rds_cluster.this.id, var.schema_version]
provisioner "local-exec" {
command = "./run-migration.sh"
}
}Version Management
Version Constraint Syntax
# Exact version (avoid unless necessary - inflexible)
version = "5.0.0"
# Pessimistic constraint (recommended for stability)
# The rightmost component is the one that's allowed to increment.
version = "~> 5.0" # 5.x: >= 5.0, < 6.0 — allows 5.1, 5.2, 5.99
version = "~> 5.0.1" # 5.0.x patches only: >= 5.0.1, < 5.1.0
# Range constraints
version = ">= 5.0, < 6.0" # Any 5.x version
version = ">= 5.0.0, < 5.1.0" # Specific minor version range
# Minimum version
version = ">= 5.0" # Any version 5.0 or higher (risky - breaking changes)
# Latest (avoid in production - unpredictable)
# No version specified = always use latest availableVersioning Strategy by Component
Terraform itself:
# versions.tf
terraform {
# Pin to minor version, allow patch updates
required_version = "~> 1.9" # Allows 1.9.x
}Providers:
# versions.tf
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0" # Pin major version, allow minor/patch updates
}
random = {
source = "hashicorp/random"
version = "~> 3.5"
}
}
}Modules:
# Production - pin exact version
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "5.1.2" # Exact version for production stability
}
# Development - allow flexibility
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "~> 5.1" # Allow patch updates in dev
}Update Strategy
Security patches:
Update immediately
Test in dev → stage → prod
Prioritize provider and Terraform core updates
Minor versions:
Regular maintenance windows (monthly/quarterly)
Review changelog for breaking changes
Test thoroughly before production
Major versions:
Planned upgrade cycles
Dedicated testing period
May require code changes
Update in phases: dev → stage → prod
Version Management Workflow
# Step 1: Lock versions in versions.tf
terraform {
required_version = "~> 1.9"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
# Step 2: Generate lock file (commit this)
terraform init
# Creates .terraform.lock.hcl with exact versions used
# Step 3: Update providers when needed
terraform init -upgrade
# Updates to latest within constraints
# Step 4: Review and test changes before committing
terraform planExample versions.tf Template
terraform {
# Terraform version
required_version = "~> 1.9"
# Provider versions
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
random = {
source = "hashicorp/random"
version = "~> 3.5"
}
null = {
source = "hashicorp/null"
version = "~> 3.2"
}
}
# Backend configuration (optional here, often in backend.tf)
backend "s3" {
bucket = "my-terraform-state"
key = "infrastructure/terraform.tfstate"
region = "us-east-1"
}
}Refactoring Patterns
Terraform Version Upgrades
0.12/0.13 → 1.x Migration Checklist
Replace legacy patterns with modern equivalents:
[ ] Replace
element(concat(...))withtry()[ ] Add
nullable = falseto variables that shouldn't accept null[ ] Use
optional()in object types for optional attributes[ ] Add
validationblocks to variables with constraints[ ] Migrate secrets to write-only arguments (Terraform 1.11+)
[ ] Use
movedblocks for resource refactoring (Terraform 1.1+)[ ] Consider cross-variable validation (Terraform 1.9+)
Example migration:
# Before (0.12 style)
output "security_group_id" {
value = element(concat(aws_security_group.this[*].id, [""]), 0)
}
variable "config" {
type = object({
name = string
size = number
})
}
# After (1.x style)
output "security_group_id" {
description = "The ID of the security group"
value = try(aws_security_group.this[0].id, "")
}
variable "config" {
description = "Configuration settings"
type = object({
name = string
size = optional(number, 100) # Optional with default
})
nullable = false # Don't accept null
}Secrets Remediation
Move secret material out of state into external secret management. Canonical depth lives in security-compliance.md — patterns below are the minimum refactor shape.
❌ BAD — both shapes land the secret in state:
# random_password.result lives in state
resource "random_password" "db" {
length = 16
special = true
}
resource "aws_db_instance" "this" {
password = random_password.db.result
}
# var + sensitive = true still writes to state (sensitive only masks display)
variable "db_password" {
type = string
sensitive = true
}
resource "aws_db_instance" "this" {
password = var.db_password
}✅ GOOD — 1.11+ write-only argument, secret created outside Terraform:
data "aws_secretsmanager_secret_version" "db_password" {
secret_id = "prod-database-password"
}
resource "aws_db_instance" "this" {
engine = "mysql"
username = "admin"
# password_wo: resource argument stays out of state (1.11+).
# Data source still reads secret_string into state on refresh.
# For true state exclusion: ephemeral (1.10+), manage_master_user_password, or CI env var.
password_wo = data.aws_secretsmanager_secret_version.db_password.secret_string
}Pre-1.11 fallback: use the same data source without password_wo; rotation must happen outside Terraform.
Migration steps:
Create secret in AWS Secrets Manager outside Terraform
Replace
random_password/ variable withdata "aws_secretsmanager_secret_version"On 1.11+: use
password_woApply, then
terraform show | grep -i password— must be empty
Locals for Dependency Management
Use locals to hint explicit resource deletion order:
# ✅ GOOD - Forces correct deletion order
# Ensures subnets deleted before secondary CIDR blocks
locals {
# References secondary CIDR first, falling back to VPC
# This forces Terraform to delete subnets before CIDR association
vpc_id = try(
aws_vpc_ipv4_cidr_block_association.this[0].vpc_id,
aws_vpc.this.id,
""
)
}
resource "aws_vpc" "this" {
cidr_block = "10.0.0.0/16"
}
resource "aws_vpc_ipv4_cidr_block_association" "this" {
count = var.add_secondary_cidr ? 1 : 0
vpc_id = aws_vpc.this.id
cidr_block = "10.1.0.0/16"
}
resource "aws_subnet" "public" {
# Uses local instead of direct reference
# Creates implicit dependency on CIDR association
vpc_id = local.vpc_id
cidr_block = "10.1.0.0/24"
}
# Without local: Terraform might try to delete CIDR before subnets → ERROR
# With local: Subnets deleted first, then CIDR association, then VPC ✓Common use cases:
VPC with secondary CIDR blocks
Resources depending on optional configurations
Complex deletion-order requirements
LLM Mistake Checklist — Code Patterns
Common model mistakes when generating HCL. Correct these before returning code:
defaults to
countfor every collection — preferfor_eachwith stable keys whenever identity mattersomits
movedblocks during rename/refactor, silently turning the change into destroy/createbuilds
for_eachkeys from computed IDs not known until apply — planning will failuses list index as long-lived identity (
count.index) instead of business-meaningful keysmarks variables
sensitive = trueand assumes the value stays out of state — on 1.11+ usewrite_only/*_woargumentsfalls back to
element(concat(...))instead oftry()on 0.12.20+accepts untyped
map(any)/anyfor long-lived module contracts instead ofoptional()with typed defaults (1.3+)suggests
terraform state mvwheremovedblocks are safer and reviewablerecommends ad-hoc CLI
terraform importinstead of declarativeimportblocks (1.5+)emits an exact
version = "5.0.0"pin where~> 5.0would be more maintainablesilently emits 1.11+ features (S3 native lock,
write_only,removed) without checking the runtime flooruses
nonsensitive()to "fix" a sensitive value appearing in plan output — this leaks secrets into CI artifactsconflates
sensitive = truewithephemeral(1.10+); onlyephemeralactually stays out of statewrites a
movedblock expecting it to cross provider boundaries; it cannotleaves
movedblocks inside a module that itself is being removed — the moves silently no-op, resources get destroyedemits CLI
terraform importin automation when declarativeimportblocks (1.5+) give a reviewable, VCS-tracked alternativeemits
ignore_changes = allor broad ignore lists to silence plan output instead of diagnosing drift root causeuses
checkblock expecting it to block apply;checkis advisory, emits warnings only. Useprecondition/postconditionto gate.uses
each.valueinside adynamicblock intending the outer iterator — shadowed by the inner block name; rename withiterator = ...emits hardcoded cloud IDs/ARNs (
vpc-0abc..., pattern-matchedarn:aws:iam::patterns) from training data instead of using data sources or input variablespairs
password_wowithaws_secretsmanager_secret_version— the data source still readssecret_stringinto state on refresh. Useephemeral(1.10+) or CI-injected env var.iterates
dynamicblocks overtoset(...)of maps/objects — the set's undefined ordering causes non-deterministic block ordering in the plan diff; sort the list or use a map keyed by a stable field
Back to: Main Skill File
Requirements and compatibility
Works with: Claude Code, Claude apps, OpenAI Codex, GitHub Copilot, Cursor, Gemini CLI, Kiro, OpenCode, Goose, Amp, Roo Code, JetBrains Junie, Factory Droid, Devin (ex-Windsurf), Mistral Vibe, OpenHands, Letta
“Works with” means the tool reads the open Agent Skills format; a skill written for one tool may use features others lack.
Security
What our scanner noticed· Medium risk
- Instructions that use the network
- Downloads piped into a shell
A static scan is not a guarantee. Skills are third-party content: review a skill before you let an agent use it.
Attribution and licence
- Created by
- Anton Babenko
- Publisher
- Anton Babenko (Community)
- Source
- antonbabenko/terraform-skill · skills/terraform-skill · commit 9c1e92d
- Licence
- Apache License 2.0 (redistribution allowed)
- LICENSE (added by AutoGineer from LICENSE)
- ATTRIBUTION.md (written by AutoGineer)
- Synced
AutoGineer is not affiliated with or endorsed by Anton Babenko.
Download ZIP
The ZIP holds the skill’s files, its licence files and an ATTRIBUTION.md. Download terraform-skill.zip