Terraform & OpenTofu Skill

repository·master·Indexed 24 days ago

https://github.com/antonbabenko/terraform-skill

A specialized skill for AI coding agents providing best practices for Terraform and OpenTofu infrastructure-as-code development. It covers testing, module structuring, state management, CI/CD, and security across AWS, Azure, and GCP. Compatible with AI agents such as Claude Code, Cursor, Copilot, Gemini CLI, OpenCode, Codex, Kiro, and Antigravity. Requires Terraform 1.0+ or OpenTofu 1.6+.

Tokens
42.2K
Snippets
105
Records
155
Agent score
69%

What's inside terraform-skill

  1. Understand the Terraform & OpenTofu Skill Response Contract

    master

    When using this skill, every response generated for Terraform or OpenTofu tasks is guaranteed to follow a specific structure to ensure safety and clarity. You should expect the following components in every response:

    1. Assumptions & version floor: Explicit declaration of the runtime (terraform or tofu), exact version, providers, state backend, execution path (local/CI/Cloud/Atlantis), and environment criticality.
    2. Risk category addressed: Identification of risks such as identity churn, secret exposure, blast radius, CI drift, compliance gaps, state corruption, provider upgrade risk, or testing blind spots.
    3. Chosen remediation & tradeoffs: An explanation of the chosen solution and what was traded off.
    4. Validation plan: A set of exact commands (e.g., fmt -check, validate, plan -out, policy check) tailored to the specific runtime and risk level.
    5. Rollback notes: Instructions on how to undo destructive or state-mutating changes and what evidence to retain.

    Safety Warning: Never run a direct production apply without a reviewed plan artifact and approval. Never run terraform destroy without first running terraform plan -destroy and reviewing all resources (including implicit dependents) that will be deleted. Never use -auto-approve on a destroy command.

  2. Choose between `command = plan` and `command = apply` in Terraform Tests

    master

    Selecting the correct command mode is critical for test accuracy and speed:

    GoalModeWhy
    Input-derived attribute (e.g. var.bucket)planValue is known before refresh
    Variable default / validationplanFast; no resource creation required
    Computed attribute (e.g. ARN, Cloud ID)applyOnly known after provider round-trip
    Set-type nested blockapplyMaterializes the set so for expressions resolve
    Real behavior / mocked provider responsesapplyRuns the actual create path

    Warning: Asserting a computed value in plan mode will result in: Condition expression could not be evaluated at this time.

  3. Refactor Resource Addressing with `moved` blocks

    master

    When migrating from count to for_each to ensure stable resource addressing, use moved blocks to prevent Terraform from destroying and recreating resources.

    Pattern:

    1. Add for_each to the resource, keeping the original count commented out.
    2. Add moved blocks for each resource to map the old address to the new one.
    3. Run terraform plan to verify that the plan shows moved instead of destroy/create.
    4. Apply the changes.
    5. Remove the commented-out count code.
  4. How to pass aliased providers to child modules

    master

    When a child module requires multiple provider configurations (e.g., for multi-region deployments), you must use configuration_aliases in the child module and explicitly map them in the caller's module block.

    1. In the Child Module: Declare the aliases in versions.tf and bind them to resources using the provider argument.

    2. In the Caller: Pass the specific provider instances into the module using the providers map.

    Warning: If you omit the providers map in the module call, the plan will fail with: No configuration for provider <alias_name>.

    Implementation Example

    Child Module (versions.tf):

    terraform {
      required_providers {
        aws = {
          source                = "hashicorp/aws"
          version               = "~> 5.0"
          configuration_aliases = [aws.primary, aws.replica]
        }
      }
    }

    Caller (main.tf):

    module "bucket" {
      source      = "./modules/replicated-s3"
      bucket_name = "app-data"
    
      providers = {
        aws.primary = aws.us_east_1
        aws.replica = aws.eu_west_1
      }
    }
    terraform {
      required_providers {
        aws = {
          source                = "hashicorp/aws"
          version               = "~> 5.0"
          configuration_aliases = [aws.primary, aws.replica]
        }
      }
    }
    
    # in any resource:
    provider = aws.primary
    
    module "bucket" {
      source      = "./modules/replicated-s3"
      bucket_name = "app-data"
    
      providers = {
        aws.primary = aws.us_east_1
        aws.replica = aws.eu_west_1
      }
    }
  5. Remove providers and resources

    master

    When removing a provider, you have three primary strategies depending on whether you want to destroy the actual cloud resources:

    1. Two-phase removal (Safe/Default):
      • Phase 1: Delete the resource blocks from your code. Run terraform apply to destroy the real resources. Verify via terraform state list that no resources for that provider remain.
      • Phase 2: Remove the provider and required_providers blocks. Run terraform init to clean up.
    2. removed block (Declarative/Unmanaged): Use the removed block (Terraform 1.7+, OpenTofu 1.7+) to remove a resource from state without destroying the real resource. The resource becomes unmanaged.
    3. Manual state removal (Orphaned): Use terraform state rm <address>. This removes the resource from state but leaves the real resource running (orphaned). Use this only when intentionally abandoning a resource.
    # Use the removed block to keep the real resource but stop managing it in Terraform
    removed {
      from = vault_policy.ops
    
      lifecycle {
        destroy = false
      }
    }
  6. Use Count vs For_Each correctly

    master

    Use the following rules to decide between count and for_each to ensure identity stability:

    ScenarioUseWhy
    Boolean condition (create / don't)count = condition ? 1 : 0Optional singleton toggle
    Items may be reordered or removedfor_each = toset(list)Stable resource addresses
    Reference by keyfor_each = mapNamed access
    Multiple named resourcesfor_eachBetter identity stability

    Critical Rule: Never use list index as a long-lived identity. Removing a middle element in a list will reshuffle every resource address after it, causing unintended destruction and recreation.

  7. Use Provisioners as a Last Resort

    master

    Provisioners (local-exec, remote-exec) should be avoided because they are non-idempotent, create-only (updates don't re-run), lack drift detection, and can leak sensitive data to CI logs.

    Use these alternatives instead:

    • Instance bootstrap: Use user_data + cloud-init via templatefile().
    • Orchestration with explicit re-run (Terraform 1.4+): Use terraform_data with triggers_replace.
    • Ongoing OS configuration: Use external tools like Ansible, SSM Run Command, or SSM State Manager.
    • Last-resort one-shot: Use terraform_data + provisioner (1.4+) or null_resource (pre-1.4).
    # ✅ 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"
      }
    }
  8. Terraform vs OpenTofu: Key Differences and Selection

    master

    Terraform and OpenTofu are both used for infrastructure management, but they have diverged significantly since version 1.6.

    Comparison Summary

    FactorTerraformOpenTofu
    LicensingBusiness Source License 1.1 (BUSL-1.1)Mozilla Public License 2.0 (MPL 2.0)
    GovernanceHashiCorp (single vendor)Linux Foundation (community-driven)
    Native Testing1.6+1.6+
    Mock Providers1.7+1.7+
    Migration PathN/ADrop-in replacement for Terraform ≤1.5.x; feature-compatible fork thereafter with divergence on encryption, mock providers, provider functions, and other post-1.6 additions.

    When to choose which

    • Choose Terraform if you require HCP Terraform / Terraform Cloud, HashiCorp enterprise support, or want first access to the latest HashiCorp features.
    • Choose OpenTofu if you prioritize open-source governance, want to avoid BUSL-1.1 licensing, or want to avoid vendor lock-in.
  9. Use `terraform_remote_state` correctly at ownership boundaries

    master

    The terraform_remote_state data source should be used sparingly, only when connecting separately-owned compositions that have different lifecycles or ownership (e.g., different teams).

    When to use it:

    • Consumer and producer are owned by different teams.
    • The producer's state is already split for lifecycle reasons.
    • You cannot pass values via module inputs.

    When NOT to use it:

    • You control both stacks (use module outputs instead).
    • You can use a cloud data source (e.g., aws_vpc by tag).
    • You are chaining many remote state reads (this indicates a need to reshape boundaries).

    Best Practices:

    • Document which outputs are consumed externally.
    • Version your outputs to avoid breaking downstream consumers.
    • Prefer cloud data sources over terraform_remote_state for provider-managed resources.
    # environments/prod/compute/main.tf
    data "terraform_remote_state" "networking" {
      backend = "s3"
      config = {
        bucket = "my-terraform-state"
        key    = "prod/networking/terraform.tfstate"
        region = "us-east-1"
      }
    }
    
    module "ec2" {
      source = "../../modules/ec2"
    
      vpc_id     = data.terraform_remote_state.networking.outputs.vpc_id
      subnet_ids = data.terraform_remote_state.networking.outputs.private_subnet_ids
    }
  10. Use optional() for object attributes in variables

    master

    For Terraform 1.3+, use the optional() function within object types to define attributes that have default values. This allows users to provide only the required fields while the rest fall back to predefined defaults.

    Best Practices:

    • Prefer simple types (string, number, list(), map()) over object() unless strict validation is required.
    • Use any to disable validation at specific depths or to support multiple types.
    # ✅ 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
    }
  11. Organize Terraform state using multi-team isolation patterns

    master

    When managing infrastructure for multiple teams or environments, use one of these three organizational patterns to manage the blast radius and ownership of your state files:

    1. State Per Environment: Separate state files by environment (e.g., dev/, staging/, prod/). This provides clear separation and different IAM roles per environment but can lead to code duplication.
    2. State Per Team/Component: Separate state files by team or functional component (e.g., networking-team/, platform-team/). This clarifies ownership and allows independent release cycles but makes cross-team dependencies more complex.
    3. Hybrid (Environment + Component): The recommended pattern for most teams. It combines environment boundaries with component isolation within each environment (e.g., prod/01-networking/, prod/02-platform/). Using numbered prefixes helps visualize dependencies.

    Decision Matrix for Splitting State:

    FactorSplit StateSingle State
    Team sizeMultiple teamsSingle team
    Resource count>500 resources<100 resources
    Update frequencyDifferent cadencesSame cadence
    Risk toleranceLow (production)High (dev/test)
    CouplingLoosely coupledTightly coupled
    OwnershipMultiple ownersSingle owner
  12. Degradation Gate: When to fallback to ripgrep (rg)

    master

    Before claiming that the LSP is unavailable and falling back to rg, you must pass the Degradation Gate. A workspace that is uninitialized or a server that is still indexing can legitimately return empty results.

    The Gate Criteria:

    1. documentSymbol on a file in scope returns symbols (proves the server is responsive).
    2. The failing call was position-anchored (not a bare symbol name).
    3. The anchored call still returned empty.

    If all three pass, you may use the rg fallback. You must disclose the substitution on the first line of your response using this format:

    Intended: terraform-ls findReferences. Actual: rg. Reason: <gate result>. Impact: text matches only, no semantic scoping - may include comments/strings.