Workshop: Terraform + CI/CD สำหรับ Automated Infrastructure Deployment

การ apply Terraform จากเครื่อง engineer เป็นขั้นแรกที่ทำได้เร็ว แต่เมื่อโปรเจกต์โตขึ้น ทีมขยายใหญ่ และต้องผ่าน audit จะเริ่มเห็นข้อจำกัด — ไม่มี audit log ครบ, concurrent apply ชนกัน, credential หลุดได้ง่าย — workshop นี้จัดทำ CI/CD pipeline สำหรับ infrastructure deployment ที่ auto plan บน PR, apply เมื่อ merge และมี approval gate สำหรับ prod

ใช้ GitHub Actions + OIDC federation (ไม่ใช้ long-lived access key), backend S3 state, และ DigitalOcean Provider เป็นตัวอย่าง — pattern นี้ portable ใช้กับ GitLab CI, CircleCI, Jenkins ได้เช่นกันเพียงเปลี่ยน syntax

สถาปัตยกรรม Pipeline

Flow:
  Developer → PR opened → workflow "plan" runs
                          ├─ terraform fmt -check
                          ├─ terraform validate
                          ├─ terraform plan → post plan เป็น PR comment
                          └─ tfsec / checkov scan → comment security warning

  Reviewer approves + merge → workflow "apply" runs (branch = main)
                          ├─ apply dev อัตโนมัติ
                          ├─ apply staging อัตโนมัติ
                          └─ apply prod → รอ manual approval → apply

  Schedule nightly      → workflow "drift-detect"
                          └─ plan ทุก env เทียบกับ state → แจ้งเตือนถ้า drift

ขั้นที่ 1 — ตั้งค่า OIDC Federation

แทนที่จะเก็บ access key ของ cloud provider เป็น secret ใน GitHub ให้ใช้ OIDC federation — GitHub Actions ออก token ระยะสั้นที่ cloud provider เชื่อถือได้โดยตรง ลดความเสี่ยง credential leak

# AWS: สร้าง OIDC provider ใน IAM
aws iam create-open-id-connect-provider \
  --url https://token.actions.githubusercontent.com \
  --client-id-list sts.amazonaws.com \
  --thumbprint-list 6938fd4d98bab03faadb97b34396831e3780aea1

# สร้าง IAM Role ที่ trust OIDC provider
cat > trust-policy.json <<EOF
{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Principal": { "Federated": "arn:aws:iam::123456789:oidc-provider/token.actions.githubusercontent.com" },
    "Action": "sts:AssumeRoleWithWebIdentity",
    "Condition": {
      "StringEquals": {
        "token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
      },
      "StringLike": {
        "token.actions.githubusercontent.com:sub": "repo:my-org/my-repo:*"
      }
    }
  }]
}
EOF

aws iam create-role --role-name gh-terraform-apply \
  --assume-role-policy-document file://trust-policy.json

ขั้นที่ 2 — Workflow: Plan บน Pull Request

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

permissions:
  id-token: write       # สำคัญ: OIDC ต้องใช้
  contents: read
  pull-requests: write  # ให้ post comment plan ลง PR ได้

jobs:
  plan:
    strategy:
      matrix:
        env: [dev, staging, prod]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789:role/gh-terraform-apply
          aws-region: ap-southeast-1

      - uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: 1.7.0

      - name: Format & Validate
        working-directory: envs/${{ matrix.env }}
        run: |
          terraform fmt -check -recursive
          terraform init -backend=false
          terraform validate

      - name: Plan
        working-directory: envs/${{ matrix.env }}
        run: |
          terraform init
          terraform plan -out=tfplan -no-color | tee plan-${{ matrix.env }}.txt

      - name: Comment Plan on PR
        uses: actions/github-script@v7
        with:
          script: |
            const fs = require('fs');
            const plan = fs.readFileSync(
              `envs/${{ matrix.env }}/plan-${{ matrix.env }}.txt`, 'utf8'
            );
            const truncated = plan.length > 50000
              ? plan.slice(0, 50000) + '\n... (truncated)'
              : plan;
            github.rest.issues.createComment({
              owner: context.repo.owner,
              repo: context.repo.repo,
              issue_number: context.issue.number,
              body: '### Terraform Plan: `${{ matrix.env }}`\n```\n' + truncated + '\n```'
            });

ขั้นที่ 3 — Workflow: Apply บน Merge

# .github/workflows/tf-apply.yml
name: Terraform Apply
on:
  push:
    branches: [main]
    paths: ['envs/**', 'modules/**']

permissions:
  id-token: write
  contents: read

jobs:
  apply-dev:
    runs-on: ubuntu-latest
    environment: dev      # GitHub Environment
    steps:
      - uses: actions/checkout@v4
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789:role/gh-terraform-apply
          aws-region: ap-southeast-1
      - uses: hashicorp/setup-terraform@v3
      - name: Apply dev
        working-directory: envs/dev
        run: |
          terraform init
          terraform apply -auto-approve

  apply-staging:
    needs: apply-dev
    runs-on: ubuntu-latest
    environment: staging
    steps:
      - uses: actions/checkout@v4
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789:role/gh-terraform-apply
          aws-region: ap-southeast-1
      - uses: hashicorp/setup-terraform@v3
      - name: Apply staging
        working-directory: envs/staging
        run: |
          terraform init
          terraform apply -auto-approve

  apply-prod:
    needs: apply-staging
    runs-on: ubuntu-latest
    environment: prod     # environment นี้ตั้ง required reviewer ใน GitHub UI
    steps:
      - uses: actions/checkout@v4
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789:role/gh-terraform-apply
          aws-region: ap-southeast-1
      - uses: hashicorp/setup-terraform@v3
      - name: Apply prod
        working-directory: envs/prod
        run: |
          terraform init
          terraform apply -auto-approve

กุญแจของความปลอดภัยอยู่ที่ environment: prod — ใน GitHub settings ของ repository ตั้งค่า Environment “prod” ให้ต้องมี required reviewer (อย่างน้อย 1 คนที่ไม่ใช่ผู้ push) — pipeline จะหยุดรอ approval ก่อน apply

ขั้นที่ 4 — Security Scan ใน PR

# เพิ่มใน tf-plan.yml เป็นอีก job
jobs:
  security-scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: tfsec
        uses: aquasecurity/[email protected]
        with:
          soft_fail: true   # ยังให้ PR ผ่านได้ แต่ comment warning
      - name: checkov
        uses: bridgecrewio/checkov-action@master
        with:
          directory: envs/
          quiet: true
          soft_fail: true

soft_fail=true ให้ scan เตือนเฉย ๆ ไม่บล็อก merge ในช่วงแรก เมื่อทีมชินแล้วค่อยเปลี่ยนเป็น hard fail เพื่อบังคับ config ให้ผ่าน security policy เสมอ

ขั้นที่ 5 — Drift Detection แบบ Schedule

# .github/workflows/tf-drift.yml
name: Terraform Drift Detection
on:
  schedule:
    - cron: '0 2 * * *'    # ทุกวันตี 2
  workflow_dispatch:        # รัน manual ได้

permissions:
  id-token: write
  contents: read

jobs:
  drift:
    strategy:
      matrix:
        env: [dev, staging, prod]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789:role/gh-terraform-readonly
          aws-region: ap-southeast-1
      - uses: hashicorp/setup-terraform@v3
      - name: Plan
        id: plan
        working-directory: envs/${{ matrix.env }}
        run: |
          terraform init
          terraform plan -detailed-exitcode -no-color > drift.txt
          echo "exit_code=$?" >> $GITHUB_OUTPUT
        continue-on-error: true

      - name: Alert on drift
        if: steps.plan.outputs.exit_code == '2'
        run: |
          curl -X POST $SLACK_WEBHOOK \
            -H 'Content-Type: application/json' \
            -d "{\"text\": \"drift detected in ${{ matrix.env }}\"}"
        env:
          SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}

terraform plan -detailed-exitcode จะ return 0 = no changes, 1 = error, 2 = diff detected — ใช้ exit code 2 ส่ง alert ไป Slack เพื่อให้ทีมรู้ว่ามีคน manual เปลี่ยน resource บน console (drift)

ขั้นที่ 6 — State Locking กันชนกัน

# envs/dev/backend.tf (และ staging, prod)
terraform {
  backend "s3" {
    bucket         = "my-tfstate"
    key            = "envs/dev/terraform.tfstate"
    region         = "ap-southeast-1"
    dynamodb_table = "tfstate-lock"    # DynamoDB สำหรับ lock
    encrypt        = true
  }
}

หากมี 2 PR merge ชนกัน (race condition) backend lock จะบังคับให้ workflow ที่ 2 รอ — ไม่เกิด state corruption เพราะ concurrent apply

ขั้นที่ 7 — Rollback Strategy

  • Git revert + apply — revert commit ที่ผิด push ขึ้น main → pipeline รัน apply → กลับสู่ state เดิม (ใช้ได้ถ้า resource ลบ/สร้างใหม่ได้)
  • Terraform state rollback — S3 bucket เปิด versioning → นำ state file version ก่อนหน้ามา restore ผ่าน aws s3api copy-object --version-id ...
  • Forward fix — สำหรับ resource ที่ลบแล้วข้อมูลหาย (เช่น database) ควร fix forward ด้วยการ restore จาก backup ไม่ใช่ revert Terraform

ขั้นที่ 8 — Observability ของ Pipeline

  • บันทึก plan output ทุกรอบเป็น artifact เก็บ 90 วัน เพื่อ audit
  • ส่ง apply log ไปยัง central log (CloudWatch, Loki) — ดูผ่านกลางได้
  • เพิ่ม metric “time to apply” ต่อ environment — ถ้าเกิน threshold (เช่น prod apply เกิน 10 นาที) แจ้งเตือน
  • track จำนวน PR ที่มี plan changes vs merge — ถ้าบาง PR เปลี่ยนหลาย resource ต้อง review ละเอียดขึ้น

ข้อควรระวัง

  • ห้าม commit secret ใน .tf หรือ .tfvars — ใช้ GitHub Secrets หรือ Vault + data source
  • Role OIDC ต้องจำกัด sub claim ให้ตรง repo และ branch จริง ๆ มิฉะนั้น repo อื่นใน org จะ assume role ได้
  • ห้ามให้ workflow รัน apply จาก branch ใดก็ได้ — จำกัดเฉพาะ main หรือ release/* เท่านั้น
  • Plan output ใน PR comment อาจมีข้อมูล sensitive (เช่น ARN, IP) — ระวังกรณี repo เป็น public
  • การ apply ตาม schedule โดยไม่มี human review เสี่ยงต่อการเปลี่ยนแปลงที่ไม่คาดคิด — drift detection ควรเป็น alert เท่านั้น ไม่ auto-apply

สรุป

Pipeline CI/CD สำหรับ Terraform ที่ดีต้องมีอย่างน้อย plan-on-PR, apply-on-merge, approval gate สำหรับ prod และ drift detection — ใช้ OIDC federation แทน access key ยาว และ state lock ผ่าน DynamoDB เพื่อกัน concurrent apply

โครงสร้างนี้ scale ได้ตั้งแต่ทีมเล็ก 2-3 คนไปจนถึง organization ขนาดใหญ่ที่มีหลาย service — เมื่อซับซ้อนมากขึ้นค่อยเพิ่ม self-hosted runner, custom action สำหรับ policy check หรือ integrate Terraform Cloud/Enterprise — แต่เริ่มจาก pattern ง่าย ๆ ที่ได้ audit log + security gate ครบก่อนเสมอ