CtrlK
BlogDocsLog inGet started
Tessl Logo
SkillPublic

terraform-skill

Provides Terraform module, testing, CI/CD, security and AWS stack-layout guidance layered with Provectus conventions (exact version pinning, required tags, ephemeral secrets). Use when working with Terraform - creating modules, writing tests (native test framework, Terratest), setting up CI/CD pipelines, reviewing configurations, choosing between testing approaches, debugging state issues, implementing security scanning (trivy, checkov), or making infrastructure-as-code architecture decisions. OpenTofu is largely compatible; verify feature floors. Does not cover application code, Kubernetes manifests, or CloudFormation/CDK.

85

1.36x
Quality

91%

Does it follow best practices?

Impact

97%

1.36x

2 of 3 eval scenarios. Add 1 more for a full score.

SecuritybySnyk

Passed

No findings from the security scan

SKILL.md
Quality
Evals
Security

Terraform Conventions

Terraform guidance covering testing, modules, CI/CD, and production patterns (OpenTofu is largely compatible; verify feature floors). Based on terraform-best-practices.com and enterprise experience. Layered with Provectus opinionated conventions.

Provectus Conventions

Version Pinning

Pin every Terraform, provider, and module version to an exact version.

ComponentConstraintExample
TerraformExact versionrequired_version = "= X.Y.Z"
ProvidersExact versionversion = "= 6.41.0"
Modules (prod)Exact versionversion = "5.1.2"
Modules (dev)Exact versionversion = "5.1.2"

No pessimistic (~>) or range constraints. Pin everything. This prevents drift between environments and ensures reproducible builds. Local modules under modules/ are no exception: they carry the same exact provider pin as the roots that call them and are bumped in the same commit — never a >= minimum.

Version numbers in this skill are illustrative — never copy them. Terraform core: reuse the repo's existing required_version / .terraform-version; in a new repo, take the latest stable release from https://api.releases.hashicorp.com/v1/releases/terraform/latest and confirm it with the user. State locking: S3 native use_lockfile = true (Terraform >= 1.10), no DynamoDB lock table.

Root Module Configuration

Use locals.tf instead of terraform.tfvars in root modules. Define values directly in locals — no .tfvars files at the root level. This keeps all configuration in version-controlled HCL and avoids hidden variable overrides.

# locals.tf — root module
locals {
  environment = "prod"
  project     = "my-project"
  region      = "us-east-1"
  owner       = "platform-team"
}

Required Resource Tags

Include these four tags on every taggable resource:

TagDescriptionExample
EnvironmentDeployment environmentprod, staging, dev
ProjectProject or service namemy-project
OwnerTeam or person responsibleplatform-team
ManagedByHow the resource is managedterraform
locals {
  required_tags = {
    Environment = local.environment
    Project     = local.project
    Owner       = local.owner
    ManagedBy   = "terraform"
  }
}

resource "aws_s3_bucket" "this" {
  bucket = "my-bucket"

  tags = merge(local.required_tags, {
    Name = "my-bucket"
  })
}

Registry Modules First

For AWS components, use a public registry module (terraform-aws-modules/*), not raw resource blocks. VPC, ALB, ECS, ACM, DynamoDB, S3, RDS, Lambda, security groups and IAM all have one. A raw resource is allowed only when no module covers it, the module would wrap a single resource (e.g. one Route53 alias record), or the module lacks a needed feature — state the reason in the design.

Resolve every new module's version through the terraform MCP (terraform-mcp-server:get_latest_module_version → terraform-mcp-server:get_module_details for that exact version) and pin it exactly. "Latest" is a one-time lookup, never a constraint. Never bump an existing pin unless the user asks. When the MCP server is not available in the session, resolve the version from the Terraform Registry HTTP API instead: WebFetch https://registry.terraform.io/v1/modules/<namespace>/<name>/<provider>/versions for a module (https://registry.terraform.io/v1/providers/<namespace>/<name>/versions for a provider), pin the newest release it lists, and read the module's inputs from its registry docs page at that version. This applies to every module in every root, including bootstrap and helper roots: a version not resolved in this session through the MCP or the Registry API is not written — never from memory, not even with a "verify later" note.

Layers

Directory layout <infra|terraform>/<aws-account-id>/<env>/, env typically dev / prod / shared; the root name, accounts and environments are confirmed with the user at design time.

One root module (one state) per environment, organised by layer files: network.tf (VPC), dns.tf (ACM + validation), data.tf (tables, secrets — deletion protection on), app.tf (ALB, compute, IAM, logs, alias record). Resources used by several environments go in the shared root. Never one module that holds every layer.

The root calls registry modules directly; layers pass values through module outputs. A local module is justified only when it composes several modules/resources whose wiring must match across environments — never a pass-through around one registry module.

Split a layer into its own root (state) only when there is a stated reason: separate owning teams, plans too slow, or a blast radius the user wants isolated. Not by default.

Cross-Root Wiring

Within a root, pass module outputs directly. Between roots (an env reading shared, or a split-out layer), read through data sources by name or tag (aws_vpc, aws_subnets, aws_acm_certificate, aws_ecr_repository, aws_route53_zone), not terraform_remote_state — the one exception is a producer owned by another team that publishes a deliberate, versioned output contract.

Details, lookup table and a worked example: AWS Stack Layout

Least Privilege by Default

Workloads and data go in private subnets. SG ingress names a port and a source SG, never a CIDR; only the public ALB takes 443 (and 80 to redirect) from 0.0.0.0/0. IAM statements list exact actions and resource ARNs: never Action = "*", and Resource = "*" only for actions without resource-level permissions (e.g. ecr:GetAuthorizationToken). Encryption at rest and TLS in transit are always on; secrets reach containers through secrets, never environment. A stricter module default is kept, not relaxed to match legacy. Every deviation is a reason written in the design, and trivy + checkov pass before a plan.

Per-component baseline: AWS Stack Layout → Security Baseline

Cost Review Before Build

Plan → cost review → build, for a new stack and for every resource added later. The design carries a monthly cost table per environment, with prices looked up for the region, never from memory. Egress defaults to one NAT per VPC (prod included) plus free S3/DynamoDB gateway endpoints; NAT per AZ only on request. When the user asks for something and another option is better on security, usage fit or cost, propose both with the trade-offs; the user decides.

Levers and format: AWS Stack Layout → Cost Review

Apply Workflow

Use plan -out and get explicit approval before applying.

terraform plan -out=plan.tfplan    # Step 1: Generate saved plan
terraform show plan.tfplan         # Step 2: Review the plan
# Step 3: Wait for explicit approval
terraform apply plan.tfplan        # Step 4: Apply the saved plan

Applying without a saved plan file applies whatever the config produces now, not what was reviewed.

Core Principles

1. Code Structure Philosophy

Module Hierarchy:

TypeWhen to UseScope
Resource ModuleSingle logical group of connected resourcesVPC + subnets, Security group + rules
Infrastructure ModuleCollection of resource modules for a purposeMultiple resource modules in one region/account
CompositionComplete infrastructureSpans multiple regions/accounts

Hierarchy: Resource → Resource Module → Infrastructure Module → Composition

Directory Structure (the layout mandated in Layers; full tree in AWS Stack Layout → Stack Layers):

infra/                       # or terraform/ — confirmed at design time
├── modules/
│   └── app/                 # local modules only for multi-module compositions
├── 111111111111/            # one directory per AWS account
│   ├── bootstrap/           # state bucket, local state
│   ├── shared/              # zone, ECR, OIDC — used by several envs
│   └── dev/                 # one root (state) per env, one file per layer
│       ├── network.tf  dns.tf  data.tf  app.tf
│       └── backend.tf  versions.tf  providers.tf  locals.tf  outputs.tf
└── 222222222222/
    └── prod/                # same layout

Key principle from terraform-best-practices.com:

  • Separate environment roots (<account-id>/<env>/) from modules (reusable components)
  • In reusable modules, use examples/ as both documentation and integration test fixtures
  • Keep modules small and focused (single responsibility)

For detailed module architecture, see: Code Patterns: Module Types & Hierarchy

2. Naming Conventions

Resources:

# Good: Descriptive, contextual
resource "aws_instance" "web_server" {}
resource "aws_s3_bucket" "application_logs" {}

# Good: "this" for singleton resources (only one of that type)
resource "aws_vpc" "this" {}
resource "aws_security_group" "this" {}

# Avoid: Generic names for non-singletons
resource "aws_instance" "main" {}
resource "aws_s3_bucket" "bucket" {}

# Avoid: "this" when the module creates several of that type (e.g. subnets)
resource "aws_subnet" "this" {}

Variables:

# Prefix with context when needed
var.vpc_cidr_block          # Not just "cidr"
var.database_instance_class # Not just "instance_class"

Files:

  • main.tf - Primary resources
  • variables.tf - Input variables
  • outputs.tf - Output values
  • versions.tf - Provider versions
  • data.tf - Data sources (optional)

Testing Strategy Framework

Decision Matrix: Which Testing Approach?

Your SituationRecommended ApproachToolsCost
Quick syntax checkStatic analysisterraform validate, fmtFree
Pre-commit validationStatic + lintvalidate, tflint, trivy, checkovFree
Terraform 1.6+, simple logicNative test frameworkBuilt-in terraform testFree-Low
Pre-1.6, or Go expertiseIntegration testingTerratestLow-Med
Security/compliance focusPolicy as codeOPA, SentinelFree
Cost-sensitive workflowMock providers (1.7+)Native tests + mockingFree
Multi-cloud, complexFull integrationTerratest + real infraMed-High

Testing Pyramid for Infrastructure

/\
       /  \          End-to-End Tests (Expensive)
      /____\         - Full environment deployment
     /      \        - Production-like setup
    /________\
   /          \      Integration Tests (Moderate)
  /____________\     - Module testing in isolation
 /              \    - Real resources in test account
/________________\   Static Analysis (Cheap)
                     - validate, fmt, lint
                     - Security scanning

Native Test Best Practices (1.6+)

Before generating test code:

  1. Confirm the resource schema — which nested blocks are sets and which are lists. With the terraform-mcp-server MCP server, call terraform-mcp-server:search_providers then terraform-mcp-server:get_provider_details; without it, read the provider's registry docs page for the pinned version.

  2. Choose correct command mode:

    • command = plan - Fast, for input validation
    • command = apply - Required for computed values and set-type blocks
  3. Handle set-type blocks correctly:

    • Cannot index with [0]
    • Use for expressions to iterate
    • Or use command = apply to materialize

Common patterns:

  • S3 encryption rules: set (use for expressions)
  • Lifecycle transitions: set (use for expressions)
  • IAM policy statements: set (use for expressions)

For detailed testing guides, see:

Code Structure Standards

Resource Block Ordering

Order arguments consistently so diffs stay readable:

  1. count or for_each first (blank line after)
  2. Other arguments
  3. tags as last real argument
  4. depends_on after tags (if needed)
  5. lifecycle at 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 = merge(local.required_tags, {
    Name = "${var.name}-nat"
  })

  depends_on = [aws_internet_gateway.this]

  lifecycle {
    create_before_destroy = true
  }
}

Variable Block Ordering

  1. description (always required)
  2. type
  3. default
  4. sensitive (when setting to true)
  5. nullable (when setting to false)
  6. validation
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."
  }
}

For complete structure guidelines, see: Code Patterns: Block Ordering & Structure

Count vs For_Each: When to Use Each

Quick Decision Guide

ScenarioUseWhy
Boolean condition (create or don't)count = condition ? 1 : 0Simple on/off toggle
Simple numeric replicationcount = 3Fixed number of identical resources
Items may be reordered/removedfor_each = toset(list)Stable resource addresses
Reference by keyfor_each = mapNamed access to resources
Multiple named resourcesfor_eachBetter maintainability

Common Patterns

Boolean conditions:

# GOOD - Boolean condition
resource "aws_nat_gateway" "this" {
  count = var.create_nat_gateway ? 1 : 0
  # ...
}

Stable addressing with for_each:

# GOOD - Removing "us-east-1b" only affects that subnet
resource "aws_subnet" "private" {
  for_each = toset(var.availability_zones)

  availability_zone = each.key
  # ...
}

# BAD - Removing middle AZ recreates all subsequent subnets
resource "aws_subnet" "private" {
  count = length(var.availability_zones)

  availability_zone = var.availability_zones[count.index]
  # ...
}

For migration guides and detailed examples, see: Code Patterns: Count vs For_Each

Locals for Dependency Management

Where destroy order matters — VPCs with secondary CIDR blocks are the usual case — route the reference through a local built with try() instead of pointing at the resource directly. That creates the implicit dependency which makes Terraform delete subnets before the CIDR association.

For the worked example, see: Code Patterns: Locals for Dependency Management

Module Development

Standard Module Structure

my-module/
├── README.md           # Usage documentation
├── main.tf             # Primary resources
├── variables.tf        # Input variables with descriptions
├── outputs.tf          # Output values
├── versions.tf         # Provider version constraints
├── examples/
│   ├── minimal/        # Minimal working example
│   └── complete/       # Full-featured example
└── tests/              # Test files
    └── module_test.tftest.hcl  # Or .go

Best Practices Summary

Variables:

  • Always include description
  • Use explicit type constraints
  • Provide sensible default values where appropriate
  • Add validation blocks for complex constraints
  • Use sensitive = true for secrets

Outputs:

  • Always include description
  • Mark sensitive outputs with sensitive = true
  • Consider returning objects for related values
  • Document what consumers should do with each output

For detailed module patterns, see:

CI/CD Integration

Recommended Workflow Stages

A pipeline is optional, and so are its plan and apply stages. Many projects keep plan and apply local (Apply Workflow) because a CI role that can change infrastructure is a bigger risk than an engineer applying a reviewed plan. Follow what the repo already does; add a pipeline, or plan/apply jobs in one, only when the user asks.

  1. Validate - Format check + syntax validation + linting
  2. Test - Run automated tests (native or Terratest)
  3. Plan (optional) - Generate and review execution plan
  4. Apply (optional) - Execute changes (with approvals for production)

Cost Optimization Strategy

  1. Use mocking for PR validation (free)
  2. Run integration tests only on main branch (controlled cost)
  3. Implement auto-cleanup (prevent orphaned resources)
  4. Tag all test resources (track spending)

For complete CI/CD templates, see:

Security & Compliance

Essential Security Checks

# Static security scanning
trivy config .
checkov -d .

The rules are the Least Privilege by Default convention above, plus: never the default VPC — every stack gets its own; secrets come from Secrets Manager / Parameter Store, never from variables.

For detailed security guidance, see:

Modern Terraform Features (1.0+)

Feature Availability by Version

FeatureVersionUse Case
try() function0.13+Safe fallbacks, replaces element(concat())
nullable = false1.1+Prevent null values in variables
moved blocks1.1+Refactor without destroy/recreate
optional() with defaults1.3+Optional object attributes
Native testing1.6+Built-in test framework
Mock providers1.7+Cost-free unit testing
Provider functions1.8+Provider-specific data transformation
Cross-variable validation1.9+Validate relationships between variables
Write-only arguments1.11+Secrets never stored in state

Check the pinned required_version before using a feature from this table — a pin below the listed version rejects it at parse time.

For worked examples of each, see: Code Patterns: Modern Terraform Features

Lock-file and upgrade workflow (terraform init, init -upgrade, multi-platform providers lock): Code Patterns: Version Management · Testing support per version and Terraform vs OpenTofu: Quick Reference: Version-Specific Guidance

References

License

This skill is based on terraform-skill by Anton Babenko, licensed under the Apache License 2.0.

Repository

provectus/awos-recruitment

Path

.claude/skills/terraform-skill/SKILL.md

Last updated
First committed

Also appears in

agentscope-ai/QwenPaw
Stale

last in sync May 28, 2026

Is this your skill?

If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.