Terraform State, Modules & CI/CD
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.
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.ymlReusable 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.
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.
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
| Rule | Why it matters |
|---|---|
| One state per environment/root | Limits blast radius and plan size |
| Remote backend with locking | Prevents two applies from corrupting state |
| Encrypt state and restrict read access | State can contain secrets and full resource metadata |
| Never edit state manually unless recovering | Bad state edits can orphan or destroy resources |
| Use imports for existing resources | Adopt 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.
# 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.
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.
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/eksPolicy 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.
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