Part 3: Multi-Environment Promotion, Drift Detection, and Policy-as-Code in Terraform Cloud

AuthorEmmanuel Secretaria

Published Jan 11, 2026

A comprehensive guide to promoting Terraform changes across environments, scheduling drift detection, and enforcing policy-as-code in Terraform Cloud using workspace design, CI pipelines, and Sentinel or OPA guardrails.

Share

If Part 1 introduced the Terraform tooling in this repo and Part 2 built a full CI pipeline. In Part 3 we take it to a production-grade operating model inside Terraform Cloud by adding:

  • Multi-environment promotion (dev → staging → prod)
  • Drift detection schedules (recurring plans with alerts)
  • Policy-as-code enforcement (Sentinel or OPA guardrails)

This is a complete, detailed playbook for turning Terraform Cloud into a safe, auditable release system rather than just a state backend.


1) Multi-Environment Promotion: The Core Model

The canonical Terraform Cloud pattern is one workspace per environment (dev, staging, prod). The promotion flow is:

  1. Develop and test in dev
  2. Promote to staging after approval
  3. Promote to prod with stricter policy + manual approval

1.1 Workspace structure and naming

A clear workspace naming scheme makes automation predictable:

  • myapp-dev
  • myapp-staging
  • myapp-prod

If you run multiple regions or tenants, expand the suffix:

  • myapp-dev-us-east-1
  • myapp-prod-eu-west-1

This makes it trivial to enumerate and update using the repo’s helper scripts:

terraform/terraform_cloud_workspaces.sh
terraform/terraform_cloud_workspace_vars.sh <workspace_id>

1.2 Environment-specific variables

Use workspace variables for environment-specific values, and variable sets for shared values.

Workspace vars (environment-specific):

terraform/terraform_cloud_workspace_set_vars.sh <workspace_id> \
  environment=dev \
  replica_count=1 \
  enable_deletion_protection=false

Variable sets (shared across environments):

terraform/terraform_cloud_varset_set_vars.sh <varset_id> \
  TF_LOG=ERROR \
  LOG_LEVEL=info

This prevents drift in shared configuration while still allowing per-env tuning.

1.3 Promotion paths: three viable patterns

There are three common promotion models. Pick the one that fits your org.

Pattern A: Branch-based promotion (simple + intuitive)

  • main
    → dev workspace
  • release/staging
    → staging workspace
  • release/prod
    → prod workspace

This works well when you treat Git branches as promotion gates.

Pattern B: Tag-based promotion (audit-friendly)

  • v1.2.3-dev
    tag → dev
  • v1.2.3-rc
    tag → staging
  • v1.2.3
    tag → prod

This provides immutable promotion artifacts, which are easier to audit.

Pattern C: Artifact promotion (advanced)

You generate a plan (or module package) in dev and reuse the artifact for staging/prod. This keeps the plan deterministic but needs more tooling.


2) Promotion Pipeline in GitHub Actions (Terraform Cloud as State + Execution)

Below is a practical pipeline to promote from dev to staging to prod. It uses environment approvals plus Terraform Cloud for state and audit.

name: terraform-promotion

on:
  push:
    branches: [ main ]

env:
  TF_IN_AUTOMATION: "true"
  TF_INPUT: "false"
  TERRAFORM_ORGANIZATION: my-org

jobs:
  plan-dev:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Install Terraform
        run: install/install_terraform.sh
      - name: Terraform login (Terraform Cloud)
        env:
          TERRAFORM_TOKEN: ${{ secrets.TERRAFORM_TOKEN }}
        run: |
          mkdir -p ~/.terraform.d
          cat <<EOF > ~/.terraform.d/credentials.tfrc.json
          {
            "credentials": {
              "app.terraform.io": {
                "token": "${TERRAFORM_TOKEN}"
              }
            }
          }
          EOF
      - name: Plan (dev)
        env:
          TERRAFORM_WORKSPACE: myapp-dev
        run: |
          terraform init
          terraform plan -out=tfplan

  apply-dev:
    runs-on: ubuntu-latest
    needs: plan-dev
    steps:
      - uses: actions/checkout@v4
      - name: Install Terraform
        run: install/install_terraform.sh
      - name: Terraform login (Terraform Cloud)
        env:
          TERRAFORM_TOKEN: ${{ secrets.TERRAFORM_TOKEN }}
        run: |
          mkdir -p ~/.terraform.d
          cat <<EOF > ~/.terraform.d/credentials.tfrc.json
          {
            "credentials": {
              "app.terraform.io": {
                "token": "${TERRAFORM_TOKEN}"
              }
            }
          }
          EOF
      - name: Apply (dev)
        env:
          TERRAFORM_WORKSPACE: myapp-dev
        run: |
          terraform init
          terraform apply -auto-approve tfplan

  plan-staging:
    runs-on: ubuntu-latest
    needs: apply-dev
    steps:
      - uses: actions/checkout@v4
      - name: Install Terraform
        run: install/install_terraform.sh
      - name: Terraform login (Terraform Cloud)
        env:
          TERRAFORM_TOKEN: ${{ secrets.TERRAFORM_TOKEN }}
        run: |
          mkdir -p ~/.terraform.d
          cat <<EOF > ~/.terraform.d/credentials.tfrc.json
          {
            "credentials": {
              "app.terraform.io": {
                "token": "${TERRAFORM_TOKEN}"
              }
            }
          }
          EOF
      - name: Plan (staging)
        env:
          TERRAFORM_WORKSPACE: myapp-staging
        run: |
          terraform init
          terraform plan -out=tfplan

  apply-staging:
    runs-on: ubuntu-latest
    needs: plan-staging
    environment:
      name: staging
    steps:
      - uses: actions/checkout@v4
      - name: Install Terraform
        run: install/install_terraform.sh
      - name: Terraform login (Terraform Cloud)
        env:
          TERRAFORM_TOKEN: ${{ secrets.TERRAFORM_TOKEN }}
        run: |
          mkdir -p ~/.terraform.d
          cat <<EOF > ~/.terraform.d/credentials.tfrc.json
          {
            "credentials": {
              "app.terraform.io": {
                "token": "${TERRAFORM_TOKEN}"
              }
            }
          }
          EOF
      - name: Apply (staging)
        env:
          TERRAFORM_WORKSPACE: myapp-staging
        run: |
          terraform init
          terraform apply -auto-approve tfplan

  plan-prod:
    runs-on: ubuntu-latest
    needs: apply-staging
    steps:
      - uses: actions/checkout@v4
      - name: Install Terraform
        run: install/install_terraform.sh
      - name: Terraform login (Terraform Cloud)
        env:
          TERRAFORM_TOKEN: ${{ secrets.TERRAFORM_TOKEN }}
        run: |
          mkdir -p ~/.terraform.d
          cat <<EOF > ~/.terraform.d/credentials.tfrc.json
          {
            "credentials": {
              "app.terraform.io": {
                "token": "${TERRAFORM_TOKEN}"
              }
            }
          }
          EOF
      - name: Plan (prod)
        env:
          TERRAFORM_WORKSPACE: myapp-prod
        run: |
          terraform init
          terraform plan -out=tfplan

  apply-prod:
    runs-on: ubuntu-latest
    needs: plan-prod
    environment:
      name: production
    steps:
      - uses: actions/checkout@v4
      - name: Install Terraform
        run: install/install_terraform.sh
      - name: Terraform login (Terraform Cloud)
        env:
          TERRAFORM_TOKEN: ${{ secrets.TERRAFORM_TOKEN }}
        run: |
          mkdir -p ~/.terraform.d
          cat <<EOF > ~/.terraform.d/credentials.tfrc.json
          {
            "credentials": {
              "app.terraform.io": {
                "token": "${TERRAFORM_TOKEN}"
              }
            }
          }
          EOF
      - name: Apply (prod)
        env:
          TERRAFORM_WORKSPACE: myapp-prod
        run: |
          terraform init
          terraform apply -auto-approve tfplan

Why this works

  • Workspaces isolate environments while keeping code identical.
  • Promotion uses GitHub environment approvals for staging/prod.
  • Terraform Cloud keeps state, run history, and audit logs.

3) Drift Detection Schedules: Catching Changes Outside Terraform

Drift happens when something changes outside Terraform (manual changes, auto-scaling, provider defaults). Drift detection should be continuous, automated, and visible.

3.1 The simplest schedule: periodic plan-only runs

Set up a scheduled plan using either:

  • Terraform Cloud UI (workspace settings → Runs → Schedule)
  • CI cron + API (queue a plan on a schedule)

A basic GitHub Actions cron approach:

name: terraform-drift-check

on:
  schedule:
    - cron: "0 3 * * *" # daily at 03:00 UTC

jobs:
  drift:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Queue drift plan
        env:
          TERRAFORM_TOKEN: ${{ secrets.TERRAFORM_TOKEN }}
          TERRAFORM_ORGANIZATION: my-org
          TERRAFORM_WORKSPACE: myapp-prod
        run: |
          payload='{
            "data": {
              "attributes": {
                "message": "drift detection (scheduled)"
              },
              "type": "runs",
              "relationships": {
                "workspace": {
                  "data": {
                    "type": "workspaces",
                    "id": "'${TERRAFORM_WORKSPACE}'"
                  }
                }
              }
            }
          }'
          terraform/terraform_cloud_api.sh /runs -X POST -d "$payload" | jq .

This queues a remote plan-only run in Terraform Cloud. You can then:

  • alert on non-empty plans
  • send notifications to Slack/email
  • block promotion if drift exists

3.2 Drift as a policy gate

Treat drift like a failing test. If a scheduled run finds changes, you can:

  • auto-open a ticket
  • block prod applies until drift is reconciled
  • require manual review

This keeps your infrastructure consistent and prevents surprises.

3.3 Workspace-level visibility

Terraform Cloud shows scheduled runs in the workspace history. This provides an audit trail and a forensic timeline of drift events.


4) Policy-as-Code Enforcement in Terraform Cloud

Policies are how you guarantee guardrails, regardless of who triggers the run. In Terraform Cloud, policies apply to all runs in a workspace—CI, CLI, or UI.

4.1 Sentinel policies (Terraform Cloud native)

Sentinel is Terraform Cloud’s built-in policy language. A simple example that blocks public S3 buckets might look like this:

import "tfplan/v2" as tfplan

public_buckets = filter tfplan.resource_changes as _, rc {
  rc.type is "aws_s3_bucket" and
  rc.change.after.acl is "public-read"
}

main = rule {
  length(public_buckets) is 0
}

Apply this policy to prod workspaces only using a policy set.

4.2 OPA (Open Policy Agent)

If you already use OPA or want Rego-based policies, Terraform Cloud supports it as a policy framework. This is useful when you want to share policy logic across Terraform and Kubernetes.

Example OPA rule (conceptual):

package terraform

deny[msg] {
  input.resource_changes[_].type == "aws_iam_user"
  msg := "IAM users must be provisioned via SSO"
}

4.3 Organizing policy sets by environment

A strong approach is:

  • Global policies: security baselines (no public buckets, encryption required)
  • Staging policies: warnings-only or soft fail
  • Production policies: hard fail + manual approval

Terraform Cloud lets you assign policy sets by workspace or tag. This supports per-environment enforcement without duplicating logic.

4.4 Guardrails for promotion pipelines

Use policies to enforce:

  • No destructive changes in prod (block if
    destroy
    detected)
  • Tagging requirements (every resource must include
    owner
    ,
    env
    ,
    cost_center
    )
  • Region constraints (production only in allowed regions)

Policies are enforced before apply, which is the ideal time to block risky or non-compliant changes.


5) Putting It All Together: A Mature Terraform Cloud Operating Model

When you combine promotion, drift schedules, and policy-as-code, you get:

✅ predictable releases across environments
✅ continuous drift detection
✅ automated security and compliance enforcement
✅ complete audit trails in Terraform Cloud

The result is a Terraform system that behaves like a real infrastructure delivery platform rather than a set of scripts.


Final Thoughts

Part 3 completes the Terraform Cloud production journey. You now have:

  • Promotion workflows that scale across environments
  • Scheduled drift detection to catch out-of-band changes
  • Policy-as-code guardrails to enforce compliance

The next evolution is run-task integrations, module registries, and automated cost governance.

Credits

Special thanks to Hari Sekhon https://github.com/HariSekhon for creating and maintaining the repository, and to all contributors for their valuable contributions.