What We’re Building
A GitLab CI/CD pipeline that:
- Merge Request opened →
terraform planruns automatically, posts the plan as a comment - MR merged to main →
terraform applyruns automatically - Manual trigger →
terraform 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:
| Variable | Value | Protected | Masked |
|---|---|---|---|
AWS_ACCESS_KEY_ID | Your key | ✅ | ✅ |
AWS_SECRET_ACCESS_KEY | Your secret | ✅ | ✅ |
AWS_DEFAULT_REGION | us-east-1 | ✅ | ❌ |
Protected = only available on protected branches (main). This prevents feature branches from applying changes.
For Azure:
| Variable | Value |
|---|---|
ARM_CLIENT_ID | Service principal ID |
ARM_CLIENT_SECRET | Service principal secret |
ARM_SUBSCRIPTION_ID | Subscription ID |
ARM_TENANT_ID | Tenant 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
mainshould triggerterraform apply - Set
mainas 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.




