Workshop: Multi-Environment Terraform Setup (dev, staging, prod)

การแยก environment dev/staging/prod เป็นแนวปฏิบัติพื้นฐานของทีม DevOps ที่จริงจังกับ reliability — workshop นี้สาธิตวิธีจัดโครงสร้าง Terraform ให้รองรับหลาย environment โดยไม่ต้อง copy-paste code และยังคงแยก state file ของแต่ละ environment อย่างชัดเจน

จะเปรียบเทียบ 3 approach หลัก ได้แก่ (1) directory-per-environment, (2) workspaces และ (3) Terragrunt-style — แล้วเลือก approach (1) ทำตามทั้ง workshop เพราะเหมาะกับองค์กรทั่วไปที่ต้องการความชัดเจนและ blast radius แคบ

โครงสร้างโปรเจกต์

multi-env-workshop/
├── modules/
│   └── web_server/
│       ├── main.tf
│       ├── variables.tf
│       └── outputs.tf
├── envs/
│   ├── dev/
│   │   ├── main.tf
│   │   ├── backend.tf
│   │   └── terraform.tfvars
│   ├── staging/
│   │   ├── main.tf
│   │   ├── backend.tf
│   │   └── terraform.tfvars
│   └── prod/
│       ├── main.tf
│       ├── backend.tf
│       └── terraform.tfvars
└── README.md

ข้อดีของ layout นี้คือ apply แต่ละ environment ต้อง cd envs/dev && terraform apply ทำให้ลืม apply ผิด environment ได้ยาก state file แยกขาดโดยธรรมชาติ และ code re-use ผ่าน modules/

ขั้นที่ 1 — Reusable Module

# modules/web_server/variables.tf
variable "environment" {
  type = string
  validation {
    condition     = contains(["dev", "staging", "prod"], var.environment)
    error_message = "environment ต้องเป็น dev, staging หรือ prod"
  }
}

variable "instance_count" {
  type    = number
  default = 1
}

variable "instance_size" {
  type    = string
  default = "s-1vcpu-1gb"
}

variable "region" {
  type    = string
  default = "sgp1"
}
# modules/web_server/main.tf
resource "digitalocean_droplet" "web" {
  count    = var.instance_count
  name     = "${var.environment}-web-${count.index + 1}"
  region   = var.region
  size     = var.instance_size
  image    = "ubuntu-22-04-x64"

  tags = [
    var.environment,
    "web",
    "managed-by-terraform",
  ]
}

resource "digitalocean_tag" "env" {
  name = var.environment
}
# modules/web_server/outputs.tf
output "droplet_ips" {
  value = digitalocean_droplet.web[*].ipv4_address
}

output "droplet_ids" {
  value = digitalocean_droplet.web[*].id
}

ขั้นที่ 2 — Environment: dev

# envs/dev/backend.tf
terraform {
  required_version = ">= 1.5"
  backend "s3" {
    bucket         = "my-tfstate-bucket"
    key            = "envs/dev/terraform.tfstate"
    region         = "ap-southeast-1"
    dynamodb_table = "tfstate-lock"
    encrypt        = true
  }
  required_providers {
    digitalocean = {
      source  = "digitalocean/digitalocean"
      version = "~> 2.0"
    }
  }
}
# envs/dev/main.tf
provider "digitalocean" {}

module "web" {
  source = "../../modules/web_server"

  environment    = "dev"
  instance_count = 1
  instance_size  = "s-1vcpu-1gb"
  region         = "sgp1"
}

output "dev_ips" {
  value = module.web.droplet_ips
}
# envs/dev/terraform.tfvars
# ค่า override (ถ้ามี)
# module นี้ไม่ต้องมี tfvars เพิ่ม เพราะใช้ default ของ module

ขั้นที่ 3 — Environment: staging

# envs/staging/main.tf
provider "digitalocean" {}

module "web" {
  source = "../../modules/web_server"

  environment    = "staging"
  instance_count = 2
  instance_size  = "s-2vcpu-2gb"
  region         = "sgp1"
}

backend.tf ของ staging ใช้ key = "envs/staging/terraform.tfstate" คนละ key กับ dev เพื่อแยก state file

ขั้นที่ 4 — Environment: prod

# envs/prod/main.tf
provider "digitalocean" {}

module "web" {
  source = "../../modules/web_server"

  environment    = "prod"
  instance_count = 3
  instance_size  = "s-4vcpu-8gb"
  region         = "sgp1"
}

resource "digitalocean_loadbalancer" "web" {
  name   = "prod-web-lb"
  region = "sgp1"

  forwarding_rule {
    entry_port      = 443
    entry_protocol  = "https"
    target_port     = 80
    target_protocol = "http"
    certificate_name = "prod-cert"
  }

  healthcheck {
    port     = 80
    protocol = "http"
    path     = "/health"
  }

  droplet_ids = module.web.droplet_ids
}

prod มี resource เพิ่มเติม เช่น load balancer ที่ไม่จำเป็นใน dev/staging — เขียนตรงในแต่ละ environment ไม่ต้องยัดเข้า module เพื่อคง flexibility

ขั้นที่ 5 — Workflow การใช้งาน

# Deploy dev
cd envs/dev
terraform init
terraform plan
terraform apply

# Deploy staging (ต่อจาก dev ผ่าน test)
cd ../staging
terraform init
terraform plan
terraform apply

# Deploy prod (require approval)
cd ../prod
terraform init
terraform plan -out=prod.tfplan
# ให้ senior review prod.tfplan ก่อน apply
terraform apply prod.tfplan

ขั้นที่ 6 — Shared Variables ระหว่าง Environment

สำหรับค่าที่ใช้ทุก environment (เช่น region, organization tags) สามารถสร้างไฟล์ common.tfvars แล้วส่งผ่าน CLI

# envs/_common.tfvars
region      = "sgp1"
org_name    = "my-company"
cost_center = "engineering"

# Apply:
terraform apply -var-file=../_common.tfvars

ขั้นที่ 7 — CI/CD Integration

# .github/workflows/tf-envs.yml
name: Terraform
on:
  pull_request:
    paths: ['envs/**', 'modules/**']

jobs:
  plan:
    strategy:
      matrix:
        env: [dev, staging, prod]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: hashicorp/setup-terraform@v3
      - name: Plan ${{ matrix.env }}
        working-directory: envs/${{ matrix.env }}
        run: |
          terraform init
          terraform plan -out=tfplan
      - name: Comment plan
        # post plan to PR comment

pipeline pattern นี้รัน plan ของทุก environment ขนานกัน — เห็น impact ของ PR ต่อทุก env พร้อมกันบน review

เปรียบเทียบ 3 Approach

  • Directory-per-env (ใน workshop นี้) — เข้าใจง่าย, blast radius แคบ, flexibility สูง แต่ต้องคัดลอก backend config — เหมาะองค์กรทั่วไป
  • Terraform Workspaces — ใช้ state เดียวแต่แยก namespace ด้วย workspace — เขียนโค้ดน้อยกว่าแต่เสี่ยงถ้ามี typo ชื่อ workspace จะ apply ผิด env
  • Terragrunt / Atlantis — framework ข้าง ๆ สำหรับลด DRY — ลด config ซ้ำได้มากแต่เพิ่ม learning curve ควรใช้เมื่อมีหลาย team

ข้อควรระวัง

  • ต้องแยก state file ทุก environment — ห้ามใช้ state เดียวกัน ไม่งั้น apply dev อาจกระทบ prod
  • backend S3 bucket ต้องเปิด versioning และ MFA delete เพื่อป้องกัน state หาย
  • ห้าม hardcode secret ใน tfvars ของ prod — ใช้ Vault, AWS Secrets Manager หรือ SOPS encrypted file
  • ให้ prod apply ผ่าน CI เท่านั้น ห้าม engineer รันจากเครื่องตัวเอง เพื่อให้ audit log ครบ
  • version pin ทั้ง module version, provider version — ไม่ให้เด้งเวอร์ชันไปเองเมื่อ prod apply

สรุป

การจัดโครงสร้างหลาย environment ด้วย directory-per-env + shared module ให้ทั้งความชัดเจนและ code reuse ไปพร้อมกัน — เหมาะกับทีมที่ต้องการ onboarding engineer ใหม่ได้เร็ว โดยเห็นจากโครงสร้าง directory ว่ามี env ใดและ module ใดถูก shared บ้าง

เมื่อโปรเจกต์โตถึงระดับหลาย service พร้อมกัน ค่อยพิจารณา Terragrunt หรือ Atlantis เพื่อลด config ที่ซ้ำกันและทำให้ CI เรียบ — แต่สำหรับ workshop และ organization ขนาดเล็ก/กลาง pattern นี้เพียงพอและอ่านง่ายที่สุด