Skip to main content
Terraform CI/CD Pipeline with GitLab: Plan on MR, Apply on Merge

Terraform CI/CD Pipeline with GitLab: Plan on MR, Apply on Merge

Key Takeaway

Build a Terraform CI/CD pipeline in GitLab CI. Automated terraform plan on merge requests, terraform apply on merge to main, with remote state, caching, and approval gates.

Table of Contents

What We’re Building

A GitLab CI/CD pipeline that:

  1. Merge Request openedterraform plan runs automatically, posts the plan as a comment
  2. MR merged to mainterraform apply runs automatically
  3. Manual triggerterraform destroy (protected)
feature-branch → MR → plan → review → merge → apply → infrastructure updated

Complete .gitlab-ci.yml

image:
  name: hashicorp/terraform:1.9
  entrypoint: [""]

variables:
  TF_ROOT: ${CI_PROJECT_DIR}
  TF_STATE_NAME: default

cache:
  key: terraform-providers
  paths:
    - ${TF_ROOT}/.terraform/

stages:
  - validate
  - plan
  - apply
  - destroy

# ──────────────────────────────────────
# Validate: fmt check + validate
# ──────────────────────────────────────
validate:
  stage: validate
  script:
    - cd ${TF_ROOT}
    - terraform init -backend=false
    - terraform fmt -check -recursive
    - terraform validate
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
    - if: $CI_COMMIT_BRANCH == "main"

# ──────────────────────────────────────
# Plan: runs on MRs and main
# ──────────────────────────────────────
plan:
  stage: plan
  script:
    - cd ${TF_ROOT}
    - terraform init
    - terraform plan -out=plan.tfplan -input=false
    - terraform show -no-color plan.tfplan > plan.txt
  artifacts:
    paths:
      - ${TF_ROOT}/plan.tfplan
      - ${TF_ROOT}/plan.txt
    reports:
      terraform: ${TF_ROOT}/plan.json
    expire_in: 7 days
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
    - if: $CI_COMMIT_BRANCH == "main"

# ──────────────────────────────────────
# Apply: only on main, after plan
# ──────────────────────────────────────
apply:
  stage: apply
  script:
    - cd ${TF_ROOT}
    - terraform init
    - terraform apply -input=false plan.tfplan
  dependencies:
    - plan
  rules:
    - if: $CI_COMMIT_BRANCH == "main"
      when: manual    # Remove 'when: manual' for fully automated apply
  environment:
    name: production

# ──────────────────────────────────────
# Destroy: manual only, protected
# ──────────────────────────────────────
destroy:
  stage: destroy
  script:
    - cd ${TF_ROOT}
    - terraform init
    - terraform destroy -auto-approve -input=false
  rules:
    - if: $CI_COMMIT_BRANCH == "main"
      when: manual
      allow_failure: true
  environment:
    name: production
    action: stop

Step-by-Step Explanation

1. Set Up Remote State

Use GitLab-managed Terraform state (built in) or S3:

GitLab Managed State (simplest):

terraform {
  backend "http" {}
}

GitLab automatically configures the HTTP backend via CI variables.

S3 Backend:

terraform {
  backend "s3" {
    bucket         = "my-terraform-state"
    key            = "project/terraform.tfstate"
    region         = "us-east-1"
    dynamodb_table = "terraform-locks"
    encrypt        = true
  }
}

2. Configure CI/CD Variables

In GitLab → Settings → CI/CD → Variables, add:

VariableValueProtectedMasked
AWS_ACCESS_KEY_IDYour key
AWS_SECRET_ACCESS_KEYYour secret
AWS_DEFAULT_REGIONus-east-1

Protected = only available on protected branches (main). This prevents feature branches from applying changes.

For Azure:

VariableValue
ARM_CLIENT_IDService principal ID
ARM_CLIENT_SECRETService principal secret
ARM_SUBSCRIPTION_IDSubscription ID
ARM_TENANT_IDTenant ID

3. Merge Request Integration

GitLab can show the Terraform plan directly in the MR. Add to the plan job:

plan:
  stage: plan
  script:
    - cd ${TF_ROOT}
    - terraform init
    - terraform plan -out=plan.tfplan -input=false
    - terraform show -json plan.tfplan > plan.json
    - terraform show -no-color plan.tfplan > plan.txt
  artifacts:
    reports:
      terraform: ${TF_ROOT}/plan.json

The terraform report type enables GitLab’s built-in Terraform plan widget in the MR UI.

4. Provider Caching

Cache downloaded providers to speed up pipelines:

cache:
  key: terraform-${CI_COMMIT_REF_SLUG}
  paths:
    - ${TF_ROOT}/.terraform/providers/

This saves 10-30 seconds on each job.

Multi-Environment Pipeline

For dev/staging/prod with workspaces or directories:

stages:
  - validate
  - plan
  - deploy-dev
  - deploy-staging
  - deploy-prod

.terraform_base: &terraform_base
  image:
    name: hashicorp/terraform:1.9
    entrypoint: [""]
  cache:
    key: terraform-providers
    paths:
      - .terraform/

plan-dev:
  <<: *terraform_base
  stage: plan
  script:
    - terraform init
    - terraform workspace select dev || terraform workspace new dev
    - terraform plan -out=dev.tfplan -input=false
  artifacts:
    paths:
      - dev.tfplan
  rules:
    - if: $CI_COMMIT_BRANCH == "main"

apply-dev:
  <<: *terraform_base
  stage: deploy-dev
  script:
    - terraform init
    - terraform workspace select dev
    - terraform apply -input=false dev.tfplan
  dependencies:
    - plan-dev
  rules:
    - if: $CI_COMMIT_BRANCH == "main"
  environment:
    name: dev

apply-staging:
  <<: *terraform_base
  stage: deploy-staging
  script:
    - terraform init
    - terraform workspace select staging || terraform workspace new staging
    - terraform plan -out=staging.tfplan -input=false
    - terraform apply -input=false staging.tfplan
  rules:
    - if: $CI_COMMIT_BRANCH == "main"
      when: manual
  environment:
    name: staging

apply-prod:
  <<: *terraform_base
  stage: deploy-prod
  script:
    - terraform init
    - terraform workspace select prod || terraform workspace new prod
    - terraform plan -out=prod.tfplan -input=false
    - terraform apply -input=false prod.tfplan
  rules:
    - if: $CI_COMMIT_BRANCH == "main"
      when: manual
  environment:
    name: production

Security Best Practices

Never Store Secrets in Code

# BAD — secret in .gitlab-ci.yml
variables:
  AWS_SECRET_ACCESS_KEY: "AKIAIOSFODNN7EXAMPLE"

# GOOD — use CI/CD variables (Settings → CI/CD → Variables)
# The variable is injected automatically

Use Protected Branches

  • Only main should trigger terraform apply
  • Set main as a protected branch in GitLab
  • Mark sensitive CI/CD variables as “Protected”

Use OIDC Instead of Long-Lived Keys

plan:
  id_tokens:
    GITLAB_OIDC_TOKEN:
      aud: https://gitlab.com
  script:
    - >
      export $(printf "AWS_ACCESS_KEY_ID=%s AWS_SECRET_ACCESS_KEY=%s AWS_SESSION_TOKEN=%s"
      $(aws sts assume-role-with-web-identity
      --role-arn ${ROLE_ARN}
      --role-session-name "GitLabRunner-${CI_PROJECT_ID}-${CI_PIPELINE_ID}"
      --web-identity-token ${GITLAB_OIDC_TOKEN}
      --query "Credentials.[AccessKeyId,SecretAccessKey,SessionToken]"
      --output text))      
    - terraform init
    - terraform plan

OIDC tokens expire automatically — no long-lived secrets to rotate.

Lock File in Version Control

Always commit .terraform.lock.hcl:

validate:
  script:
    - terraform init
    - terraform providers lock -platform=linux_amd64
    # Fails if lock file is out of date

Common Pipeline Errors

“Backend configuration changed”

Error: Backend configuration changed

Run terraform init -reconfigure or terraform init -migrate-state.

“Error acquiring the state lock”

Someone else is running Terraform, or a previous run crashed:

terraform force-unlock LOCK_ID

“No changes. Infrastructure is up-to-date.”

Not an error — the plan found nothing to change. Your pipeline should handle this gracefully (exit code 0).

Plan artifact expired

If the plan artifact expired before applying, re-run the pipeline. Set longer expiry:

artifacts:
  expire_in: 30 days

Hands-On Courses

Learn by doing with interactive courses on CopyPasteLearn:

Conclusion

Plan on merge request, apply on merge to main. Use GitLab CI/CD variables for secrets (never in code), cache providers for speed, and use the terraform artifact report for MR plan widgets. For multi-environment, use workspaces or separate jobs per environment with manual approval gates for staging and prod.

🚀

Level Up Your Terraform Skills

Hands-on courses, books, and resources from Luca Berton

Luca Berton
Written by

Luca Berton

DevOps Engineer, AWS Partner, Terraform expert, and author. Creator of Ansible Pilot, Terraform Pilot, and CopyPasteLearn.