TerraformPilot

Terraform

Terraform CI/CD with GitLab CI - Complete Pipeline

Automate Terraform with GitLab CI/CD. Plan on merge requests, apply on main, remote state with HTTP backend, and environment-specific pipelines.

LLuca Berton1 min read

Quick Answer

#
# .gitlab-ci.yml
stages:
  - validate
  - plan
  - apply
 
image:
  name: hashicorp/terraform:1.8
  entrypoint: [""]
 
variables:
  TF_ROOT: ${CI_PROJECT_DIR}
 
before_script:
  - cd ${TF_ROOT}
  - terraform init
 
validate:
  stage: validate
  script:
    - terraform validate
    - terraform fmt -check
 
plan:
  stage: plan
  script:
    - terraform plan -out=tfplan
  artifacts:
    paths:
      - tfplan
 
apply:
  stage: apply
  script:
    - terraform apply -auto-approve tfplan
  dependencies:
    - plan
  rules:
    - if: $CI_COMMIT_BRANCH == "main"
      when: manual

Production Pipeline

#
stages:
  - validate
  - plan
  - apply
 
image:
  name: hashicorp/terraform:1.8
  entrypoint: [""]
 
variables:
  TF_ROOT: ${CI_PROJECT_DIR}/environments/${CI_ENVIRONMENT_NAME:-dev}
  TF_IN_AUTOMATION: "true"
 
cache:
  key: ${CI_COMMIT_REF_SLUG}
  paths:
    - ${TF_ROOT}/.terraform/
 
.terraform_base:
  before_script:
    - cd ${TF_ROOT}
    - terraform init -backend-config="address=${TF_STATE_ADDRESS}"
      -backend-config="lock_address=${TF_STATE_ADDRESS}/lock"
      -backend-config="unlock_address=${TF_STATE_ADDRESS}/lock"
      -backend-config="username=gitlab-ci-token"
      -backend-config="password=${CI_JOB_TOKEN}"
 
validate:
  extends: .terraform_base
  stage: validate
  script:
    - terraform validate
    - terraform fmt -check -recursive
 
plan:dev:
  extends: .terraform_base
  stage: plan
  environment:
    name: dev
  variables:
    TF_STATE_ADDRESS: "${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/terraform/state/dev"
  script:
    - terraform plan -out=tfplan -var-file=dev.tfvars
  artifacts:
    paths:
      - ${TF_ROOT}/tfplan
    expire_in: 1 week
  rules:
    - if: $CI_MERGE_REQUEST_IID
    - if: $CI_COMMIT_BRANCH == "main"
 
apply:dev:
  extends: .terraform_base
  stage: apply
  environment:
    name: dev
  variables:
    TF_STATE_ADDRESS: "${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/terraform/state/dev"
  script:
    - terraform apply -auto-approve tfplan
  dependencies:
    - plan:dev
  rules:
    - if: $CI_COMMIT_BRANCH == "main"
      when: manual
 
plan:prod:
  extends: .terraform_base
  stage: plan
  environment:
    name: production
  variables:
    TF_STATE_ADDRESS: "${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/terraform/state/prod"
  script:
    - terraform plan -out=tfplan -var-file=prod.tfvars
  artifacts:
    paths:
      - ${TF_ROOT}/tfplan
  rules:
    - if: $CI_COMMIT_BRANCH == "main"
 
apply:prod:
  extends: .terraform_base
  stage: apply
  environment:
    name: production
  variables:
    TF_STATE_ADDRESS: "${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/terraform/state/prod"
  script:
    - terraform apply -auto-approve tfplan
  dependencies:
    - plan:prod
  rules:
    - if: $CI_COMMIT_BRANCH == "main"
      when: manual

GitLab Managed Terraform State

#
# Use GitLab's built-in HTTP backend — no S3 needed
terraform {
  backend "http" {}
}
# State is managed at:
# Settings → Infrastructure → Terraform states

AWS Credentials via CI/CD Variables

#
  1. Go to Settings → CI/CD → Variables
  2. Add:
    • AWS_ACCESS_KEY_ID (masked)
    • AWS_SECRET_ACCESS_KEY (masked)
    • AWS_DEFAULT_REGION

Or use OIDC:

assume_role:
  id_tokens:
    GITLAB_OIDC_TOKEN:
      aud: https://gitlab.com
  script:
    - >
      STS=$(aws sts assume-role-with-web-identity
      --role-arn ${ROLE_ARN}
      --web-identity-token ${GITLAB_OIDC_TOKEN}
      --role-session-name "gitlab-ci-${CI_JOB_ID}")
    - export AWS_ACCESS_KEY_ID=$(echo $STS | jq -r '.Credentials.AccessKeyId')
    - export AWS_SECRET_ACCESS_KEY=$(echo $STS | jq -r '.Credentials.SecretAccessKey')
    - export AWS_SESSION_TOKEN=$(echo $STS | jq -r '.Credentials.SessionToken')

Merge Request Integration

#
plan:
  stage: plan
  script:
    - terraform plan -out=tfplan -no-color 2>&1 | tee plan.txt
  artifacts:
    reports:
      terraform: plan.json  # GitLab renders this in MR widget
    paths:
      - tfplan
  rules:
    - if: $CI_MERGE_REQUEST_IID
#

Conclusion

#

GitLab CI with the built-in HTTP state backend gives you Terraform CI/CD with zero external dependencies. Plan on merge requests (with MR widget integration), manual apply on main, and use GitLab OIDC for keyless cloud authentication. Cache .terraform/ to speed up pipelines.

#Terraform#GitLab#CI/CD#DevOps#Infrastructure as Code

Share this article