Managing Multiple Environments in Terraform

How do I make it so that my Terraform code can deploy to multiple environments without copy-pasting or using a complex folder structure like Terragrunt—and how can we address the small differences between each environment?

A little while back I had some ideas about doing this in a super-generic way, with the additional requirements of:

  • Only having one set of AWS keys
    
  • Using GitLab CI/CD
    
  • Keeping the CI/CD YAML as simple as possible
    

Okay, that’s a lot—but the solution is surprisingly simple.

The idea is to link Git branches to AWS accounts and split up your state files with Terraform workspaces. Sounds good—so how do we handle authentication?

variable "workspace_iam_roles" {
  default = {
    default = "arn:aws:iam::111111111111:role/terraform-sa-role"
  }
}

provider "aws" {
  region = var.region

  assume_role {
    role_arn = var.workspace_iam_roles[terraform.workspace]
  }
}

In providers.tf this defines the workspace-to-account relationship. Here we only have one entry, default, which is linked to account 111111111111 via an assumed role called terraform-sa-role. We use the built-in terraform.workspace variable to pick the right role for each environment.


Adding More Environments

variable "workspace_iam_roles" {
  default = {
    default = "arn:aws:iam::111111111111:role/terraform-sa-role"
    dev     = "arn:aws:iam::222222222222:role/terraform-sa-role"
    preprod = "arn:aws:iam::333333333333:role/terraform-sa-role"
    staging = "arn:aws:iam::333333333333:role/terraform-sa-role"
  }
}

By switching our Terraform workspace we tell Terraform which role—and therefore which account—to use. In this example preprod and staging share the same account.


Auto-Selecting Workspace in GitLab CI

Switching workspaces manually is a pain—so let’s couple them to branches. When you’re on preprod, CI will automatically select the preprod workspace (and its role).

before_script:
  - terraform init
  - |
      # Map master → default, otherwise use branch name
      if [ "$CI_COMMIT_REF_SLUG" = "master" ]; then
        WORKSPACE="default"
      else
        WORKSPACE="$CI_COMMIT_REF_SLUG"
      fi
      terraform workspace new $WORKSPACE || true
      terraform workspace select $WORKSPACE

Now CI_COMMIT_REF_SLUG drives both your workspace and AWS account.


Handling Environment-Specific Differences

Use *.tfvars files:

vars/
  default.tfvars
  dev.tfvars
  preprod.tfvars
  production.tfvars

Then in your plan job:

tf-plan:
  stage: tf-plan
  script:
    - terraform plan \
        -var-file="vars/${WORKSPACE}.tfvars" \
        -out=plan.tfplan
  artifacts:
    paths:
      - plan.tfplan

Each file sets the small differences—like desired_count in an Auto Scaling Group—so production can use 40 hosts, preprod only 1, and so on.


CI Authentication Flow

  1. CI AWS Keys (stored in GitLab CI variables) can assume any role in workspace_iam_roles and read/write the remote state in S3.
  2. terraform init uses those keys to initialize the backend (S3).
  3. During apply, the AWS provider block assumes the per-workspace IAM role to make changes in the target account.

Local Workflow with Atmos
For the same convenience locally, check out Atmos (https://github.com/Spengreb/atmos) small Python wrapper that:

  • Manages AWS credentials and role assumption
  • Mirrors the CI branch→workspace mapping
  • Lets you run Terraform locally without extra flags