TL;DR

Companies usually run Terraform through pull requests: format, validate, scan, plan, review, then apply from a protected branch or approved deployment job. State is sensitive production metadata, so lock it, encrypt it, separate it per environment, and limit who can read it.

Repo Layout

This layout separates live environment roots from reusable modules and policy checks. Use it when multiple environments or clouds must share patterns without sharing the same Terraform state.

textterraform-repo.txt
infra-live/
  envs/
    dev/us-east-1/eks/
    prod/us-east-1/eks/
    prod/us-east-1/network/
  modules/
    vpc/
    eks/
    irsa/
    aks/
    gke/
  policies/
    conftest/
    checkov/
  .github/workflows/terraform-plan.yml
  .github/workflows/terraform-apply.yml

Reusable Module Contract

This variable contract defines the safe inputs for a reusable security group module. Use typed objects and descriptions so callers know exactly what the module accepts.

hclmodules/security-group/variables.tf
variable "name" {
  type        = string
  description = "Security group name prefix."
}

variable "vpc_id" {
  type        = string
  description = "VPC where the security group is created."
}

variable "ingress_rules" {
  description = "Map of named ingress rules."
  type = map(object({
    cidr_ipv4   = string
    from_port   = number
    to_port     = number
    ip_protocol = string
    description = optional(string)
  }))
  default = {}
}

Module With A Rule Loop

This module turns a map of ingress rules into individual AWS security group rule resources. Use for_each like this when reviewers need stable, named resources instead of one large inline rule list.

hclmodules/security-group/main.tf
resource "aws_security_group" "this" {
  name   = var.name
  vpc_id = var.vpc_id
}

resource "aws_vpc_security_group_ingress_rule" "this" {
  for_each = var.ingress_rules

  security_group_id = aws_security_group.this.id
  cidr_ipv4         = each.value.cidr_ipv4
  from_port         = each.value.from_port
  to_port           = each.value.to_port
  ip_protocol       = each.value.ip_protocol
  description       = try(each.value.description, each.key)
}

State Rules

RuleWhy it matters
One state per environment/rootLimits blast radius and plan size
Remote backend with lockingPrevents two applies from corrupting state
Encrypt state and restrict read accessState can contain secrets and full resource metadata
Never edit state manually unless recoveringBad state edits can orphan or destroy resources
Use imports for existing resourcesAdopt client infra without recreating it

Import Existing Client Resource

Use this flow when the client already has a cloud resource and wants Terraform to manage it going forward. Always write the resource block first, import the real ID, then tune code until the plan does not replace it.

bashimport-flow.sh
# 1. Write the resource block first.
# 2. Import the real object into that address.
terraform import aws_security_group.app_lb sg-0123456789abcdef0

# 3. Run plan and adjust arguments until Terraform shows no unwanted replace/delete.
terraform plan

# 4. Commit code only after imported state and code agree.

Refactor With moved Blocks

Use a moved block when renaming a resource or moving it into a module. It tells Terraform the address changed without destroying and recreating the real infrastructure.

hclmoved.tf
moved {
  from = aws_security_group.app_lb
  to   = module.lb_security_group.aws_security_group.this
}

GitHub Actions Plan / Apply

This workflow runs Terraform checks and creates a plan for pull requests. Use this pattern so reviewers can inspect infrastructure changes before a protected apply job runs.

yamlterraform-plan.yml
name: terraform-plan
on:
  pull_request:
    paths:
      - "envs/**"
      - "modules/**"
jobs:
  plan:
    runs-on: ubuntu-latest
    permissions:
      id-token: write
      contents: read
      pull-requests: write
    steps:
      - uses: actions/checkout@v4
      - uses: hashicorp/setup-terraform@v3
      - name: Configure cloud credentials
        run: echo "Use OIDC to assume the client CI plan role"
      - run: terraform fmt -check -recursive
      - run: terraform init
        working-directory: envs/prod/us-east-1/eks
      - run: terraform validate
        working-directory: envs/prod/us-east-1/eks
      - run: terraform plan -no-color -out=tfplan
        working-directory: envs/prod/us-east-1/eks

Policy Checks

Use these checks to catch formatting, provider, security, and policy problems before apply. They are usually part of the pull-request pipeline for regulated or shared infrastructure repos.

bashsecurity-checks.sh
terraform fmt -check -recursive
terraform validate
tflint --recursive
checkov -d .
tfsec .

# Optional plan JSON for policy engines.
terraform show -json tfplan > tfplan.json
conftest test tfplan.json