DEV Community

Olivia Craft
Olivia Craft

Posted on

Cursor Rules for Terraform: The Complete Guide to AI-Assisted Infrastructure as Code

Cursor Rules for Terraform: The Complete Guide to AI-Assisted Terraform Development

Terraform is the tool where a single sloppy command can delete the production database. The plan looks innocent. The reviewer skims. apply runs. The AWS console turns red. The incident retrospective documents every warning the team got — the prevent_destroy that was never set, the count = 0 that was supposed to be count = 1, the for_each that shifted the key ordering when a name was renamed, the Terraform state that lived in one developer's laptop, the provider "aws" block with no version constraint so the next laptop got a different API surface, the module that took vpc_id and subnets as variables but also quietly read data "aws_default_vpc" and drifted between regions. The infrastructure doesn't collapse in one dramatic moment; it corrodes through a thousand small decisions that each felt reasonable alone.

Then you add an AI assistant.

Cursor and Claude Code were trained on a decade of Terraform, most of it HCL0.11 or HCL0.12 from tutorials that predate terraform_remote_state being a warning sign, resource "aws_s3_bucket" examples that hard-code every attribute and don't separate aws_s3_bucket from aws_s3_bucket_policy (the 4.x provider split), modules that take six variables and output one, state files committed to git "just temporarily," terraform apply -auto-approve in Makefiles, count = var.enabled ? 1 : 0 patterns that break when the variable flips, and data sources that implicitly depend on a region that's never declared. Ask for "Terraform for a staging and production VPC," and you get one main.tf with a var.environment toggle, the same state backend for both, and a comment that says "TODO: separate envs." The apply succeeds. The audit fails six months later when someone notices staging and production share IAM policies because the module was "reused."

The fix is .cursorrules — one file in the repo that tells the AI what idiomatic, production-grade Terraform looks like. Eight rules below, each with the failure mode, the rule, and a before/after. Copy-paste .cursorrules at the end. Examples use AWS, but the rules apply equally to Azure, GCP, and provider-agnostic code.


How Cursor Rules Work for Terraform Projects

Cursor reads project rules from two locations: .cursorrules (a single file at the repo root, still supported) and .cursor/rules/*.mdc (modular files with frontmatter, recommended for any non-trivial Terraform repo). For Terraform I recommend modular rules so module-authoring conventions don't bleed into root-module composition, and so plan/apply guidance only fires on .tf files near the pipeline:

.cursor/rules/
  tf-module.mdc         # module structure, inputs, outputs, README
  tf-state.mdc          # remote backend, locking, one state per env
  tf-variables.mdc      # typed vars, validation, sensitive, defaults
  tf-plan.mdc           # plan artifacts, no -auto-approve, review
  tf-envs.mdc           # env separation, naming, tagging
  tf-providers.mdc      # pinned versions, aliases, required_version
  tf-lifecycle.mdc      # prevent_destroy, ignore_changes, for_each vs count
  tf-secrets.mdc        # no secrets in state, KMS, SSM/SM references
Enter fullscreen mode Exit fullscreen mode

Frontmatter controls activation: globs: ["**/*.tf", "**/*.tfvars", "**/modules/**"] with alwaysApply: false. Now the rules.


Rule 1: Module Structure — Real Contracts, Real READMEs, Root Modules Compose

Terraform has exactly one reuse primitive: the module. And yet the most common AI-generated Terraform structure is a single main.tf at the repo root with resources, data sources, and provider blocks mixed together, no variables.tf, no outputs.tf, no versions.tf, no README — and the next person who needs "the same but for another team" copies the whole file and starts editing. A month later nobody knows which changes in each copy were intentional. The rule is: modules are libraries with a signed contract (inputs, outputs, side effects) and root modules are thin compositions that wire them together.

The rule:

MODULE LAYOUT  for every module (./modules/foo/ and each root):
  main.tf         resources + locals + data sources
  variables.tf    ALL input variables with type, description, default?
  outputs.tf      ALL outputs with description
  versions.tf     terraform { required_version; required_providers {...} }
  README.md       purpose, inputs, outputs, example usage
Optional: iam.tf, network.tf, data.tf  split when main.tf exceeds ~200
lines or a clear domain emerges.

MODULES HAVE A CONTRACT
- Every variable has a description. Never `variable "foo" {}` with no
  type / description.
- Every output has a description.
- Inputs are explicit. A module must NOT silently read a data source
  that depends on the caller's environment (e.g. data
  "aws_default_vpc"). Take a vpc_id var instead.
- Modules do NOT declare provider { ... } blocks (they inherit from
  the caller). They MAY declare `required_providers` with source +
  version constraints in versions.tf.
- A module is ONE conceptual resource. `rds-postgres`, `vpc-3az`,
  `ecs-service`. Not `everything-for-team-x`.

ROOT MODULES COMPOSE
- Root modules (envs/production, envs/staging) contain module calls,
  provider blocks, backend configuration, and locals. NO direct resources
  in a root module except very thin glue (random_pet, tags locals).
- Root modules are tiny (< 200 lines). If they grow, factor into a
  new module.

NAMING
- snake_case for variable / resource / module names. kebab-case for
  tags / AWS resource names that show up in consoles.
- Resource-type prefixes aren't necessary: `aws_s3_bucket.logs` not
  `aws_s3_bucket.logs_bucket`.
- Module instance names describe role: `module.vpc_prod`, not
  `module.terraform-aws-vpc` (a published source path is different).

NO DEEP NESTING
- Modules call modules at most one level deep. A module that calls
  two modules that each call two more is a red flag  extract flat.

VERSIONING
- Modules in this repo are referenced by relative path. Modules in
  OTHER repos are referenced by `source = "git::...//path?ref=v1.2.3"`
  or a Terraform Registry version. Never `ref=main`  pin.
Enter fullscreen mode Exit fullscreen mode

Before — single main.tf with everything, implicit provider reads, no contract:

# main.tf (root)
provider "aws" { region = "us-east-1" }
data "aws_default_vpc" "default" {}
resource "aws_db_instance" "db" {
  identifier = "prod-${random_id.suffix.hex}"
  engine     = "postgres"
  engine_version = "14"
  instance_class = "db.t3.medium"
  vpc_security_group_ids = [aws_security_group.db.id]
  # 40 more attributes inline
}
Enter fullscreen mode Exit fullscreen mode

No module boundary. aws_default_vpc ties this to whatever "default" means in the caller's account. Any rename breaks every consumer.

After — root module composes, rds module has a contract:

# envs/production/main.tf  (root)
module "vpc" {
  source              = "../../modules/vpc-3az"
  name                = "prod"
  cidr_block          = "10.20.0.0/16"
  private_subnet_bits = 4
}

module "orders_db" {
  source             = "../../modules/rds-postgres"
  name               = "orders"
  environment        = local.environment
  vpc_id             = module.vpc.vpc_id
  subnet_ids         = module.vpc.private_subnet_ids
  instance_class     = "db.t3.medium"
  deletion_protection = true
  tags               = local.tags
}
Enter fullscreen mode Exit fullscreen mode
# modules/rds-postgres/variables.tf
variable "name"           { type = string; description = "Logical DB name (snake_case, used in identifiers)." }
variable "environment"    { type = string; description = "One of production | staging | dev." }
variable "vpc_id"         { type = string; description = "VPC the DB lives in." }
variable "subnet_ids"     { type = list(string); description = "Private subnets for the DB subnet group." }
variable "instance_class" { type = string; default = "db.t3.micro"; description = "RDS instance class." }
variable "deletion_protection" { type = bool; default = true; description = "Set false only in ephemeral envs." }
variable "tags"           { type = map(string); default = {}; description = "Extra tags merged with module defaults." }
Enter fullscreen mode Exit fullscreen mode

Explicit contract. No environment assumptions. Reusable.


Rule 2: State Management — Remote Backend, Locked, One State Per Env

Terraform state is the source of truth for what Terraform believes exists. Lose it or corrupt it and you're back to importing every resource by hand. Cursor's default is terraform { } with no backend — so state lives in terraform.tfstate next to the code, on whoever-ran-it's laptop, uncommitted, irrecoverable. The second-most-common failure is one state for the whole company: s3://company-terraform/state.tfstate with all environments, where a careless terraform destroy in dev also deletes production resources because they're in the same state. The third is terraform.tfstate checked into git — which leaks secrets (every *_password, every SSH private key, every cert) and overwrites silently on merge.

The rule:

REMOTE BACKEND, MANDATORY
- Every root module declares a backend (S3 + DynamoDB lock table,
  Terraform Cloud, GCS with locking, Azure Storage). No local
  backends in production or staging. Local backend is OK ONLY
  for throwaway sandboxes, and those never contain production
  resources.
- The S3 bucket has: versioning ON, encryption ON, block public
  access ON, MFA delete ON (where ops tolerate), lifecycle rule
  to expire non-current versions after N days.
- The DynamoDB lock table has `LockID` as the partition key.

ONE STATE PER ENVIRONMENT
- Envs are separated by state, not by variable. `envs/production/`
  and `envs/staging/` each have their own backend + state file.
  Cross-env blast radius is zero by construction.
- Do NOT use workspaces for environment separation. Workspaces share
  one backend and one set of credentials; a mistake in the CLI
  targets the wrong workspace. Workspaces are fine for ephemeral
  previews off one env.

NO STATE IN GIT
- .gitignore includes: *.tfstate, *.tfstate.backup, .terraform/,
  .terraform.lock.hcl is COMMITTED.
- `.terraform.lock.hcl` is committed  it locks provider versions
  for reproducible plans.

STATE ACCESS
- Only CI has write access to production state (via OIDC/IAM role).
  Engineers have read-only locally  enough to `plan`, not enough to
  `apply`.
- Any `terraform state mv` / `terraform import` / manual state edit
  is reviewed like a migration. It IS a migration.

CROSS-STATE READS
- When another stack's output is needed, use `data
  "terraform_remote_state"` READING ONLY, with explicit outputs.
  Never mutate another stack's state. Prefer SSM Parameter Store /
  consul / explicit outputs over remote_state coupling.
Enter fullscreen mode Exit fullscreen mode

Before — local state, env toggled by variable, one blast radius:

terraform {
  # no backend — state in ./terraform.tfstate on a laptop
}

variable "environment" { default = "staging" }

resource "aws_db_instance" "db" {
  identifier = "${var.environment}-orders"
  # one resource, two envs, one state
}
Enter fullscreen mode Exit fullscreen mode

terraform destroy with the wrong var destroys production. State on a laptop is gone if the laptop is gone.

After — remote backend per env, strict separation:

# envs/production/versions.tf
terraform {
  required_version = "~> 1.7"
  backend "s3" {
    bucket         = "acme-tfstate-production"
    key            = "orders/terraform.tfstate"
    region         = "us-east-1"
    dynamodb_table = "acme-tfstate-lock"
    encrypt        = true
  }
}
Enter fullscreen mode Exit fullscreen mode
# envs/staging/versions.tf
terraform {
  required_version = "~> 1.7"
  backend "s3" {
    bucket         = "acme-tfstate-staging"
    key            = "orders/terraform.tfstate"
    region         = "us-east-1"
    dynamodb_table = "acme-tfstate-lock"
    encrypt        = true
  }
}
Enter fullscreen mode Exit fullscreen mode

Two states, two buckets, two lock tables. A mistake in staging cannot touch production. CI runs with role-assumption per env.


Rule 3: Variable Hygiene — Typed, Validated, Sensitive, Defaulted Only When It's Truly Safe

A variable "port" {} with no type can receive a string, a number, or a list. Cursor generates variables without types because every tutorial does, then the module breaks when a caller passes "5432" instead of 5432. Defaults are worse: variable "enable_deletion_protection" { default = false } — "because dev doesn't need it" — becomes production's default because production inherits the module and forgot to override. Validation blocks catch impossible values at plan time, not at 2am during an apply. And sensitive = true keeps secrets out of logs — but only when it's set.

The rule:

TYPED
- Every variable declares `type`. Primitive types (string, number,
  bool) OR structured (list(string), map(string), object({...}),
  tuple([...])). Never untyped.

DESCRIBED
- Every variable has `description`. Description answers "what does
  this control and what are the valid values". Not "the foo".

VALIDATED
- `validation {}` block on any variable with a constrained domain.
  environment, region, instance class, cidr block, port range,
  retention days.

DEFAULTS
- Defaults are SAFE for the most cautious environment. If in doubt,
  no default  make it required. `deletion_protection` defaults to
  true. `skip_final_snapshot` defaults to false.
- No defaults that depend on `environment == "production"`. If
  production needs a different value, pass it explicitly.

SENSITIVE
- `sensitive = true` on any variable carrying a password, API key,
  token, private key material, or PII. Terraform masks in output;
  it does NOT encrypt state  secrets still land in state. Prefer
  reading secrets from SSM Parameter Store / Secrets Manager /
  Vault at apply time (see Rule 8).

NULLABLE
- Prefer not nullable. `nullable = false` by default; optional args
  with defaults, not `null`.

NO GOD VARIABLES
- No `var.config` object with 40 keys. Group related inputs into
  small typed objects:
    variable "rds" { type = object({ instance_class, storage_gb,
      multi_az }) }

ENV AS AN ENUM
- `variable "environment" { type = string; validation { condition
  = contains(["production","staging","dev"], var.environment) ... } }`.
- Use that variable only for TAGS and NAMING. Conditional resources
  based on environment live in locals, not inline.
Enter fullscreen mode Exit fullscreen mode

Before — untyped variables, unsafe defaults, no validation:

variable "environment" {}
variable "port" { default = 5432 }
variable "password" { default = "changeme" }

resource "aws_db_instance" "db" {
  port     = var.port
  password = var.password
}
Enter fullscreen mode Exit fullscreen mode

Password defaults to a well-known value. If the variable is unset, terraform happily applies. State file contains the (possibly real) password.

After — typed, validated, sensitive, no bad default:

variable "environment" {
  type        = string
  description = "Environment this stack belongs to."
  validation {
    condition     = contains(["production", "staging", "dev"], var.environment)
    error_message = "environment must be production | staging | dev."
  }
}

variable "port" {
  type        = number
  default     = 5432
  description = "Postgres listen port."
  validation {
    condition     = var.port >= 1024 && var.port <= 65535
    error_message = "port must be between 1024 and 65535."
  }
}

variable "master_password_ssm_name" {
  type        = string
  description = "SSM Parameter Store name holding the master password. Parameter is resolved at apply time."
  sensitive   = true
}
Enter fullscreen mode Exit fullscreen mode

No hard-coded default secret. Environment is an enum. Invalid port fails at plan.


Rule 4: Plan Review — Plan Is An Artifact, -auto-approve Is A Smell

terraform plan && terraform apply in a CI job that runs both in the same step is two mistakes. The plan produced by plan and the plan executed by apply CAN DIFFER because the world changed between them (a manual console tweak, another PR merging, an AWS-side change). The only safe pattern is plan -out=tfplan, review the plan, then apply tfplan using the exact captured plan. Cursor defaults to apply -auto-approve in pipeline YAML because it's in every quickstart. The rule: treat the plan as an artifact, make reviewers read it, and make -auto-approve only work on explicit plan files.

The rule:

PLAN IS AN ARTIFACT
- CI runs: `terraform plan -out=tfplan -input=false -lock-timeout=10m`
  on PR. The plan binary + a pretty-printed summary (terraform show
  tfplan) are uploaded as a PR artifact / comment.
- Apply runs: `terraform apply -input=false tfplan` in the
  deploy job, using the saved plan. Never `apply` without `tfplan`.
- If the plan is stale (e.g. older than 24h), CI re-plans and
  requires re-approval.

REVIEW IS MANDATORY
- Every destructive line in the plan (resources marked "-" or
  "-/+") requires explicit reviewer signoff. PR description includes
  a block like: "Destructive changes: aws_db_instance.old (delete,
  confirmed safe because migrated to new)".
- Reviewers look for: replaced resources that imply data loss,
  IAM policy widening, security group 0.0.0.0/0 rules, RDS /
  EBS / S3 deletion without snapshot-before-destroy, unexpected
  provider upgrades.

POLICY-AS-CODE
- OPA / Sentinel / tflint / checkov / tfsec run in CI before apply:
    * no 0.0.0.0/0 ingress on SG except 443
    * no wildcard IAM actions
    * no S3 buckets without encryption + block-public-access
    * no unencrypted EBS / RDS
    * required tags present
  Violations block merge.

`-auto-approve` IS ALLOWED ONLY WITH A PLAN FILE
- `terraform apply -auto-approve tfplan` is acceptable (the plan is
  reviewed).
- `terraform apply -auto-approve` with NO plan file is forbidden
  and should fail in CI (grep the pipeline for it).

NO `terraform apply` FROM DEVELOPER LAPTOPS IN STAGING/PROD
- Engineers plan locally (read-only creds). Apply happens via CI
  with short-lived role-assumption credentials.
Enter fullscreen mode Exit fullscreen mode

Before — plan + apply in the same step, no artifact, no review:

# .github/workflows/deploy.yml (dangerous)
- run: terraform init
- run: terraform plan
- run: terraform apply -auto-approve
Enter fullscreen mode Exit fullscreen mode

PR reviewer sees no plan output; diff between plan and apply is possible.

After — plan artifact, comment on PR, apply uses saved plan:

# .github/workflows/terraform.yml
jobs:
  plan:
    steps:
      - uses: actions/checkout@v4
      - uses: hashicorp/setup-terraform@v3
      - run: terraform init -input=false
      - run: terraform plan -out=tfplan -input=false -lock-timeout=10m
      - run: terraform show -no-color tfplan > plan.txt
      - uses: actions/upload-artifact@v4
        with:
          name: tfplan
          path: |
            tfplan
            plan.txt
      - run: gh pr comment "$PR" --body-file plan.txt
  apply:
    needs: plan
    if: github.ref == 'refs/heads/main'
    environment: production  # GH environment with required reviewers
    steps:
      - uses: actions/download-artifact@v4
        with: { name: tfplan }
      - run: terraform init -input=false
      - run: terraform apply -input=false tfplan
Enter fullscreen mode Exit fullscreen mode

Reviewer reads the plan in the PR. Apply uses the reviewed plan. CI can't apply without one.


Rule 5: Multi-Environment Patterns — Separate Directories, Shared Modules, No Boolean Gymnastics

Every Terraform repo eventually faces "how do we do staging and production?". The wrong answer is count = var.environment == "production" ? 1 : 0 scattered across resources, because a small change to one environment ripples through every other one via the same module code, the state has orphaned resources when a count flips, and for_each with conditional keys shuffles the ordering of everything. The right answer is structural: a modules/ directory for reusable modules (no env assumptions) and an envs/<env>/ directory for each environment's root module. Environments share MODULE code, not STATE or VARIABLES.

The rule:

DIRECTORY LAYOUT
  modules/
    vpc-3az/
    rds-postgres/
    ecs-service/
    ...
  envs/
    production/
      main.tf
      versions.tf (backend for production)
      terraform.tfvars (env-specific inputs)
    staging/
      main.tf
      versions.tf
      terraform.tfvars
    dev/
      ...

Each envs/<env> is its own root module with its own state, backend,
and credentials.

TFVARS
- envs/production/terraform.tfvars holds the inputs for that env.
  No committed tfvars for sandboxes (they may include PII).
- Secrets NEVER in tfvars. Reference SSM/SM names instead (see Rule 8).

ENV DIFFERS BY VALUES, NOT BY STRUCTURE
- Production and staging should call the SAME modules with different
  inputs. If a module needs a conditional resource, model it as a
  module input (e.g. `enable_read_replica = true/false`), not as an
  env-name check inside the module.

TAGS
- Every module accepts a `tags` map. Root module defines:
    locals { tags = { environment = var.environment, owner = "team-x",
      managed_by = "terraform", repo = "acme/infra" } }
  passes to every module. No hand-setting tags per resource.

NAMING
- Every resource name carries the env prefix/suffix: `prod-orders`,
  `staging-orders`. Module receives `name` and `environment` and
  composes.

WORKSPACES  SPARINGLY
- Workspaces are for ephemeral preview envs off a single root
  (PR previews). They share state backend  fine for short-lived.
- NEVER workspaces for production vs staging.

CROSS-ENV REFERENCES
- Read another env's outputs via `data "terraform_remote_state"`
  with narrow, explicit outputs. Prefer SSM Parameter Store for
  loose coupling.
Enter fullscreen mode Exit fullscreen mode

Before — one root with environment toggles, orphaned state on flip:

variable "environment" { default = "staging" }

resource "aws_db_instance" "replica" {
  count = var.environment == "production" ? 1 : 0
  # ...
}
Enter fullscreen mode Exit fullscreen mode

Flipping environment from staging to production in the same state creates a resource that staging thought didn't exist. Data was never in staging; now it is in the state because the same directory ran both.

After — two root modules, shared module, inputs differ:

# envs/production/main.tf
module "orders_db" {
  source              = "../../modules/rds-postgres"
  name                = "orders"
  environment         = "production"
  instance_class      = "db.t3.medium"
  multi_az            = true
  read_replicas       = 2
  deletion_protection = true
  tags                = local.tags
}

# envs/staging/main.tf
module "orders_db" {
  source              = "../../modules/rds-postgres"
  name                = "orders"
  environment         = "staging"
  instance_class      = "db.t3.small"
  multi_az            = false
  read_replicas       = 0
  deletion_protection = false
  tags                = local.tags
}
Enter fullscreen mode Exit fullscreen mode

Same module, two inputs, two states. Blast radius contained.


Rule 6: Provider Versioning — Pin Everything, Lock File Committed

Terraform providers are downloaded on init. Without version constraints, terraform init pulls whatever is latest — which may introduce breaking schema changes between providers (a v4 -> v5 AWS provider bump that renames aws_db_instance.name to aws_db_instance.identifier). Cursor's default provider "aws" {} block with no version constraint is the cause of "works on my laptop, fails in CI" outages. Combined with an unpinned required_version, the whole plan output becomes non-reproducible. The rule is: pin both Terraform and every provider, commit the lock file, and upgrade deliberately.

The rule:

REQUIRED_VERSION
- `terraform { required_version = "~> 1.7" }` on every root module.
  Explicit minor constraint. Bump deliberately.

REQUIRED_PROVIDERS
- `required_providers { aws = { source = "hashicorp/aws", version = "~> 5.40" } }`
  on every module (root + sub). `~> 5.40` allows patch bumps,
  blocks 6.0.
- Lock file `.terraform.lock.hcl` is committed. CI runs with
  `-lockfile=readonly`. Provider upgrades are explicit PRs that
  update the lock file.

PROVIDER CONFIGURATION
- Root modules configure providers with explicit region, profile
  or IAM-assume-role, and default_tags.
- Sub-modules never configure providers. They INHERIT. To target
  a different region / account from a sub-module, use provider
  ALIASES passed in.

ALIASES FOR MULTI-REGION / MULTI-ACCOUNT
- Root: `provider "aws" { alias = "us_west"; region = "us-west-2" }`.
  Module call: `providers = { aws.replica = aws.us_west }`.
  Module declares: `configuration_aliases = [aws.replica]`.

DEFAULT TAGS
- Every provider block sets default_tags merged with environment /
  managed_by / owner. A missing tag on any resource is a drift.

NO "latest"
- No `source = "git::...//path"` without `ref=`. No `version = ">= 5.0"`
  with no upper bound. No `terraform_version = ">=1.0"`. Always
  bounded ranges.

UPGRADES
- Provider upgrade PR: change version constraint -> run `terraform
  init -upgrade` -> commit updated lock file -> run plan, include
  diff in PR, review, merge.
Enter fullscreen mode Exit fullscreen mode

Before — no version pin, no lock file in git, breaking upgrade in CI:

terraform {}
provider "aws" {
  region = "us-east-1"
}
Enter fullscreen mode Exit fullscreen mode

First init after a provider 4->5 bump breaks every resource using renamed attributes. Lock file not committed, so CI and laptop don't agree.

After — pinned, aliased, default-tagged, lock committed:

# versions.tf
terraform {
  required_version = "~> 1.7"
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.40"
    }
    random = {
      source  = "hashicorp/random"
      version = "~> 3.6"
    }
  }
}

# providers.tf
provider "aws" {
  region = "us-east-1"
  default_tags {
    tags = {
      environment = var.environment
      managed_by  = "terraform"
      owner       = "platform"
      repo        = "acme/infra"
    }
  }
}

provider "aws" {
  alias  = "dr"
  region = "us-west-2"
  default_tags { tags = { environment = var.environment, managed_by = "terraform" } }
}
Enter fullscreen mode Exit fullscreen mode

Lock file .terraform.lock.hcl committed. CI uses -lockfile=readonly. Upgrades deliberate.


Rule 7: Lifecycle Guards — prevent_destroy on Data, ignore_changes for Drift, for_each Over count

Two AWS resources you cannot recreate from code: RDS databases (with data) and S3 buckets (with objects). If Terraform decides to replace them, the data is gone. lifecycle { prevent_destroy = true } is a panic button that refuses to run the destroy — the operator must remove the block, commit, and apply again. ignore_changes suppresses drift for attributes that legitimately change outside Terraform (ECS task definition image tags from a CI pipeline, Auto Scaling group desired count from an autoscaler). And for_each over count: with count, removing an item shifts every subsequent index, which Terraform interprets as "delete everything past the removed item and recreate"; with for_each, keys are stable.

The rule:

prevent_destroy
- On any resource holding data that cannot be recreated from code:
  aws_db_instance, aws_rds_cluster, aws_s3_bucket (with content),
  aws_dynamodb_table, aws_kinesis_stream, aws_kms_key, cloudwatch
  log groups with retained logs.
- `lifecycle { prevent_destroy = true }`. To intentionally destroy,
  remove the block in a PR (visible in review) then apply.

ignore_changes
- For attributes Terraform doesn't own. ECS task def image when CD
  rolls images; ASG desired_capacity when autoscaler manages it;
  aws_s3_bucket.tags when another system writes tags.
- NARROW list of attributes, not `all`. `ignore_changes = all` is a
  code smell — the resource probably shouldn't be in Terraform.

create_before_destroy
- For resources where replacement means downtime: load balancers,
  target groups, IAM policies in active use. `lifecycle {
  create_before_destroy = true }` plus a unique name suffix (e.g.
  random_pet) so the old and new coexist briefly.

for_each OVER count
- Iterating over a list/map: `for_each = var.items` with keys
  stable. `count = length(var.items)` reshuffles on removal and
  recreates resources unnecessarily.
- `count = var.enabled ? 1 : 0` is fine for optional single
  resources; `count` as a loop is not.

DEPENDENCIES
- Implicit via references is preferred. `depends_on` ONLY when the
  dependency can't be expressed through references (e.g. waiting
  for an IAM policy to propagate before a consumer assumes it).
- `depends_on` on a module is supported; use sparingly.

IMPORTING EXISTING RESOURCES
- `terraform import` followed by writing the matching resource block.
  Verify the plan is no-op after import. Never `import` without
  writing code.
Enter fullscreen mode Exit fullscreen mode

Before — no lifecycle on a DB, count over a renameable list:

resource "aws_db_instance" "main" {
  # no prevent_destroy; a small config mistake can delete the data
}

variable "services" { type = list(string); default = ["orders", "payments", "shipping"] }

resource "aws_ecs_service" "svc" {
  count = length(var.services)
  name  = var.services[count.index]
}
Enter fullscreen mode Exit fullscreen mode

Delete "orders" from the list, terraform wants to destroy services 0 and 1 (payments, shipping) because indices shifted, and recreate them under new names.

After — prevent_destroy on data, for_each, create_before_destroy where replacement means downtime:

resource "aws_db_instance" "main" {
  # ... attributes
  deletion_protection = true
  lifecycle {
    prevent_destroy = true
    ignore_changes  = [password]  # managed via SSM rotate
  }
}

variable "services" { type = set(string); default = ["orders", "payments", "shipping"] }

resource "aws_ecs_service" "svc" {
  for_each = var.services
  name     = each.key
  lifecycle { create_before_destroy = true }
}
Enter fullscreen mode Exit fullscreen mode

Data protected. Removing a service from the set destroys ONLY that service. Zero-downtime replace via create_before_destroy.


Rule 8: Secrets — Never In State, Never In Plan, Fetched At Apply Time

terraform.tfstate is JSON, and it stores every resource attribute. A aws_db_instance with password = var.master_password writes the password into state. Every engineer with state read access now has the password. sensitive = true masks it in CLI output but DOES NOT encrypt state. Cursor will happily generate password = "changeme" in a .tf file because the resource schema requires password. The fix is to keep the secret outside Terraform entirely — in SSM Parameter Store (SecureString), Secrets Manager, or Vault — and pass the REFERENCE (arn / name) into Terraform, resolving at apply time where the provider knows how to send it to the AWS API without persisting it to state.

The rule:

NO LITERAL SECRETS IN .tf OR .tfvars
- Never `password = "..."`. Never `token = "..."`. Never private keys
  in heredoc. CI grep step fails PRs with high-entropy strings in .tf
  (pre-commit detect-secrets / truffleHog).

FETCH AT APPLY TIME
- SSM Parameter Store / Secrets Manager stores the secret. Terraform
  reads the parameter via `data "aws_ssm_parameter" "x" { name = ... }`
  -> feed into the resource attribute. Data source is re-fetched on
  every plan/apply.
- For AWS RDS: generate the password OUTSIDE Terraform, store in SM,
  reference by ARN in `aws_db_instance.manage_master_user_password =
  true` (let AWS rotate). Or use the `random_password` resource + SM
  secret, and mark `ignore_changes = [password]`.

STATE ENCRYPTION
- Remote backend with encryption at rest (S3 SSE-KMS). Access to the
  backend bucket is tightly scoped (separate from application roles).
- Do not share state file screenshots / dumps.

OUTPUTS
- Outputs that would reveal secrets are marked `sensitive = true`.
  Still: they end up in state. Prefer not exporting secrets through
  Terraform at all  downstream consumers fetch from SSM/SM directly.

IAM / CERTIFICATES / KEYS
- ACM certs: use the AWS-issued ones via data "aws_acm_certificate".
  Private keys NEVER live in state  `aws_acm_certificate` with
  `private_key` is forbidden.
- IAM: inline policies are fine (they're not secret); never paste
  access keys.

TOOLING
- pre-commit: terraform fmt, tflint, tfsec, detect-secrets.
- CI: tfsec / checkov / KICS + a grep for known secret patterns
  in the .tf diff.
Enter fullscreen mode Exit fullscreen mode

Before — password in .tf, password in state, password in plan output:

resource "aws_db_instance" "main" {
  username = "admin"
  password = "SuperSecret123"  # ends up in state, in plan, in git history
}
Enter fullscreen mode Exit fullscreen mode

The state file (in S3) now holds a real password. Anyone with read on the state bucket is a DB admin.

After — AWS manages + rotates, Terraform references:

resource "aws_db_instance" "main" {
  identifier = "${var.environment}-${var.name}"
  engine     = "postgres"
  username   = "rds_admin"

  manage_master_user_password   = true
  master_user_secret_kms_key_id = aws_kms_key.rds.key_id

  # password not in state; AWS Secrets Manager holds + rotates it.

  vpc_security_group_ids = [aws_security_group.db.id]
  db_subnet_group_name   = aws_db_subnet_group.main.name
  deletion_protection    = true
  storage_encrypted      = true
  kms_key_id             = aws_kms_key.rds.arn

  lifecycle { prevent_destroy = true }
  tags = merge(var.tags, { name = "${var.environment}-${var.name}" })
}

output "db_master_secret_arn" {
  value       = aws_db_instance.main.master_user_secret[0].secret_arn
  description = "ARN of the Secrets Manager secret holding the master password. Consumers fetch from SM at runtime."
}
Enter fullscreen mode Exit fullscreen mode

No secret in state. No secret in plan. App reads the secret from SM with a scoped IAM role.


The Complete .cursorrules File

Drop this in the repo root. Cursor and Claude Code both pick it up.

# Terraform — Production Patterns

## Module Structure
- Every module: main.tf, variables.tf, outputs.tf, versions.tf, README.md.
- Every variable has type + description. Every output has description.
- Modules do NOT declare provider blocks. They declare required_providers.
- Modules take explicit inputs — never read environment-dependent data
  sources (e.g. aws_default_vpc).
- Root modules compose (module calls + providers + backend). No direct
  resources in root modules beyond thin glue.
- Module source references are pinned (ref=v1.2.3 or Registry version).

## State Management
- Remote backend on every root (S3+DynamoDB / TF Cloud / GCS / Azure).
  No local backends in staging/prod.
- One state per environment. envs/production + envs/staging, each with
  own backend. NEVER workspaces for env separation.
- *.tfstate .gitignore'd. .terraform.lock.hcl committed.
- Manual state edits reviewed like migrations.

## Variable Hygiene
- Typed + described + validated. No untyped variables.
- Defaults safe for production. No env-dependent defaults.
- `sensitive = true` on secrets (but secrets still hit state — see Rule 8).
- nullable = false unless genuinely optional.
- Group related inputs into small object() types, not god-variables.
- environment variable is a validated enum; used for tags/naming only.

## Plan Review
- CI runs `terraform plan -out=tfplan`, uploads artifact + PR comment.
- Apply uses the saved plan: `terraform apply tfplan`.
- Destructive lines in the plan require explicit reviewer signoff.
- Policy-as-code (tfsec, checkov, OPA) blocks: 0.0.0.0/0, wildcard IAM,
  unencrypted storage, missing required tags.
- `-auto-approve` allowed only with a saved plan file. No plan+apply
  in the same unreviewed CI step. No laptop apply to staging/prod.

## Multi-Environment
- envs/<env>/ directory per environment. Same modules, different inputs.
- No `environment == "production" ? ...` toggles inside modules. If a
  resource is optional, make it a module input.
- Tags map propagates from root locals to every module.
- Workspaces only for ephemeral preview envs.

## Provider Versioning
- required_version pinned (~> 1.7). required_providers pinned (~> 5.40).
- Lock file committed. CI uses -lockfile=readonly.
- Providers configured in ROOT modules only. Sub-modules declare
  configuration_aliases.
- default_tags on every provider with env/managed_by/owner.

## Lifecycle
- `prevent_destroy = true` on all data-bearing resources (RDS, S3,
  DynamoDB, KMS).
- `ignore_changes` narrow and purposeful — never `= all`.
- `create_before_destroy` on resources whose replacement would cause
  downtime, with unique name suffixes.
- `for_each` over `count` when iterating a list/map.
- `depends_on` only when references can't express the dependency.

## Secrets
- No literal secrets in .tf / .tfvars. Pre-commit detect-secrets.
- Secrets live in SSM Parameter Store / Secrets Manager / Vault.
  Terraform reads via data sources OR passes the reference (ARN).
- Prefer provider-managed secrets (e.g. aws_db_instance
  manage_master_user_password = true) so secrets never hit state.
- Outputs that could reveal secrets: avoid. If necessary,
  sensitive = true, but still out of state is better.
Enter fullscreen mode Exit fullscreen mode

End-to-End Example: A Production RDS Instance

Without rules: inline password, local state, unpinned provider, no prevent_destroy.

provider "aws" { region = "us-east-1" }

variable "environment" { default = "production" }

resource "aws_db_instance" "db" {
  identifier = "${var.environment}-orders"
  engine     = "postgres"
  username   = "admin"
  password   = "Password1"
  allocated_storage = 20
  instance_class    = "db.t3.micro"
}
Enter fullscreen mode Exit fullscreen mode

Password in code. No backend. No version pin. No deletion protection. No tags.

With rules: module with contract, typed vars, remote state, pinned provider, AWS-managed secret, lifecycle guard.

# envs/production/main.tf
module "orders_db" {
  source              = "../../modules/rds-postgres"
  name                = "orders"
  environment         = "production"
  vpc_id              = module.vpc.vpc_id
  subnet_ids          = module.vpc.private_subnet_ids
  instance_class      = "db.t3.medium"
  allocated_storage   = 100
  multi_az            = true
  deletion_protection = true
  tags                = local.tags
}

# modules/rds-postgres/main.tf
resource "aws_db_instance" "main" {
  identifier                    = "${var.environment}-${var.name}"
  engine                        = "postgres"
  engine_version                = var.engine_version
  instance_class                = var.instance_class
  allocated_storage             = var.allocated_storage
  storage_encrypted             = true
  kms_key_id                    = aws_kms_key.rds.arn
  username                      = "rds_admin"
  manage_master_user_password   = true
  master_user_secret_kms_key_id = aws_kms_key.rds.key_id
  multi_az                      = var.multi_az
  deletion_protection           = var.deletion_protection
  db_subnet_group_name          = aws_db_subnet_group.main.name
  vpc_security_group_ids        = [aws_security_group.db.id]
  backup_retention_period       = 14
  skip_final_snapshot           = false
  final_snapshot_identifier     = "${var.environment}-${var.name}-final"
  tags                          = merge(var.tags, { name = "${var.environment}-${var.name}" })

  lifecycle {
    prevent_destroy = true
    ignore_changes  = [password]
  }
}
Enter fullscreen mode Exit fullscreen mode

Password never touches state. DB protected from accidental destroy. Provider pinned. CI plan reviewed. Module reusable in staging with different inputs. The next prompt you write at this repo will produce something that already fits.


Get the Full Pack

These eight rules cover the Terraform patterns where AI assistants consistently reach for a tutorial that was published before the current provider existed. Drop them into .cursorrules and the next module you generate will have a contract, a backend, pinned providers, lifecycle guards, and no secrets in state — without having to re-prompt.

If you want the expanded pack — these eight plus rules for drift detection, OPA/Sentinel policy libraries, Terragrunt patterns for DRY backends, CDKTF interop, cross-account role-assumption topologies, cost estimation in CI (infracost), disaster-recovery Terraform, and the testing conventions I use on production infra — it is bundled in Cursor Rules Pack v2 ($27, one payment, lifetime updates). Drop it in your repo, stop fighting your AI, ship Terraform you would actually merge.

Top comments (0)