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
91%
Does it follow best practices?
Impact
97%
1.36x2 of 3 eval scenarios. Add 1 more for a full score.
Passed
No findings from the security scan
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.
Version Pinning
Pin every Terraform, provider, and module version to an exact version.
Component Constraint Example Terraform Exact version required_version = "= X.Y.Z"Providers Exact version version = "= 6.41.0"Modules (prod) Exact version version = "5.1.2"Modules (dev) Exact version version = "5.1.2"No pessimistic (
~>) or range constraints. Pin everything. This prevents drift between environments and ensures reproducible builds. Local modules undermodules/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 fromhttps://api.releases.hashicorp.com/v1/releases/terraform/latestand confirm it with the user. State locking: S3 nativeuse_lockfile = true(Terraform >= 1.10), no DynamoDB lock table.
Root Module Configuration
Use
locals.tfinstead ofterraform.tfvarsin root modules. Define values directly in locals — no.tfvarsfiles 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:
Tag Description Example EnvironmentDeployment environment prod,staging,devProjectProject or service name my-projectOwnerTeam or person responsible platform-teamManagedByHow the resource is managed terraformlocals { 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 rawresourceblocks. VPC, ALB, ECS, ACM, DynamoDB, S3, RDS, Lambda, security groups and IAM all have one. A rawresourceis 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_detailsfor 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: WebFetchhttps://registry.terraform.io/v1/modules/<namespace>/<name>/<provider>/versionsfor a module (https://registry.terraform.io/v1/providers/<namespace>/<name>/versionsfor 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, includingbootstrapand 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 typicallydev/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 thesharedroot. 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), notterraform_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: neverAction = "*", andResource = "*"only for actions without resource-level permissions (e.g.ecr:GetAuthorizationToken). Encryption at rest and TLS in transit are always on; secrets reach containers throughsecrets, neverenvironment. A stricter module default is kept, not relaxed to match legacy. Every deviation is a reason written in the design, andtrivy+checkovpass 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 -outand 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 planApplying without a saved plan file applies whatever the config produces now, not what was reviewed.
Module Hierarchy:
| Type | When to Use | Scope |
|---|---|---|
| Resource Module | Single logical group of connected resources | VPC + subnets, Security group + rules |
| Infrastructure Module | Collection of resource modules for a purpose | Multiple resource modules in one region/account |
| Composition | Complete infrastructure | Spans 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 layoutKey principle from terraform-best-practices.com:
<account-id>/<env>/) from modules (reusable components)For detailed module architecture, see: Code Patterns: Module Types & Hierarchy
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 resourcesvariables.tf - Input variablesoutputs.tf - Output valuesversions.tf - Provider versionsdata.tf - Data sources (optional)| Your Situation | Recommended Approach | Tools | Cost |
|---|---|---|---|
| Quick syntax check | Static analysis | terraform validate, fmt | Free |
| Pre-commit validation | Static + lint | validate, tflint, trivy, checkov | Free |
| Terraform 1.6+, simple logic | Native test framework | Built-in 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 + mocking | Free |
| Multi-cloud, complex | Full integration | Terratest + real infra | Med-High |
/\
/ \ 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 scanningBefore generating test code:
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.
Choose correct command mode:
command = plan - Fast, for input validationcommand = apply - Required for computed values and set-type blocksHandle set-type blocks correctly:
[0]for expressions to iteratecommand = apply to materializeCommon patterns:
For detailed testing guides, see:
Order arguments consistently so diffs stay readable:
count or for_each first (blank line after)tags as last real argumentdepends_on after tags (if needed)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
}
}description (always required)typedefaultsensitive (when setting to true)nullable (when setting to false)validationvariable "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
| Scenario | Use | Why |
|---|---|---|
| Boolean condition (create or don't) | count = condition ? 1 : 0 | Simple on/off toggle |
| Simple numeric replication | count = 3 | Fixed number of identical resources |
| Items may be reordered/removed | for_each = toset(list) | Stable resource addresses |
| Reference by key | for_each = map | Named access to resources |
| Multiple named resources | for_each | Better maintainability |
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
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
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 .goVariables:
descriptiontype constraintsdefault values where appropriatevalidation blocks for complex constraintssensitive = true for secretsOutputs:
descriptionsensitive = trueFor detailed module patterns, see:
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.
For complete CI/CD templates, see:
# 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:
| Feature | Version | Use Case |
|---|---|---|
try() function | 0.13+ | Safe fallbacks, replaces element(concat()) |
nullable = false | 1.1+ | Prevent null values in variables |
moved blocks | 1.1+ | Refactor without destroy/recreate |
optional() with defaults | 1.3+ | Optional object attributes |
| Native testing | 1.6+ | Built-in test framework |
| Mock providers | 1.7+ | Cost-free unit testing |
| Provider functions | 1.8+ | Provider-specific data transformation |
| Cross-variable validation | 1.9+ | Validate relationships between variables |
| Write-only arguments | 1.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/aws-stack-layout.md — registry module selection, stack layers, cross-stack lookups, security baseline, cost reviewreferences/code-patterns.md — block ordering, count vs for_each, modern features, version management, refactoringreferences/module-patterns.md — module hierarchy, structure, variables, outputs, anti-patternsreferences/testing-frameworks.md — static analysis, native tests, Terratestreferences/ci-cd-workflows.md — GitHub Actions, GitLab CI, Atlantis, cost controlreferences/security-compliance.md — trivy/checkov, secrets management, state securityreferences/quick-reference.md — command cheat sheets, pre-commit checklist, troubleshootingThis skill is based on terraform-skill by Anton Babenko, licensed under the Apache License 2.0.
provectus/awos-recruitment
ce6a3ea
Also appears in
last in sync May 28, 2026
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.