Advanced

Terraform: Automation and GitOps

Automate Terraform, build a CI/CD pipeline, manage multiple environments and use HCP Terraform.

Scenario: Globomantics — Taco Wagon team (AWS infrastructure: VPC, subnets, Auto Scaling Group, ELB)


Table of Contents

  1. Overview and Objectives
  2. Module 1 — Automating Terraform
  3. Module 2 — Building a CI/CD Pipeline
  4. Module 3 — Multiple Environments
  5. Module 4 — HCP Terraform for Automation
  6. Architecture Diagrams
  7. Reference — Terraform Automation Tools
  8. Project File Structure

1. Overview and Objectives

This course covers complete automation of the Terraform workflow through GitOps practices: from local commits all the way to production deployment, including CI/CD pipelines and dedicated automation platforms.

Course Objectives

#Objective
1Create automatic pre-commit hooks on local workstations to validate code
2Build a Continuous Integration pipeline that validates code and generates plans
3Create a Continuous Delivery pipeline that automates deployment to lower environments
4Create a release pipeline that automates deployment to production

Target Infrastructure (Taco Wagon)

AWS
├── VPC
├── Public Subnets
├── Auto Scaling Group
├── Elastic Load Balancer
└── Security Groups

Environments: devstagingprod-east || prod-west


2. Module 1 — Automating Terraform

Automation Planning

The standard Terraform workflow includes three stages that are candidates for automation:

flowchart LR
    A[📝 Write code] --> B[✅ Validation\nfmt / validate / lint]
    B --> C[📋 Plan\nReview changes]
    C --> D{Approval?}
    D -- Yes --> E[🚀 Apply]
    D -- No --> A
    E --> A
StageRecommended AutomationWhere to Run
Code validationterraform fmt, terraform validate, tflint, terraform-docsLocally (pre-commit)
Plan generationterraform plan, terraform show, tf-summarizeCI/CD pipeline (remote)
Applyterraform applyCD pipeline (remote, controlled)

GitOps Terminology

GitOps = using Git as the single source of truth to trigger infrastructure automation.

TermDefinition
RepositoryCode repository hosted on GitHub, GitLab, Bitbucket, CodeCommit…
BranchDevelopment branch isolated from main
Pull Request (PR)Request to merge a branch into main — main CI trigger
CommitVersioned unit of change
MergeIntegration of a branch into main — main CD trigger
Release TagVersioned tag (e.g., v1.0.0) — production pipeline trigger

Validation and Automation Tools

ToolRoleType
terraform fmtHCL code formattingOfficial HashiCorp
terraform validateSyntax validationOfficial HashiCorp
terraform planChange plan generationOfficial HashiCorp
terraform show -jsonExport plan as JSONOfficial HashiCorp
TFLintAdvanced linting, best practices, provider-specific rulesOpen source
terraform-docsAutomatic Markdown documentation generationOpen source
TrivySecurity scanning and misconfigurationsAqua Security
CheckovIaC static security analysisBridgecrew/Palo Alto
KICSMulti-IaC security scanningCheckmarx
tf-summarizeHuman-readable Terraform plan summaryOpen source
pre-commitGit hook framework (Python)Open source

Pre-commit Hooks

Pre-commit hooks run automatically before each git commit. They block the commit if a check fails.

How they work:

  • Hooks are defined in .pre-commit-config.yaml
  • The pre-commit tool (Python project) generates Git hooks from this YAML file
  • Hooks live in .git/hooks/

.pre-commit-config.yaml

repos:
  - repo: local
    hooks:
      # Format Terraform configuration files
      - id: terraform-fmt
        name: Terraform Format
        entry: terraform fmt -recursive
        language: system
        files: \.tf$
        pass_filenames: false

      # Validate Terraform configuration files
      - id: terraform-validate
        name: Terraform Validate
        entry: terraform validate
        language: system
        files: \.tf$
        pass_filenames: false

      # Linting with TFLint
      - id: tflint
        name: TFLint
        entry: tflint --config=.tflint.hcl
        language: system
        files: \.tf$
        pass_filenames: false

      # Generate documentation from Terraform modules
      - id: terraform-docs
        name: Terraform Docs
        entry: terraform-docs markdown table --output-file TACOWAGON.md --output-mode inject .
        language: system
        files: \.tf$
        pass_filenames: false

.tflint.hcl — TFLint Configuration

plugin "aws" {
  enabled = true
  version = "0.31.0"
  source  = "github.com/terraform-linters/tflint-ruleset-aws"
}

Installation and usage commands:

# Install the pre-commit framework (Python required)
pip install pre-commit

# Install hooks in the local Git repo
pre-commit install

# Manually run all hooks
pre-commit run --all-files

# Skip hooks during a commit (avoid!)
git commit --no-verify

3. Module 2 — Building a CI/CD Pipeline

CI/CD Concepts for IaC

flowchart TD
    subgraph CI["🔄 Continuous Integration"]
        PR[PR created / updated] --> FMT[terraform fmt]
        FMT --> VAL[terraform validate]
        VAL --> LINT[tflint]
        LINT --> SEC[Trivy / Checkov]
        SEC --> PLAN[terraform plan]
        PLAN --> SUM[tf-summarize → PR Comment]
    end

    subgraph CD["📦 Continuous Delivery"]
        MERGE[PR merged to main] --> DEV[Apply → DEV]
        DEV --> STAGING[Apply → STAGING]
    end

    subgraph PROD["🚀 Production Release"]
        TAG[Release Tag v*] --> PE[Apply → PROD-EAST]
        TAG --> PW[Apply → PROD-WEST]
    end

    CI --> CD
    CD --> PROD
PhaseTriggerEnvironmentsAction
CIPR creation/updateAll (dev, staging, prod-east, prod-west)Validation + Plan
CDPR merge to maindev → staging (sequential)Apply
ReleasePush of a v* tagprod-east || prod-west (parallel)Apply

Terraform Automation Considerations

Files to manage in .gitignore:

# Terraform initialization directory
.terraform/

# State files (never commit in prod)
*.tfstate
*.tfstate.backup

# Sensitive variables (optional depending on context)
*.tfvars

# Crash file
crash.log
crash.*.log

# Override files
override.tf
override.tf.json
*_override.tf
*_override.tf.json

Important environment variables:

VariableValueEffect
TF_IN_AUTOMATIONtrueReduces interactive messages
TF_LOGINFOLogging level
TF_INPUTfalseDisables interactive prompts
TF_PLUGIN_CACHE_DIR/path/to/cacheCaches provider plugins

The .terraform.lock.hcl file:

  • Records the exact provider versions used during initialization
  • Must be committed to source control to ensure consistency between machines and CI systems
  • Prevents provider version drift

CI Pipeline — GitHub Actions

Terraform Backend Configuration (S3 backend)

# terraform.tf
terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~>6.0"
    }
  }

  backend "s3" {
    # Use backend files: backends/*.hcl
    # terraform init -backend-config="./backends/dev.hcl"
  }
}
# backends/dev.hcl
key    = "tw-dev.tfstate"
bucket = "my-terraform-state-bucket"
region = "us-east-1"

Complete CI Workflow (.github/workflows/ci.yml) — Module 2

name: Terraform CI

on:
  pull_request:
    branches:
      - main

env:
  TF_LOG: INFO
  TF_INPUT: false
  TF_VERSION: ${{ vars.TF_VERSION }}

jobs:
  # Format check
  format:
    name: Terraform Format
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: ${{ env.TF_VERSION }}
      - name: Terraform Format Check
        run: terraform fmt -check -recursive

  # Syntax validation
  validate:
    name: Terraform Validate
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: ${{ env.TF_VERSION }}
      - run: terraform init -backend=false
      - run: terraform validate

  # Linting
  tflint:
    name: TFLint
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: terraform-linters/setup-tflint@v4
      - run: tflint --init
      - run: tflint --recursive

  # Security scan
  trivy:
    name: Trivy Security Scan
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: aquasecurity/trivy-action@master
        with:
          scan-type: config
          scan-ref: .
          exit-code: 1
          severity: CRITICAL

  # Plan generation
  plan:
    name: Terraform Plan Dev
    runs-on: ubuntu-latest
    needs: [format, validate, tflint, trivy]
    permissions:
      contents: read
      pull-requests: write
    steps:
      - uses: actions/checkout@v4
      - uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: ${{ env.TF_VERSION }}
          terraform_wrapper: false
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: ${{ secrets.AWS_REGION }}
      - run: terraform init -backend-config="./backends/dev.hcl"
      - run: terraform plan -var-file="./environments/dev.tfvars" -out=tf.plan

      # Save plan as artifact
      - uses: actions/upload-artifact@v4
        with:
          name: tf-plan-dev-${{ github.run_id }}
          path: tf.plan
          retention-days: 5

      # Plan summary as PR comment
      - uses: kishaningithub/setup-tf-summarize@v2
      - name: Generate Plan Summary
        run: |
          terraform show -json tf.plan > plan.json
          tf-summarize -md plan.json > summary.md
      - uses: actions/github-script@v7
        with:
          script: |
            const fs = require('fs');
            const summary = fs.readFileSync('summary.md', 'utf8');
            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: `## Terraform Plan Summary - dev\n\n${summary}\n\n*Run ID: ${{ github.run_id }}*`
            });

CD Pipeline — Continuous Delivery

Deployment Trigger Options

flowchart TD
    PR[Pull Request opened] --> CI[CI Pipeline]
    CI --> PLAN[Plan generated + artifact saved]
    
    subgraph OPT1["Option 1 — Deploy on Merge"]
        PLAN --> REVIEW1[Review plan]
        REVIEW1 --> MERGE1[Merge PR → main]
        MERGE1 --> APPLY1[terraform apply]
    end

    subgraph OPT2["Option 2 — Deploy via PR Comment"]
        PLAN --> REVIEW2[Review plan]
        REVIEW2 --> COMMENT[Comment /deploy in the PR]
        COMMENT --> APPLY2[terraform apply BEFORE merge]
        APPLY2 --> MERGE2[Merge if successful]
    end

Advantage of Option 2: If deployment fails, the buggy code stays in the feature branch (not in main). The fix is done in the same PR, without polluting main.

CD Workflow (.github/workflows/cd.yml)

name: Terraform CD

on:
  pull_request:
    branches:
      - main
    types:
      - closed

permissions:
  contents: read
  actions: read

env:
  TF_LOG: INFO
  TF_INPUT: false
  TF_VERSION: ${{ vars.TF_VERSION }}

jobs:
  # Get the CI run ID to retrieve the plan artifact
  get-run-id:
    name: Get CI Run ID
    if: github.event.pull_request.merged == true
    runs-on: ubuntu-latest
    outputs:
      run_id: ${{ steps.get-run.outputs.run_id }}
    steps:
      - name: Get CI Workflow Run ID
        id: get-run
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          RUN_ID=$(gh run list \
            --repo ${{ github.repository }} \
            --workflow "Terraform CI" \
            --branch ${{ github.head_ref }} \
            --status success \
            --limit 1 \
            --json databaseId \
            --jq '.[0].databaseId')
          echo "run_id=$RUN_ID" >> $GITHUB_OUTPUT

  # Deploy to development
  deploy-dev:
    name: Deploy dev
    needs: [get-run-id]
    runs-on: ubuntu-latest
    environment: dev
    steps:
      - uses: actions/checkout@v4
      - uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: ${{ env.TF_VERSION }}
          terraform_wrapper: false
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: ${{ secrets.AWS_REGION }}
      - run: terraform init -backend-config="./backends/dev.hcl"
      - uses: actions/download-artifact@v4
        with:
          name: tf-plan-dev-${{ needs.get-run-id.outputs.run_id }}
          run-id: ${{ needs.get-run-id.outputs.run_id }}
          github-token: ${{ secrets.GITHUB_TOKEN }}
      - run: terraform apply tf.plan

4. Module 3 — Multiple Environments

Multi-environment Strategies

flowchart LR
    subgraph APPROACHES["Approaches for multi-environments"]
        W[Workspaces\nCommunity Edition]
        F[Folders\nSeparate Directories]
        B[Branches\nLong-lived Branches]
        P[Pipelines\n✅ Recommended]
    end

    W -->|"❌ No access control\n❌ Shared backend"| FAIL1[Limited]
    F -->|"❌ Code duplication\n❌ Difficult to scale"| FAIL2[Limited]
    B -->|"⚠️ Complex merges\n⚠️ Long-lived branches"| PARTIAL[Acceptable]
    P -->|"✅ Reusable\n✅ Promoted env to env"| OK[Recommended]
ApproachAdvantagesDisadvantages
Workspaces (Community)Simple to startNo access control, shared backend
FoldersClear separationCode duplication, does not scale
BranchesSame root moduleComplex merges, long-lived branches
Pipelines + tfvarsReusable, scalable, promotedInitial setup complexity

Terraform Community Workspaces

# Create a workspace
terraform workspace new dev

# List workspaces
terraform workspace list

# Select a workspace
terraform workspace select staging

# Reference the current workspace in HCL code
# terraform.workspace returns the active workspace name
# Example usage of terraform.workspace in code
resource "aws_vpc" "main" {
  cidr_block = var.vpc_address_range

  tags = {
    Name        = "${var.prefix}-${terraform.workspace}-vpc"
    Environment = terraform.workspace
  }
}

Community Workspaces Limitations:

  • All workspaces share the same backend → no access isolation
  • No native access control
  • Unaware of version control (branch/tag)
  • Designed for temporary environments, not long-term use

Pipeline-based Approach

Project Structure

taco-wagon-app/
├── .github/
│   └── workflows/
│       ├── ci.yml               # Validation + Plans
│       ├── cd.yml               # Deploy dev + staging
│       ├── release.yml          # Deploy prod-east + prod-west
│       ├── deploy-environment.yml  # Reusable workflow
│       ├── manual_deploy.yml    # Manual deploy with approval
│       └── manual_destroy.yml   # Destroy an environment
├── backends/
│   ├── dev.hcl
│   ├── staging.hcl
│   ├── prodeast.hcl
│   └── prodwest.hcl
├── environments/
│   ├── dev.tfvars
│   ├── staging.tfvars
│   ├── prodeast.tfvars
│   └── prodwest.tfvars
├── main.tf
├── variables.tf
├── providers.tf
├── outputs.tf
├── terraform.tf
├── .pre-commit-config.yaml
└── .tflint.hcl

CI with matrix for all environments (Module 3)

name: Terraform CI

on:
  pull_request:
    branches:
      - main

env:
  TF_LOG: INFO
  TF_INPUT: false
  TF_VERSION: ${{ vars.TF_VERSION }}
  ENVIRONMENTS: '["dev","staging","prodeast","prodwest"]'

jobs:
  # Setup job to export the environment list
  setup:
    name: Setup
    runs-on: ubuntu-latest
    outputs:
      environments: ${{ env.ENVIRONMENTS }}
    steps:
      - run: echo "Environments - ${{ env.ENVIRONMENTS }}"

  # Plans in parallel for all environments via matrix
  plan:
    name: Terraform Plan (${{ matrix.environment }})
    runs-on: ubuntu-latest
    needs: [setup, format, validate, tflint, trivy]
    strategy:
      fail-fast: false
      matrix:
        environment: ${{ fromJson(needs.setup.outputs.environments) }}
    steps:
      - uses: actions/checkout@v4
      - uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: ${{ env.TF_VERSION }}
          terraform_wrapper: false
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: ${{ secrets.AWS_REGION }}
      - run: terraform init -backend-config="./backends/${{ matrix.environment }}.hcl"
      - run: terraform plan -var-file="./environments/${{ matrix.environment }}.tfvars" -out=tf.plan
      - uses: actions/upload-artifact@v4
        with:
          name: tf-plan-${{ matrix.environment }}-${{ github.run_id }}
          path: tf.plan
          retention-days: 5

Reusable Workflow — Deploy Environment

name: Deploy Environment

on:
  workflow_call:
    inputs:
      environment:
        description: 'Environment to deploy to'
        required: true
        type: string
      terraform_version:
        required: true
        type: string
      ci_run_id:
        description: 'CI workflow run ID for artifact retrieval'
        required: true
        type: string
    secrets:
      AWS_ACCESS_KEY_ID:
        required: true
      AWS_SECRET_ACCESS_KEY:
        required: true
      AWS_REGION:
        required: true

jobs:
  deploy:
    name: Deploy to ${{ inputs.environment }}
    runs-on: ubuntu-latest
    environment: ${{ inputs.environment }}
    env:
      TF_LOG: INFO
      TF_INPUT: false
    steps:
      - uses: actions/checkout@v4
      - uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: ${{ inputs.terraform_version }}
          terraform_wrapper: false
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: ${{ secrets.AWS_REGION }}
      - run: terraform init -backend-config="./backends/${{ inputs.environment }}.hcl"
      - uses: actions/download-artifact@v4
        with:
          name: tf-plan-${{ inputs.environment }}-${{ inputs.ci_run_id }}
          run-id: ${{ inputs.ci_run_id }}
          github-token: ${{ secrets.GITHUB_TOKEN }}
      - run: terraform apply tf.plan

Production Release Pipeline

name: Production Release

on:
  push:
    tags:
      - 'v*'   # Triggered by a version tag, e.g.: v1.2.0

env:
  TF_LOG: INFO
  TF_INPUT: false
  TF_VERSION: ${{ vars.TF_VERSION }}
  ENVIRONMENTS: '["prodeast", "prodwest"]'

jobs:
  setup:
    name: Setup
    runs-on: ubuntu-latest
    outputs:
      environments: ${{ env.ENVIRONMENTS }}
    steps:
      - run: echo "Deploying to production - ${{ env.ENVIRONMENTS }}"

  get-run-id:
    name: Get CI Run ID
    runs-on: ubuntu-latest
    outputs:
      run_id: ${{ steps.get-run.outputs.run_id }}
    steps:
      - name: Get CI Workflow Run ID
        id: get-run
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          RUN_ID=$(gh run list \
            --repo ${{ github.repository }} \
            --workflow "Terraform CI" \
            --status success \
            --limit 1 \
            --json databaseId \
            --jq '.[0].databaseId')
          echo "run_id=$RUN_ID" >> $GITHUB_OUTPUT

  # Deploy in parallel to prod-east and prod-west
  deploy:
    name: Deploy ${{ matrix.environment }}
    needs: [setup, get-run-id]
    strategy:
      fail-fast: false
      matrix:
        environment: ${{ fromJson(needs.setup.outputs.environments) }}
    uses: ./.github/workflows/deploy-environment.yml
    with:
      environment: ${{ matrix.environment }}
      terraform_version: ${{ vars.TF_VERSION }}
      ci_run_id: ${{ needs.get-run-id.outputs.run_id }}
    secrets:
      AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
      AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
      AWS_REGION: ${{ secrets.AWS_REGION }}

Complete Promotion Flow

sequenceDiagram
    participant DEV as Developer
    participant GH as GitHub
    participant CI as CI Pipeline
    participant CD as CD Pipeline
    participant REL as Release Pipeline

    DEV->>GH: git push feature-branch
    DEV->>GH: Create Pull Request
    GH->>CI: Trigger CI workflow
    CI->>CI: fmt + validate + tflint + trivy
    CI->>CI: terraform plan (dev, staging, prod-east, prod-west)
    CI->>GH: Add plan summaries to PR comment
    DEV->>GH: Review + Merge PR
    GH->>CD: Trigger CD workflow
    CD->>CD: terraform apply → dev
    CD->>CD: terraform apply → staging
    DEV->>GH: git tag v1.2.0 && git push --tags
    GH->>REL: Trigger Release workflow
    REL-->>REL: terraform apply → prod-east (parallel)
    REL-->>REL: terraform apply → prod-west (parallel)

5. Module 4 — HCP Terraform for Automation

Introduction to HCP Terraform

HCP Terraform (HashiCorp Cloud Platform Terraform) is a SaaS platform that resolves the limitations of Terraform Community Edition:

Community LimitationHCP Terraform Solution
No centralized state managementManaged state backend, encrypted at rest and in transit
No access controlGranular RBAC per organization/project/workspace
No run history logsComplete run history
Credentials on each workstation/CICredentials centralized per workspace or project
Repeated setup for each configReusable workspaces with config inheritance

Two editions:

  • HCP Terraform — SaaS hosted by HashiCorp (free up to 500 resources)
  • Terraform Enterprise — Self-hosted, feature-equivalent

HCP Terraform Workspaces

Organizational Hierarchy

Organization
└── Project (taco-wagon-app)
    ├── Variable Sets (shared aws-creds)
    ├── Policies (Sentinel / OPA)
    └── Workspaces
        ├── taco-wagon-app-dev
        ├── taco-wagon-app-staging
        ├── taco-wagon-app-prodeast
        └── taco-wagon-app-prodwest

Three Workflow Types

WorkflowDescriptionUse Case
CLI WorkflowUses HCP Terraform as cloud backend, local commandsProgressive migration from Community
VCS WorkflowDirect integration with GitHub/GitLab/Bitbucket/Azure ReposAutomated pipeline triggered by PR/tag/commit
API-driven WorkflowTriggered by external APIIntegration with other automation systems

Cloud Backend Configuration

# terraform.tf — use HCP Terraform as backend
terraform {
  cloud {
    organization = "globomantics"

    workspaces {
      name = "taco-wagon-app-dev"
    }
  }

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~>6.0"
    }
  }
}

Migrating to HCP Terraform

flowchart LR
    subgraph BEFORE["Before — GitHub Actions only"]
        A1[PR created] --> A2[CI: fmt+validate+lint+scan]
        A2 --> A3[CI: terraform plan ×4 envs]
        A3 --> A4[Merge PR]
        A4 --> A5[CD: apply dev]
        A5 --> A6[CD: apply staging]
        A6 --> A7[Release tag]
        A7 --> A8[Release: apply prod ×2]
    end

    subgraph AFTER["After — HCP Terraform + GitHub Actions"]
        B1[PR created] --> B2[CI: fmt+validate+lint+scan]
        B2 --> B3[HCP: Speculative Plans ×4]
        B3 --> B4[Merge PR]
        B4 --> B5[HCP: apply dev workspace]
        B5 --> B6[HCP: apply staging\nvia Run Triggers]
        B6 --> B7[Release tag]
        B7 --> B8[HCP: apply prod-east\nHCP: apply prod-west\nvia tag trigger]
    end

Elements replaced by HCP Terraform:

  • Plan phase in ci.yml → Automatic Speculative Plans per workspace
  • cd.yml → Built-in automation + Run Triggers between workspaces
  • release.yml → VCS tag trigger on prod workspaces
  • manual_deploy.yml → Native workspace feature
  • manual_destroy.yml → Native workspace feature

What remains in GitHub Actions:

  • Code validation: terraform fmt, terraform validate, tflint, trivy

Implementation and Customization

Workspace Configuration per Environment

WorkspaceVCS TriggerRun Trigger
taco-wagon-app-devBranch-based (main)
taco-wagon-app-stagingNon-existent path (disabled)Runs after dev
taco-wagon-app-prodeastTag-based (v*)
taco-wagon-app-prodwestTag-based (v*)

Advantage of Run Trigger staging → dev: When a run completes successfully on dev, the staging workspace triggers automatically — no YAML workflow to maintain.

Variable Sets at the Project Level

AWS credentials are configured once in a Variable Set at the project level and are inherited by all workspaces:

Project: taco-wagon-app
└── Variable Set: aws-creds (scope: entire project)
    ├── AWS_ACCESS_KEY_ID  (env var, sensitive)
    └── AWS_SECRET_ACCESS_KEY  (env var, sensitive)

Sentinel Policy (policy-as-code example)

# Example Sentinel policy — require environment tag
import "tfplan/v2" as tfplan

# All AWS resources must have an "environment" tag
main = rule {
  all tfplan.resource_changes as _, rc {
    rc.type starts_with "aws_" and rc.change.actions contains "create" implies
      rc.change.after.tags["environment"] is not null
  }
}

Simplified CI with HCP Terraform (Module 4)

name: Terraform CI

on:
  pull_request:
    branches:
      - main

env:
  TF_LOG: INFO
  TF_INPUT: false
  TF_VERSION: ${{ vars.TF_VERSION }}
  ENVIRONMENTS: '["dev","staging","prodeast","prodwest"]'

jobs:
  setup:
    name: Setup
    runs-on: ubuntu-latest
    outputs:
      environments: ${{ env.ENVIRONMENTS }}
    steps:
      - run: echo "Environments - ${{ env.ENVIRONMENTS }}"

  # Only validation remains in GitHub Actions
  # Plans are now managed by HCP Terraform (Speculative Plans)
  format:
    name: Terraform Format
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: ${{ env.TF_VERSION }}
      - run: terraform fmt -check -recursive

  validate:
    name: Terraform Validate
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: ${{ env.TF_VERSION }}
      - run: terraform init -backend=false
      - run: terraform validate

  tflint:
    name: TFLint
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: terraform-linters/setup-tflint@v4
      - run: tflint --init
      - run: tflint --recursive

  trivy:
    name: Trivy Security Scan
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: aquasecurity/trivy-action@master
        with:
          scan-type: config
          scan-ref: .
          exit-code: 1
          severity: CRITICAL
  # Note: "plan" job removed — managed by HCP Terraform Speculative Plans

6. Architecture Diagrams

Terraform GitOps Pipeline — Global Overview

flowchart TD
    DEV[👨‍💻 Developer\nworkstation]
    
    subgraph LOCAL["Local"]
        HC[pre-commit hooks\nfmt + validate + tflint + docs]
    end

    subgraph GH["GitHub Repository"]
        FEAT[feature branch]
        MAIN[main branch]
        TAG[Release Tag v*]
    end

    subgraph CI["CI Pipeline\nGitHub Actions"]
        FMT[terraform fmt]
        VAL[terraform validate]
        LINT[tflint]
        SEC[trivy scan]
        PLAN[terraform plan ×4 envs]
    end

    subgraph CD["CD Pipeline\nGitHub Actions / HCP Terraform"]
        DEV_ENV[Apply → DEV]
        STG_ENV[Apply → STAGING]
    end

    subgraph PROD_PIPE["Release Pipeline"]
        PE[Apply → PROD-EAST]
        PW[Apply → PROD-WEST]
    end

    subgraph STATE["Remote State\nAWS S3"]
        S1[tw-dev.tfstate]
        S2[tw-staging.tfstate]
        S3[tw-prodeast.tfstate]
        S4[tw-prodwest.tfstate]
    end

    DEV --> HC
    HC --> FEAT
    FEAT -->|PR created| CI
    CI --> FMT & VAL & LINT & SEC
    FMT & VAL & LINT & SEC --> PLAN
    PLAN -->|PR comment| FEAT
    FEAT -->|Approved merge| MAIN
    MAIN --> CD
    CD --> DEV_ENV --> STG_ENV
    DEV_ENV --> S1
    STG_ENV --> S2
    MAIN -->|git tag v*| TAG
    TAG --> PROD_PIPE
    PE --> S3
    PW --> S4

PR → Plan → Apply Flow (Atlantis style)

sequenceDiagram
    actor DEV as Developer
    participant GIT as GitHub PR
    participant BOT as Atlantis / CI Bot
    participant TF as Terraform
    participant INFRA as AWS Infrastructure

    DEV->>GIT: git push + Create PR
    GIT->>BOT: Webhook: PR opened
    BOT->>TF: terraform init
    BOT->>TF: terraform plan
    TF-->>BOT: Plan output
    BOT->>GIT: Comment plan on the PR
    DEV->>GIT: Review plan
    DEV->>GIT: atlantis apply (or merge)
    GIT->>BOT: Webhook: apply request
    BOT->>TF: terraform apply
    TF->>INFRA: Provision resources
    INFRA-->>TF: Success
    TF-->>BOT: Apply output
    BOT->>GIT: Comment result
    DEV->>GIT: Merge PR

HCP Terraform Architecture with Drift Detection

flowchart LR
    subgraph HCP["HCP Terraform"]
        subgraph ORG["Organization: globomantics"]
            subgraph PROJ["Project: taco-wagon-app"]
                VS[Variable Set\naws-creds]
                WS_DEV[Workspace: dev\nBranch trigger: main]
                WS_STG[Workspace: staging\nRun trigger: ← dev]
                WS_PE[Workspace: prod-east\nTag trigger: v*]
                WS_PW[Workspace: prod-west\nTag trigger: v*]
            end
        end
        DRIFT[Drift Detection\nPeriodic planning]
        SENTINEL[Policy Engine\nSentinel / OPA]
    end

    GH[GitHub Repository] -->|VCS Integration| WS_DEV
    WS_DEV -->|Run Trigger| WS_STG
    GH -->|Tag v*| WS_PE & WS_PW
    
    DRIFT -.->|Detects diffs| WS_DEV & WS_STG & WS_PE & WS_PW
    SENTINEL -->|Enforce policies| WS_DEV & WS_STG & WS_PE & WS_PW

    WS_DEV --> AWS_DEV[(AWS DEV\nState)]
    WS_STG --> AWS_STG[(AWS STAGING\nState)]
    WS_PE --> AWS_PE[(AWS PROD-EAST\nState)]
    WS_PW --> AWS_PW[(AWS PROD-WEST\nState)]

Drift Detection

Drift is the divergence between the desired state (Terraform code) and the actual state of the infrastructure. It can occur when changes are made manually outside of Terraform.

flowchart TD
    DESIRED[Desired state\nmain.tf + state] 
    ACTUAL[Actual state\nAWS Infrastructure]
    
    DESIRED -->|terraform plan| COMPARE{Diff?}
    ACTUAL --> COMPARE
    
    COMPARE -->|No diff| OK[✅ No drift]
    COMPARE -->|Diff detected| DRIFT[⚠️ Drift detected!]
    
    DRIFT --> NOTIFY[Notification / Alert]
    NOTIFY --> ACTION{Action}
    ACTION -->|Fix infrastructure| APPLY[terraform apply]
    ACTION -->|Fix code| UPDATE[Update main.tf]
    ACTION -->|Ignore| IGNORE[terraform import or lifecycle ignore]

Drift detection strategies:

  • HCP Terraform: Automatic periodic planning per workspace
  • GitHub Actions: Scheduled job (schedule: cron) that runs terraform plan and alerts on detected changes
  • atlantis: Drift checks via webhooks and automatic runs

7. Reference — Terraform Automation Tools

Local Validation Tools

ToolCommandDescription
terraform fmtterraform fmt -recursiveFormats .tf files according to canonical HCL style
terraform validateterraform validateChecks syntax and logical consistency
TFLinttflint --recursiveAdvanced linting with provider-specific rules
terraform-docsterraform-docs markdown table .Generates documentation from variables/outputs

Security Analysis Tools

ToolPublisherFocus
TrivyAqua SecurityIaC misconfigurations, CVEs, secrets
CheckovBridgecrew/Palo AltoIaC security policies
KICSCheckmarxMulti-IaC (Terraform, K8s, Docker, etc.)
tfsecAqua SecurityTerraform security scanning (legacy → Trivy)

Planning and Summary Tools

ToolRole
tf-summarizeHuman-readable (Markdown) Terraform plan summary
terraform show -jsonExport plan in JSON format for programmatic analysis
infracostInfrastructure change cost estimation
terragruntTerraform wrapper for DRY configs and multi-module orchestration

IaC Automation Platforms

PlatformTypeVCS IntegrationPolicy EngineState Management
HCP TerraformSaaS (HashiCorp)✅ GitHub/GitLab/Bitbucket/Azure✅ Sentinel / OPA✅ Built-in
Terraform EnterpriseSelf-hosted (HashiCorp)✅ Sentinel / OPA
AtlantisSelf-hosted (Open source)✅ GitHub/GitLab/Bitbucket❌ (via hooks)❌ (external)
SpaceliftSaaS✅ OPA
Env0SaaS✅ OPA
ScalrSaaS / Self-hosted✅ OPA
GitHub ActionsGeneric CI/CD✅ Native GitHubVia plugins❌ (external S3/GCS)

Terraform Environment Variables

VariableExample ValueEffect
TF_IN_AUTOMATIONtrueReduces interactive output for pipelines
TF_LOGINFO, DEBUG, WARN, ERRORLog verbosity level
TF_INPUTfalseDisables interactive prompts
TF_PLUGIN_CACHE_DIR/opt/tf-cacheLocal provider plugin cache
TF_VAR_<name>TF_VAR_region=us-east-1Defines a Terraform variable via env var
TF_CLI_ARGS_plan-parallelism=30Default CLI arguments for a sub-command

8. Project File Structure

Base Terraform Configuration (taco-wagon-app)

main.tf

# VPC Resources
data "aws_availability_zones" "available" {
  state = "available"
}

module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "6.4.0"

  name                    = "${var.prefix}-${var.environment}-vpc"
  cidr                    = var.vpc_address_range
  azs                     = slice(data.aws_availability_zones.available.names, 0, length(var.vpc_public_subnet_ranges))
  public_subnets          = var.vpc_public_subnet_ranges
  enable_nat_gateway      = false
  enable_vpn_gateway      = false
  enable_dns_hostnames    = true
  map_public_ip_on_launch = true
}

variables.tf

variable "environment" {
  type        = string
  description = "(Required) Environment of all resources"
}

variable "prefix" {
  type        = string
  description = "(Required) Prefix to use for all resources in this module."
}

variable "region" {
  type        = string
  description = "(Optional) AWS Region to deploy in. Defaults to us-west-2."
  default     = "us-west-2"
}

variable "vpc_address_range" {
  type        = string
  description = "Address range for the VPC"
}

variable "vpc_public_subnet_ranges" {
  type        = list(string)
  description = "List of public subnet CIDR ranges for the VPC"
}

providers.tf

provider "aws" {
  region = var.region

  default_tags {
    tags = {
      owner       = "taco-wagon-team"
      environment = var.environment
    }
  }
}

terraform.tf

terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~>6.0"
    }
  }

  backend "s3" {
    # Use partial backend: terraform init -backend-config="./backends/dev.hcl"
  }
}

backends/dev.hcl

key    = "tw-dev.tfstate"
bucket = "my-terraform-state-bucket"
region = "us-east-1"

Complete Workflow Commands

# ─── LOCAL SETUP ───────────────────────────────────────────────
# Install pre-commit
pip install pre-commit
pre-commit install

# Initialize Terraform for the dev environment
terraform init -backend-config="./backends/dev.hcl"

# ─── DEVELOPMENT ───────────────────────────────────────────────
# Create a new feature branch
git checkout -b feature/add-new-subnet

# Make changes...
# Format and validate locally
terraform fmt -recursive
terraform validate
tflint --recursive

# Commit (triggers pre-commit hooks)
git add .
git commit -m "feat: add new subnet configuration"
git push origin feature/add-new-subnet

# ─── CI/CD (automatic via GitHub Actions) ──────────────────────
# PR created → CI starts automatically
# Plan generated → comment added to PR

# ─── MERGE AND DEPLOYMENT ──────────────────────────────────────
# Merge via GitHub UI → CD starts automatically
# DEV → STAGING (sequential)

# ─── PRODUCTION RELEASE ────────────────────────────────────────
git tag v1.2.0
git push --tags
# Release pipeline starts → PROD-EAST || PROD-WEST (parallel)

# ─── USEFUL TERRAFORM COMMANDS ─────────────────────────────────
# Plan with tfvars
terraform plan -var-file="./environments/dev.tfvars" -out=tf.plan

# Apply from a saved plan
terraform apply tf.plan

# Export plan as JSON for analysis
terraform show -json tf.plan > plan.json

# Destroy an environment
terraform destroy -var-file="./environments/dev.tfvars"

# Workspace management
terraform workspace new staging
terraform workspace list
terraform workspace select dev

Search Terms

terraform · automation · gitops · infrastructure · ci/cd · devops · hcp · configuration · pipeline · tools · workflow · environment · github · architecture · backend · detection · drift · environments · flow · iac · objectives · planning · validation · workflows

Interested in this course?

Contact us to book it or get a custom training plan for your team.