Skip to content

Split View: Terraform 모듈 설계 패턴 완전 가이드: 상태 관리·워크스페이스·Atlantis 자동화

✨ Learn with Quiz
|

Terraform 모듈 설계 패턴 완전 가이드: 상태 관리·워크스페이스·Atlantis 자동화

Terraform Module Design

들어가며

인프라를 코드로 관리하는 IaC(Infrastructure as Code) 생태계에서 Terraform은 멀티 클라우드 환경을 지원하는 사실상의 표준 도구로 자리잡았다. 그러나 Terraform 프로젝트의 규모가 커질수록 모듈 설계, 상태 관리, 팀 협업 워크플로의 복잡성이 기하급수적으로 증가한다.

단일 main.tf 파일에 수백 개의 리소스를 나열하던 초기 방식은 유지보수 불가능한 "스파게티 인프라"로 빠르게 변질된다. 모듈화된 코드라 하더라도 상태 파일이 하나에 몰려 있으면 terraform plan에 10분 이상 걸리고, 팀원 간 상태 충돌이 빈번하게 발생한다.

이 글에서는 Terraform 모듈 설계의 3대 패턴(Composition, Facade, Factory)을 실제 HCL 코드와 함께 설명하고, 원격 상태 관리(S3+DynamoDB, GCS, Terraform Cloud), 워크스페이스 전략, 그리고 Atlantis를 활용한 GitOps 기반 자동화까지 종합적으로 다룬다. 실전 운영에서 마주하는 상태 잠금 충돌, 드리프트 감지, 순환 의존성 등 장애 사례와 복구 절차도 포함했다.

Terraform 모듈 기본 구조와 설계 원칙

모듈 디렉토리 구조

잘 설계된 Terraform 모듈은 명확한 파일 구조를 따른다. HashiCorp 공식 가이드라인과 Google Cloud Best Practices를 기반으로 한 표준 구조는 다음과 같다.

modules/
  networking/
    main.tf          # 핵심 리소스 정의
    variables.tf     # 입력 변수 선언
    outputs.tf       # 출력 값 정의
    versions.tf      # provider/terraform 버전 제약
    README.md        # 모듈 사용법 문서
    examples/
      simple/
        main.tf      # 간단한 사용 예시
      complete/
        main.tf      # 모든 옵션을 활용한 예시
    tests/
      networking_test.go  # terratest 테스트

핵심 설계 원칙

1. 단일 책임 원칙(Single Responsibility)

하나의 모듈은 하나의 논리적 기능만 담당해야 한다. "모듈의 기능이나 목적을 한 문장으로 설명하기 어렵다면, 모듈이 너무 복잡하다"는 것이 HashiCorp의 기준이다.

2. 느슨한 결합(Loose Coupling)

모듈 간 직접적인 의존성을 최소화한다. terraform plan 실행 시 하나의 모듈 변경이 다른 여러 모듈의 상태를 예기치 않게 변경한다면, 모듈 간 결합도가 지나치게 높다는 신호이다.

3. 프로바이더 설정 금지

공유 모듈에서는 provider 블록이나 backend 블록을 직접 설정하지 않는다. 프로바이더 설정은 항상 루트 모듈에서 수행한다.

# 잘못된 예시 - 모듈 내부에서 provider 설정
# modules/vpc/main.tf
provider "aws" {
  region = "ap-northeast-2"  # 하드코딩된 리전
}

resource "aws_vpc" "main" {
  cidr_block = var.cidr_block
}

# 올바른 예시 - 루트 모듈에서 provider 설정
# environments/prod/main.tf
provider "aws" {
  region = "ap-northeast-2"
}

module "vpc" {
  source     = "../../modules/vpc"
  cidr_block = "10.0.0.0/16"
}

4. 출력 값의 필수화

모듈에서 생성하는 모든 리소스에 대해 최소 하나의 출력 값을 정의한다. 출력 값이 없으면 모듈 간 의존성 추론이 불가능하고, 모듈 조합 시 다른 모듈에서 참조할 수 없다.

모듈 설계 패턴

1. Composition 패턴 (컴포지션)

작은 단위의 모듈을 조합하여 복잡한 인프라를 구성하는 패턴이다. 소프트웨어 공학의 "Composition over Inheritance" 원칙을 인프라 코드에 적용한 것으로, 가장 권장되는 패턴이다.

# environments/prod/main.tf - Composition 패턴
module "vpc" {
  source     = "../../modules/networking/vpc"
  cidr_block = "10.0.0.0/16"
  azs        = ["ap-northeast-2a", "ap-northeast-2b", "ap-northeast-2c"]
}

module "security_group" {
  source = "../../modules/networking/security-group"
  vpc_id = module.vpc.vpc_id

  ingress_rules = [
    {
      port        = 443
      protocol    = "tcp"
      cidr_blocks = ["0.0.0.0/0"]
    }
  ]
}

module "eks" {
  source            = "../../modules/compute/eks"
  vpc_id            = module.vpc.vpc_id
  subnet_ids        = module.vpc.private_subnet_ids
  security_group_id = module.security_group.sg_id
  cluster_version   = "1.31"
}

module "rds" {
  source            = "../../modules/database/rds"
  vpc_id            = module.vpc.vpc_id
  subnet_ids        = module.vpc.database_subnet_ids
  security_group_id = module.security_group.sg_id
  engine            = "postgres"
  engine_version    = "16.4"
}

각 모듈은 독립적으로 테스트, 버전 관리, 재사용이 가능하며, 출력 값을 통해 모듈 간 데이터를 전달한다.

2. Facade 패턴 (파사드)

복잡한 내부 구현을 감추고, 소비자에게 단순한 인터페이스를 제공하는 패턴이다. TV 리모컨처럼 하나의 버튼(변수)으로 내부의 복잡한 동작(여러 리소스 생성)을 제어한다.

# modules/platform/main.tf - Facade 패턴
variable "environment" {
  type = string
}

variable "app_name" {
  type = string
}

variable "instance_type" {
  type    = string
  default = "t3.medium"
}

# 내부에서 여러 하위 모듈을 조합
module "networking" {
  source      = "../networking/vpc"
  cidr_block  = var.environment == "prod" ? "10.0.0.0/16" : "10.1.0.0/16"
  environment = var.environment
}

module "compute" {
  source        = "../compute/eks"
  vpc_id        = module.networking.vpc_id
  subnet_ids    = module.networking.private_subnet_ids
  instance_type = var.instance_type
  cluster_name  = "cluster-name-placeholder"
}

module "monitoring" {
  source     = "../observability/cloudwatch"
  cluster_id = module.compute.cluster_id
  alarm_sns  = module.compute.alarm_topic_arn
}

# 소비자는 간단하게 사용
# environments/prod/main.tf
module "platform" {
  source       = "../../modules/platform"
  environment  = "prod"
  app_name     = "my-service"
  instance_type = "m5.xlarge"
}

3. Factory 패턴 (팩토리)

for_each를 활용하여 동일한 구조의 리소스를 데이터 기반으로 대량 생성하는 패턴이다.

# modules/multi-region/main.tf - Factory 패턴
variable "regions" {
  type = map(object({
    cidr_block    = string
    instance_type = string
    replicas      = number
  }))
}

module "regional_stack" {
  source   = "../regional-stack"
  for_each = var.regions

  region        = each.key
  cidr_block    = each.value.cidr_block
  instance_type = each.value.instance_type
  replicas      = each.value.replicas
}

# 사용 예시
module "global_infra" {
  source = "../../modules/multi-region"

  regions = {
    "ap-northeast-2" = {
      cidr_block    = "10.0.0.0/16"
      instance_type = "m5.xlarge"
      replicas      = 3
    }
    "us-east-1" = {
      cidr_block    = "10.1.0.0/16"
      instance_type = "m5.large"
      replicas      = 2
    }
  }
}

변수 설계와 출력값 전략

변수 설계 가이드라인

효과적인 변수 설계는 모듈의 재사용성과 안정성을 결정한다.

# modules/vpc/variables.tf
variable "cidr_block" {
  type        = string
  description = "VPC CIDR block (e.g., 10.0.0.0/16)"

  validation {
    condition     = can(cidrnetmask(var.cidr_block))
    error_message = "Must be a valid CIDR block."
  }
}

variable "environment" {
  type        = string
  description = "Environment name (dev, staging, prod)"

  validation {
    condition     = contains(["dev", "staging", "prod"], var.environment)
    error_message = "Environment must be dev, staging, or prod."
  }
}

variable "enable_nat_gateway" {
  type        = bool
  default     = true
  description = "Whether to create NAT Gateways for private subnets"
}

variable "tags" {
  type        = map(string)
  default     = {}
  description = "Additional tags to apply to all resources"
}

핵심 원칙: 환경별로 달라져야 하는 값(CIDR, 인스턴스 크기, 이름 등)만 변수로 노출하고, 내부 구현 세부사항(IAM 정책 구조, 로깅 설정, 태그 체계 등)은 모듈 내부에 캡슐화한다.

출력값 설계

# modules/vpc/outputs.tf
output "vpc_id" {
  value       = aws_vpc.main.id
  description = "The ID of the VPC"
}

output "private_subnet_ids" {
  value       = aws_subnet.private[*].id
  description = "List of private subnet IDs"
}

output "database_subnet_ids" {
  value       = aws_subnet.database[*].id
  description = "List of database subnet IDs"
}

output "nat_gateway_ips" {
  value       = aws_eip.nat[*].public_ip
  description = "Elastic IPs of NAT Gateways"
}

원격 상태 관리

S3 + DynamoDB 백엔드 (AWS)

AWS 환경에서 가장 널리 사용되는 원격 상태 관리 구성이다. S3는 상태 파일 저장, DynamoDB는 상태 잠금을 담당한다. 다만 AWS는 DynamoDB 기반 잠금을 점차 S3 네이티브 잠금으로 전환하고 있으므로, 최신 버전에서는 use_lockfile = true 옵션을 확인해야 한다.

# backend.tf - S3 + DynamoDB 원격 상태 설정
terraform {
  backend "s3" {
    bucket         = "my-company-terraform-state"
    key            = "prod/networking/terraform.tfstate"
    region         = "ap-northeast-2"
    encrypt        = true
    dynamodb_table = "terraform-state-lock"
    # use_lockfile = true  # S3 네이티브 잠금 (최신 버전)
  }
}

상태 버킷 부트스트래핑 스크립트:

#!/bin/bash
# bootstrap-backend.sh - 상태 저장 인프라 생성

BUCKET_NAME="my-company-terraform-state"
DYNAMODB_TABLE="terraform-state-lock"
REGION="ap-northeast-2"

# S3 버킷 생성
aws s3api create-bucket \
  --bucket "$BUCKET_NAME" \
  --region "$REGION" \
  --create-bucket-configuration LocationConstraint="$REGION"

# 버전 관리 활성화
aws s3api put-bucket-versioning \
  --bucket "$BUCKET_NAME" \
  --versioning-configuration Status=Enabled

# 퍼블릭 액세스 차단
aws s3api put-public-access-block \
  --bucket "$BUCKET_NAME" \
  --public-access-block-configuration \
    BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true

# KMS 암호화 설정
aws s3api put-bucket-encryption \
  --bucket "$BUCKET_NAME" \
  --server-side-encryption-configuration '{
    "Rules": [{"ApplyServerSideEncryptionByDefault": {"SSEAlgorithm": "aws:kms"}}]
  }'

# DynamoDB 테이블 생성 (상태 잠금용)
aws dynamodb create-table \
  --table-name "$DYNAMODB_TABLE" \
  --attribute-definitions AttributeName=LockID,AttributeType=S \
  --key-schema AttributeName=LockID,KeyType=HASH \
  --billing-mode PAY_PER_REQUEST \
  --region "$REGION"

echo "Backend infrastructure created successfully"

GCS 백엔드 (Google Cloud)

terraform {
  backend "gcs" {
    bucket = "my-company-tf-state"
    prefix = "prod/networking"
  }
}

Terraform Cloud / HCP Terraform

terraform {
  cloud {
    organization = "my-company"

    workspaces {
      name = "prod-networking"
    }
  }
}

원격 상태 데이터 소스 (Cross-Stack 참조)

한 스택의 출력 값을 다른 스택에서 참조하려면 terraform_remote_state 데이터 소스를 사용한다.

# compute 스택에서 networking 스택의 상태 참조
data "terraform_remote_state" "networking" {
  backend = "s3"

  config = {
    bucket = "my-company-terraform-state"
    key    = "prod/networking/terraform.tfstate"
    region = "ap-northeast-2"
  }
}

resource "aws_instance" "app" {
  ami           = "ami-0c55b159cbfafe1f0"
  instance_type = "t3.medium"
  subnet_id     = data.terraform_remote_state.networking.outputs.private_subnet_ids[0]
}

워크스페이스 전략 vs 디렉토리 분리

워크스페이스 방식

Terraform 워크스페이스는 동일한 .tf 파일을 공유하면서 환경별로 독립된 상태 파일을 관리한다.

# 워크스페이스 생성 및 전환
terraform workspace new dev
terraform workspace new staging
terraform workspace new prod
terraform workspace select prod

# 현재 워크스페이스 확인
terraform workspace show

HCL에서 워크스페이스 참조:

resource "aws_instance" "app" {
  instance_type = terraform.workspace == "prod" ? "m5.xlarge" : "t3.medium"

  tags = {
    Environment = terraform.workspace
  }
}

디렉토리 분리 방식

infrastructure/
  modules/
    vpc/
    eks/
    rds/
  environments/
    dev/
      main.tf
      terraform.tfvars
      backend.tf
    staging/
      main.tf
      terraform.tfvars
      backend.tf
    prod/
      main.tf
      terraform.tfvars
      backend.tf

워크스페이스 vs 디렉토리 비교

기준워크스페이스디렉토리 분리
코드 중복없음 (코드 공유)일부 중복 발생
환경별 격리약함 (같은 백엔드)강함 (별도 백엔드 가능)
IAM 권한 분리어려움환경별 별도 설정 가능
폭발 반경넓음 (코드 공유)좁음 (독립적)
운영 복잡도낮음중간
적합한 사용처임시 환경, 테스트프로덕션 환경

권장 사항: 프로덕션 환경에는 디렉토리 분리를, 단기 테스트 환경에는 워크스페이스를 사용한다. 많은 성공적인 팀들이 두 방식을 조합하여 사용한다.

Atlantis를 활용한 GitOps 자동화

Atlantis란?

Atlantis는 Pull Request 기반으로 Terraform planapply를 자동화하는 GitOps 도구이다. 개발자가 인프라 변경 PR을 올리면, Atlantis가 자동으로 terraform plan을 실행하고 그 결과를 PR 코멘트에 표시한다. 리뷰어가 승인하면 atlantis apply 코멘트로 적용할 수 있다.

핵심 장점

  • 일관된 실행 환경: 모든 Terraform 실행이 전용 서버에서 이뤄져 "내 PC에서는 되는데" 문제를 방지
  • 자동 상태 잠금: PR이 열려 있는 동안 해당 프로젝트의 상태 파일을 잠가 동시 수정 방지
  • 코드 리뷰와 통합: plan 결과를 PR에서 바로 확인하여 인프라 변경의 가시성 확보
  • 감사 로그: 모든 변경이 PR 히스토리에 기록

atlantis.yaml 설정

# atlantis.yaml - 저장소 루트에 위치
version: 3
automerge: false
parallel_plan: true
parallel_apply: false

projects:
  - name: prod-networking
    dir: environments/prod/networking
    workspace: default
    terraform_version: v1.9.0
    autoplan:
      when_modified:
        - '*.tf'
        - '*.tfvars'
        - '../../../modules/networking/**/*.tf'
      enabled: true
    apply_requirements:
      - approved
      - mergeable

  - name: prod-compute
    dir: environments/prod/compute
    workspace: default
    terraform_version: v1.9.0
    autoplan:
      when_modified:
        - '*.tf'
        - '*.tfvars'
        - '../../../modules/compute/**/*.tf'
      enabled: true
    apply_requirements:
      - approved
      - mergeable

  - name: dev-networking
    dir: environments/dev/networking
    workspace: default
    terraform_version: v1.9.0
    autoplan:
      when_modified:
        - '*.tf'
        - '*.tfvars'
      enabled: true

Atlantis 워크플로 커스터마이징

# atlantis.yaml - 커스텀 워크플로
workflows:
  custom:
    plan:
      steps:
        - run: terraform fmt -check -recursive
        - run: tflint --init
        - run: tflint
        - init
        - plan
    apply:
      steps:
        - apply

모듈 버전 관리와 레지스트리

시맨틱 버전 관리

Terraform 모듈은 시맨틱 버전(SemVer)을 따르는 것이 권장된다.

  • Major 버전 증가: 필수 입력 변수 추가, 출력 값 제거 등 호환성이 깨지는 변경
  • Minor 버전 증가: 선택적 입력 변수 추가, 새로운 출력 값 추가
  • Patch 버전 증가: 버그 수정, 문서 업데이트
# 버전 제약 지정
module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "~> 5.0"  # 5.x 범위 내에서 최신 버전
}

module "eks" {
  source  = "git::https://github.com/my-org/terraform-aws-eks.git?ref=v3.2.1"
}

Private Module Registry

Terraform Cloud나 자체 Registry를 사용하여 조직 내부 모듈을 관리할 수 있다.

# Terraform Cloud Private Registry 사용
module "vpc" {
  source  = "app.terraform.io/my-org/vpc/aws"
  version = "2.1.0"
}

비교표

상태 백엔드 비교

기능S3 + DynamoDBGCSTerraform CloudAzure Blob
상태 잠금DynamoDB / S3 네이티브기본 지원기본 지원Blob Lease
암호화KMSGoogle KMS기본 제공Azure KeyVault
버전 관리S3 VersioningObject Versioning기본 제공Blob Snapshots
접근 제어IAM PolicyIAMTeams/RBACAzure RBAC
비용S3 + DynamoDB 과금GCS 과금무료 티어 제한Blob 과금
설정 난이도중간낮음낮음중간

IaC 도구 비교

특성Terraform/OpenTofuPulumiCrossplaneCloudFormation
언어HCLTypeScript/Python/GoYAML/CRDJSON/YAML
상태 관리외부 백엔드 필요자체/외부Kubernetes etcdAWS 관리형
멀티 클라우드우수우수우수AWS 전용
학습 곡선중간낮음 (기존 언어)높음낮음 (AWS 사용자)
커뮤니티매우 큼성장 중성장 중AWS 생태계
드리프트 감지plan으로 수동preview로 수동자동 (reconciliation)Drift Detection

장애 사례와 복구 절차

사례 1: 상태 잠금 충돌

증상: terraform plan 또는 apply 실행 시 "Error acquiring the state lock" 오류 발생

원인: 이전 Terraform 작업이 비정상 종료(네트워크 끊김, CI 러너 타임아웃, Ctrl+C 강제 중단)되어 잠금이 해제되지 않은 상태

복구 절차:

# 1. 잠금 상태 확인 - 다른 사용자가 실행 중인지 먼저 확인
# 오류 메시지에서 Lock ID를 확인

# 2. 실제로 다른 작업이 실행 중이 아님을 확인한 후 강제 해제
terraform force-unlock LOCK_ID

# 3. -force 옵션으로 확인 없이 즉시 해제 (주의: 다른 작업이 없음을 반드시 확인)
terraform force-unlock -force LOCK_ID

예방 조치:

  • CI/CD 파이프라인에 적절한 타임아웃 설정
  • 동시 실행 제어(concurrency control) 적용
  • Atlantis 사용 시 PR 기반 자동 잠금으로 충돌 방지

사례 2: 상태 드리프트 (Drift)

증상: terraform plan에서 예상치 못한 변경 사항이 표시됨. 콘솔에서 수동으로 변경한 리소스가 Terraform 상태와 불일치

복구 절차:

# 1. 현재 실제 인프라 상태로 상태 파일 갱신
terraform refresh

# 2. 또는 plan으로 드리프트 확인 후 선택적으로 import
terraform plan

# 3. 수동 변경 사항을 코드에 반영하거나 되돌리기
terraform apply  # 코드 기준으로 인프라 복원

사례 3: 순환 의존성

증상: terraform plan 시 "Cycle" 오류 발생

원인: 모듈 A가 모듈 B의 출력을 참조하고, 모듈 B가 다시 모듈 A의 출력을 참조하는 경우

해결 방법:

  • 공통 의존성을 별도 모듈로 분리
  • depends_on을 사용하여 명시적 의존성 지정
  • 데이터 소스를 사용하여 간접 참조로 전환

사례 4: 대규모 상태 파일 성능 저하

증상: terraform plan이 10분 이상 소요, API rate limiting 발생

해결 방법:

# 특정 모듈만 대상으로 plan/apply 실행
terraform plan -target=module.eks
terraform apply -target=module.eks

# 상태 파일 분리 (state 이동)
terraform state mv module.monitoring module.monitoring

근본적 해결: 상태 파일을 컴포넌트별로 분리하여 각 상태 파일의 크기를 줄인다. 네트워킹, 컴퓨트, 데이터베이스, 모니터링을 별도 상태 파일로 관리하고, terraform_remote_state 데이터 소스로 상호 참조한다.

운영 체크리스트

모듈 설계 체크리스트

  • 모듈이 단일 책임 원칙을 따르는가
  • 모든 리소스에 대한 출력 값이 정의되어 있는가
  • 변수에 type, description, validation이 포함되어 있는가
  • provider와 backend가 루트 모듈에만 설정되어 있는가
  • README.md와 examples 디렉토리가 포함되어 있는가
  • 시맨틱 버전으로 태그가 관리되고 있는가

상태 관리 체크리스트

  • 원격 백엔드가 설정되어 있는가 (로컬 상태 파일 사용 금지)
  • 상태 잠금이 활성화되어 있는가
  • 상태 파일이 암호화되어 있는가
  • S3 버킷에 버전 관리가 활성화되어 있는가
  • 퍼블릭 액세스가 차단되어 있는가
  • 컴포넌트별로 상태 파일이 분리되어 있는가

Atlantis / CI-CD 체크리스트

  • atlantis.yaml이 저장소 루트에 설정되어 있는가
  • apply 전 approved + mergeable 요구사항이 설정되어 있는가
  • 모듈 변경 시 의존하는 프로젝트가 자동으로 plan되는가
  • Webhook 시크릿이 안전하게 관리되고 있는가
  • 자격 증명(credentials) 로테이션이 주기적으로 수행되는가

마무리

Terraform 모듈 설계와 상태 관리는 인프라 코드의 규모가 커질수록 그 중요성이 기하급수적으로 증가한다. Composition 패턴으로 작은 모듈을 조합하고, 원격 상태를 컴포넌트별로 분리하며, Atlantis로 GitOps 워크플로를 자동화하는 것이 2026년 현재 가장 성숙한 운영 모델이다.

핵심은 **"작게 시작하고, 필요할 때 분리하라"**는 원칙이다. 처음부터 완벽한 모듈 구조를 설계하려 하기보다, 단일 모듈에서 시작하여 중복이 발생할 때 리팩토링하고, 상태 파일이 커지면 분리하며, 팀원이 늘어나면 Atlantis를 도입하는 점진적 접근이 가장 현실적이다.

인프라 코드도 애플리케이션 코드와 동일한 엔지니어링 규율(코드 리뷰, 테스트, 버전 관리, CI/CD)을 적용해야 하며, 이 글에서 소개한 패턴과 도구들이 그 여정에 실질적인 도움이 되길 바란다.

Complete Guide to Terraform Module Design Patterns: State Management, Workspaces, and Atlantis Automation

Terraform Module Design

Introduction

In the Infrastructure as Code (IaC) ecosystem, Terraform has established itself as the de facto standard tool for managing multi-cloud environments. However, as Terraform projects grow in scale, the complexity of module design, state management, and team collaboration workflows increases exponentially.

The early approach of listing hundreds of resources in a single main.tf file quickly degenerates into unmaintainable "spaghetti infrastructure." Even modularized code suffers when state files are consolidated into a single backend -- terraform plan can take over 10 minutes, and state conflicts between team members become frequent.

This guide covers three core Terraform module design patterns (Composition, Facade, Factory) with real HCL code examples, remote state management (S3+DynamoDB, GCS, Terraform Cloud), workspace strategies, and GitOps automation with Atlantis. It also includes failure cases encountered in production operations -- state lock conflicts, drift detection, and circular dependencies -- along with recovery procedures.

Terraform Module Structure and Design Principles

Module Directory Structure

A well-designed Terraform module follows a clear file structure. Based on HashiCorp official guidelines and Google Cloud Best Practices, the standard structure is:

modules/
  networking/
    main.tf          # Core resource definitions
    variables.tf     # Input variable declarations
    outputs.tf       # Output value definitions
    versions.tf      # Provider/terraform version constraints
    README.md        # Module usage documentation
    examples/
      simple/
        main.tf      # Simple usage example
      complete/
        main.tf      # Full-featured usage example
    tests/
      networking_test.go  # Terratest tests

Core Design Principles

1. Single Responsibility Principle

Each module should handle exactly one logical function. As HashiCorp states, "If a module's function or purpose is hard to explain, the module is probably too complex."

2. Loose Coupling

Minimize direct dependencies between modules. If running terraform plan reveals that a change in one module unexpectedly alters the state of several others, that is a signal of excessive coupling.

3. No Provider Configuration in Shared Modules

Shared modules must never configure provider or backend blocks directly. Provider configuration should always be done in root modules.

# Bad example - provider configured inside module
# modules/vpc/main.tf
provider "aws" {
  region = "us-east-1"  # Hardcoded region
}

resource "aws_vpc" "main" {
  cidr_block = var.cidr_block
}

# Good example - provider configured in root module
# environments/prod/main.tf
provider "aws" {
  region = "us-east-1"
}

module "vpc" {
  source     = "../../modules/vpc"
  cidr_block = "10.0.0.0/16"
}

4. Mandatory Output Values

Define at least one output for every resource created by a module. Without outputs, dependency inference between modules is impossible, and other modules cannot reference resources from your module.

Module Design Patterns

1. Composition Pattern

The Composition pattern combines small, focused modules to build complex infrastructure. It applies the software engineering principle of "Composition over Inheritance" to infrastructure code and is the most recommended pattern.

# environments/prod/main.tf - Composition Pattern
module "vpc" {
  source     = "../../modules/networking/vpc"
  cidr_block = "10.0.0.0/16"
  azs        = ["us-east-1a", "us-east-1b", "us-east-1c"]
}

module "security_group" {
  source = "../../modules/networking/security-group"
  vpc_id = module.vpc.vpc_id

  ingress_rules = [
    {
      port        = 443
      protocol    = "tcp"
      cidr_blocks = ["0.0.0.0/0"]
    }
  ]
}

module "eks" {
  source            = "../../modules/compute/eks"
  vpc_id            = module.vpc.vpc_id
  subnet_ids        = module.vpc.private_subnet_ids
  security_group_id = module.security_group.sg_id
  cluster_version   = "1.31"
}

module "rds" {
  source            = "../../modules/database/rds"
  vpc_id            = module.vpc.vpc_id
  subnet_ids        = module.vpc.database_subnet_ids
  security_group_id = module.security_group.sg_id
  engine            = "postgres"
  engine_version    = "16.4"
}

Each module can be independently tested, versioned, and reused. Data flows between modules through output values.

2. Facade Pattern

The Facade pattern hides complex internal implementation and provides consumers with a simple interface. Like a TV remote control, a single button (variable) controls complex internal operations (multiple resource creation).

# modules/platform/main.tf - Facade Pattern
variable "environment" {
  type = string
}

variable "app_name" {
  type = string
}

variable "instance_type" {
  type    = string
  default = "t3.medium"
}

# Internally composes multiple sub-modules
module "networking" {
  source      = "../networking/vpc"
  cidr_block  = var.environment == "prod" ? "10.0.0.0/16" : "10.1.0.0/16"
  environment = var.environment
}

module "compute" {
  source        = "../compute/eks"
  vpc_id        = module.networking.vpc_id
  subnet_ids    = module.networking.private_subnet_ids
  instance_type = var.instance_type
  cluster_name  = "cluster-name-placeholder"
}

module "monitoring" {
  source     = "../observability/cloudwatch"
  cluster_id = module.compute.cluster_id
  alarm_sns  = module.compute.alarm_topic_arn
}

# Consumer uses it simply
# environments/prod/main.tf
module "platform" {
  source        = "../../modules/platform"
  environment   = "prod"
  app_name      = "my-service"
  instance_type = "m5.xlarge"
}

3. Factory Pattern

The Factory pattern uses for_each to create identical resource structures in bulk based on data-driven configuration.

# modules/multi-region/main.tf - Factory Pattern
variable "regions" {
  type = map(object({
    cidr_block    = string
    instance_type = string
    replicas      = number
  }))
}

module "regional_stack" {
  source   = "../regional-stack"
  for_each = var.regions

  region        = each.key
  cidr_block    = each.value.cidr_block
  instance_type = each.value.instance_type
  replicas      = each.value.replicas
}

# Usage example
module "global_infra" {
  source = "../../modules/multi-region"

  regions = {
    "us-east-1" = {
      cidr_block    = "10.0.0.0/16"
      instance_type = "m5.xlarge"
      replicas      = 3
    }
    "eu-west-1" = {
      cidr_block    = "10.1.0.0/16"
      instance_type = "m5.large"
      replicas      = 2
    }
  }
}

Variable Design and Output Strategy

Variable Design Guidelines

Effective variable design determines the reusability and stability of your modules.

# modules/vpc/variables.tf
variable "cidr_block" {
  type        = string
  description = "VPC CIDR block (e.g., 10.0.0.0/16)"

  validation {
    condition     = can(cidrnetmask(var.cidr_block))
    error_message = "Must be a valid CIDR block."
  }
}

variable "environment" {
  type        = string
  description = "Environment name (dev, staging, prod)"

  validation {
    condition     = contains(["dev", "staging", "prod"], var.environment)
    error_message = "Environment must be dev, staging, or prod."
  }
}

variable "enable_nat_gateway" {
  type        = bool
  default     = true
  description = "Whether to create NAT Gateways for private subnets"
}

variable "tags" {
  type        = map(string)
  default     = {}
  description = "Additional tags to apply to all resources"
}

Key Principle: Expose only values that should vary across environments (CIDR ranges, instance sizes, names, timeouts) as variables. Encapsulate internal implementation details (IAM policy structures, logging configurations, tagging schemes) inside the module.

Output Design

# modules/vpc/outputs.tf
output "vpc_id" {
  value       = aws_vpc.main.id
  description = "The ID of the VPC"
}

output "private_subnet_ids" {
  value       = aws_subnet.private[*].id
  description = "List of private subnet IDs"
}

output "database_subnet_ids" {
  value       = aws_subnet.database[*].id
  description = "List of database subnet IDs"
}

output "nat_gateway_ips" {
  value       = aws_eip.nat[*].public_ip
  description = "Elastic IPs of NAT Gateways"
}

Remote State Management

S3 + DynamoDB Backend (AWS)

The most widely used remote state configuration in AWS environments. S3 handles state file storage while DynamoDB provides state locking. Note that AWS is transitioning from DynamoDB-based locking to S3 native locking, so check the use_lockfile = true option in newer versions.

# backend.tf - S3 + DynamoDB remote state configuration
terraform {
  backend "s3" {
    bucket         = "my-company-terraform-state"
    key            = "prod/networking/terraform.tfstate"
    region         = "us-east-1"
    encrypt        = true
    dynamodb_table = "terraform-state-lock"
    # use_lockfile = true  # S3 native locking (newer versions)
  }
}

State bucket bootstrap script:

#!/bin/bash
# bootstrap-backend.sh - Create state storage infrastructure

BUCKET_NAME="my-company-terraform-state"
DYNAMODB_TABLE="terraform-state-lock"
REGION="us-east-1"

# Create S3 bucket
aws s3api create-bucket \
  --bucket "$BUCKET_NAME" \
  --region "$REGION"

# Enable versioning
aws s3api put-bucket-versioning \
  --bucket "$BUCKET_NAME" \
  --versioning-configuration Status=Enabled

# Block public access
aws s3api put-public-access-block \
  --bucket "$BUCKET_NAME" \
  --public-access-block-configuration \
    BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true

# Configure KMS encryption
aws s3api put-bucket-encryption \
  --bucket "$BUCKET_NAME" \
  --server-side-encryption-configuration '{
    "Rules": [{"ApplyServerSideEncryptionByDefault": {"SSEAlgorithm": "aws:kms"}}]
  }'

# Create DynamoDB table for state locking
aws dynamodb create-table \
  --table-name "$DYNAMODB_TABLE" \
  --attribute-definitions AttributeName=LockID,AttributeType=S \
  --key-schema AttributeName=LockID,KeyType=HASH \
  --billing-mode PAY_PER_REQUEST \
  --region "$REGION"

echo "Backend infrastructure created successfully"

GCS Backend (Google Cloud)

terraform {
  backend "gcs" {
    bucket = "my-company-tf-state"
    prefix = "prod/networking"
  }
}

Terraform Cloud / HCP Terraform

terraform {
  cloud {
    organization = "my-company"

    workspaces {
      name = "prod-networking"
    }
  }
}

Remote State Data Source (Cross-Stack References)

To reference outputs from one stack in another, use the terraform_remote_state data source.

# Referencing networking stack state from compute stack
data "terraform_remote_state" "networking" {
  backend = "s3"

  config = {
    bucket = "my-company-terraform-state"
    key    = "prod/networking/terraform.tfstate"
    region = "us-east-1"
  }
}

resource "aws_instance" "app" {
  ami           = "ami-0c55b159cbfafe1f0"
  instance_type = "t3.medium"
  subnet_id     = data.terraform_remote_state.networking.outputs.private_subnet_ids[0]
}

Workspace Strategy vs Directory Separation

Workspace Approach

Terraform workspaces share the same .tf files while maintaining independent state files per environment.

# Create and switch workspaces
terraform workspace new dev
terraform workspace new staging
terraform workspace new prod
terraform workspace select prod

# Check current workspace
terraform workspace show

Referencing workspaces in HCL:

resource "aws_instance" "app" {
  instance_type = terraform.workspace == "prod" ? "m5.xlarge" : "t3.medium"

  tags = {
    Environment = terraform.workspace
  }
}

Directory Separation Approach

infrastructure/
  modules/
    vpc/
    eks/
    rds/
  environments/
    dev/
      main.tf
      terraform.tfvars
      backend.tf
    staging/
      main.tf
      terraform.tfvars
      backend.tf
    prod/
      main.tf
      terraform.tfvars
      backend.tf

Workspaces vs Directories Comparison

CriteriaWorkspacesDirectory Separation
Code DuplicationNone (shared code)Some duplication
Environment IsolationWeak (same backend)Strong (separate backends)
IAM Permission SeparationDifficultPer-environment configuration
Blast RadiusWide (shared code)Narrow (independent)
Operational ComplexityLowMedium
Best Suited ForEphemeral environments, testingProduction environments

Recommendation: Use directory separation for production environments and workspaces for short-lived test environments. Many successful teams combine both approaches.

GitOps Automation with Atlantis

What is Atlantis?

Atlantis is a GitOps tool that automates Terraform plan and apply through pull request workflows. When a developer opens an infrastructure change PR, Atlantis automatically runs terraform plan and posts the results as a PR comment. Once reviewers approve, the changes can be applied with an atlantis apply comment.

Key Benefits

  • Consistent execution environment: All Terraform operations run on a dedicated server, eliminating "works on my machine" problems
  • Automatic state locking: While a PR is open, Atlantis locks the corresponding project state file to prevent concurrent modifications
  • Code review integration: Plan results are visible directly in the PR, ensuring visibility of infrastructure changes
  • Audit logging: All changes are recorded in PR history

atlantis.yaml Configuration

# atlantis.yaml - located at repository root
version: 3
automerge: false
parallel_plan: true
parallel_apply: false

projects:
  - name: prod-networking
    dir: environments/prod/networking
    workspace: default
    terraform_version: v1.9.0
    autoplan:
      when_modified:
        - '*.tf'
        - '*.tfvars'
        - '../../../modules/networking/**/*.tf'
      enabled: true
    apply_requirements:
      - approved
      - mergeable

  - name: prod-compute
    dir: environments/prod/compute
    workspace: default
    terraform_version: v1.9.0
    autoplan:
      when_modified:
        - '*.tf'
        - '*.tfvars'
        - '../../../modules/compute/**/*.tf'
      enabled: true
    apply_requirements:
      - approved
      - mergeable

  - name: dev-networking
    dir: environments/dev/networking
    workspace: default
    terraform_version: v1.9.0
    autoplan:
      when_modified:
        - '*.tf'
        - '*.tfvars'
      enabled: true

Custom Atlantis Workflows

# atlantis.yaml - custom workflow
workflows:
  custom:
    plan:
      steps:
        - run: terraform fmt -check -recursive
        - run: tflint --init
        - run: tflint
        - init
        - plan
    apply:
      steps:
        - apply

Module Versioning and Registry

Semantic Versioning

Terraform modules should follow Semantic Versioning (SemVer):

  • Major version bump: Adding required input variables, removing outputs -- breaking changes
  • Minor version bump: Adding optional input variables, new outputs
  • Patch version bump: Bug fixes, documentation updates
# Specifying version constraints
module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "~> 5.0"  # Latest within 5.x range
}

module "eks" {
  source  = "git::https://github.com/my-org/terraform-aws-eks.git?ref=v3.2.1"
}

Private Module Registry

Use Terraform Cloud or a self-hosted registry to manage internal modules.

# Using Terraform Cloud Private Registry
module "vpc" {
  source  = "app.terraform.io/my-org/vpc/aws"
  version = "2.1.0"
}

Comparison Tables

State Backend Comparison

FeatureS3 + DynamoDBGCSTerraform CloudAzure Blob
State LockingDynamoDB / S3 NativeBuilt-inBuilt-inBlob Lease
EncryptionKMSGoogle KMSIncludedAzure KeyVault
VersioningS3 VersioningObject VersioningIncludedBlob Snapshots
Access ControlIAM PolicyIAMTeams/RBACAzure RBAC
CostS3 + DynamoDB billingGCS billingFree tier limitedBlob billing
Setup DifficultyMediumLowLowMedium

IaC Tool Comparison

FeatureTerraform/OpenTofuPulumiCrossplaneCloudFormation
LanguageHCLTypeScript/Python/GoYAML/CRDJSON/YAML
State ManagementExternal backend requiredSelf-managed/externalKubernetes etcdAWS managed
Multi-CloudExcellentExcellentExcellentAWS only
Learning CurveMediumLow (existing languages)HighLow (AWS users)
CommunityVery largeGrowingGrowingAWS ecosystem
Drift DetectionManual via planManual via previewAutomatic (reconciliation)Drift Detection

Failure Cases and Recovery Procedures

Case 1: State Lock Conflict

Symptom: "Error acquiring the state lock" error when running terraform plan or apply

Cause: A previous Terraform operation terminated abnormally (network disconnection, CI runner timeout, Ctrl+C forced termination) and the lock was not released

Recovery procedure:

# 1. Check lock status - verify no other users are running operations
# Find the Lock ID from the error message

# 2. After confirming no other operations are running, force release
terraform force-unlock LOCK_ID

# 3. Force release without confirmation (caution: verify no operations running)
terraform force-unlock -force LOCK_ID

Prevention measures:

  • Set appropriate timeouts in CI/CD pipelines
  • Implement concurrency controls
  • Use Atlantis for PR-based automatic locking to prevent conflicts

Case 2: State Drift

Symptom: terraform plan shows unexpected changes. Resources manually modified in the console are inconsistent with Terraform state

Recovery procedure:

# 1. Refresh state file to match actual infrastructure
terraform refresh

# 2. Or check drift with plan and selectively import
terraform plan

# 3. Either update code to match manual changes or revert
terraform apply  # Restore infrastructure to match code

Case 3: Circular Dependencies

Symptom: "Cycle" error during terraform plan

Cause: Module A references outputs from Module B, and Module B references outputs from Module A

Solutions:

  • Extract common dependencies into a separate module
  • Use depends_on for explicit dependency specification
  • Switch to indirect references using data sources

Case 4: Large State File Performance Degradation

Symptom: terraform plan takes over 10 minutes, API rate limiting occurs

Solutions:

# Target specific modules for plan/apply
terraform plan -target=module.eks
terraform apply -target=module.eks

# Split state file (move state)
terraform state mv module.monitoring module.monitoring

Root cause fix: Split state files by component to reduce individual state file size. Manage networking, compute, database, and monitoring as separate state files, using terraform_remote_state data sources for cross-references.

Operational Checklists

Module Design Checklist

  • Does each module follow the single responsibility principle
  • Are output values defined for all resources
  • Do variables include type, description, and validation
  • Are provider and backend configured only in root modules
  • Does the module include README.md and an examples directory
  • Are semantic version tags maintained

State Management Checklist

  • Is a remote backend configured (no local state files)
  • Is state locking enabled
  • Are state files encrypted
  • Is S3 bucket versioning enabled
  • Is public access blocked
  • Are state files separated by component

Atlantis / CI-CD Checklist

  • Is atlantis.yaml configured at the repository root
  • Are approved + mergeable requirements set before apply
  • Do dependent projects auto-plan when modules change
  • Are webhook secrets securely managed
  • Is credential rotation performed periodically

Conclusion

Terraform module design and state management become exponentially more important as infrastructure code scales. Composing small modules with the Composition pattern, separating remote state by component, and automating GitOps workflows with Atlantis represents the most mature operational model as of 2026.

The key principle is "start small and split when needed." Rather than attempting to design a perfect module structure from the beginning, start with a single module and refactor when duplication arises, split state files when they grow too large, and introduce Atlantis when the team expands. This incremental approach is the most practical path forward.

Infrastructure code should be subject to the same engineering disciplines as application code -- code review, testing, version control, and CI/CD. The patterns and tools presented in this guide aim to provide practical assistance on that journey.