GitLab + Terraform

Learn how to run Terraform in GitLab CI/CD. From validate, plan, and apply workflows to GitLab-managed state and merge request plan reports, build a complete Infrastructure-as-Code pipeline.

Terraform State GitLab CI/CD
Why Terraform with GitLab?

Terraform is the standard for Infrastructure as Code (IaC): you define your cloud resources — networks, compute, databases, DNS — in declarative configuration files, and Terraform creates and manages them. GitLab provides the CI/CD platform to run Terraform automatically: every push triggers a validate and plan, every merge request shows the planned changes, and every apply is tracked and auditable. Together, they bring the same rigor to infrastructure that you already apply to application code.

GitLab's integration with Terraform goes beyond just running commands. GitLab can act as the Terraform state backend, storing the state file securely in your project with locking and versioning built in. It can parse the plan output and display a summary directly in the merge request, showing exactly what will be created, updated, or destroyed. And it supports OpenID Connect (OIDC) for keyless authentication to cloud providers, eliminating long-lived access keys from your CI/CD variables [citation:2][citation:18].

It's worth noting that GitLab deprecated its built-in Terraform CI/CD templates in GitLab 18.0, and now recommends using the OpenTofu CI/CD component or building your own pipeline [citation:4][citation:15]. The OpenTofu component provides validate, plan, and apply jobs out of the box, and Terraform users can adapt it or write their own. The core concepts — state management, merge request integration, and OIDC — remain the same regardless of which tool you use.

Core Principle: Validate fast, plan on every change, apply only after review. GitLab's merge request integration makes the plan visible to reviewers before any infrastructure changes.

Validate

Syntax and format checks. Fast, no credentials needed. Runs on every commit and merge request.

Plan

Shows what Terraform will change. Runs on MRs and main. Output can be displayed in the MR.

Apply

Executes the plan. Manual on main, or automatic with approval. Creates/modifies real infrastructure.

State Backend

GitLab can store state as HTTP backend. Locking, versioning, and encryption built in.

MR Integration

Plan summary displayed in merge request. Reviewers see what will change before approving.

OIDC Auth

Keyless authentication to AWS/GCP/Azure. No long-lived credentials stored in GitLab.
Validate and Plan Workflow

The Terraform workflow in CI/CD starts with validation. terraform validate checks the configuration syntax and internal consistency, while terraform fmt -check enforces canonical formatting. These run fast — in seconds — and require no cloud credentials, so they're safe to run on every commit and merge request. A failed validate or format check should block the pipeline immediately, before any slower or more expensive steps run [citation:2][citation:6].

After validation, terraform plan generates an execution plan: a detailed preview of what Terraform will create, update, or destroy. The plan is saved to a file with -out=tfplan, which is critical — the apply step later uses that exact plan, ensuring that what was reviewed is what gets applied. On merge requests, the plan output can be parsed and displayed as a summary, showing reviewers the resource change counts without forcing them to read raw Terraform output [citation:2].

GitLab's merge request integration takes this further. By generating a JSON representation of the plan and declaring it as a terraform report artifact, GitLab parses the plan and displays it directly in the MR widget. Reviewers see "2 to add, 1 to change, 0 to destroy" alongside the code diff, making infrastructure changes as reviewable as application code. This is one of the most valuable features of running Terraform in GitLab: infrastructure changes go through the same review process as code [citation:2].

# Basic Terraform pipeline: validate, plan, apply image: name: hashicorp/terraform:1.7 entrypoint: [""] stages: - validate - plan - apply variables: TF_ROOT: ${CI_PROJECT_DIR}/terraform cache: key: ${CI_COMMIT_REF_SLUG} paths: - ${TF_ROOT}/.terraform 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: - ${TF_ROOT}/tfplan expire_in: 1 week apply: stage: apply script: - terraform apply -auto-approve tfplan dependencies: - plan when: manual rules: - if: $CI_COMMIT_BRANCH == "main" # Merge request plan with summary report plan-mr: stage: plan script: - terraform plan -out=tfplan - terraform show -no-color tfplan > plan.txt - terraform show -json tfplan > tfplan.json artifacts: paths: - ${TF_ROOT}/tfplan - ${TF_ROOT}/plan.txt - ${TF_ROOT}/tfplan.json reports: terraform: ${TF_ROOT}/tfplan.json rules: - if: $CI_MERGE_REQUEST_IID # The terraform report artifact enables # GitLab to display plan summary in the MR widget # Note: GitLab's Terraform CI/CD templates were deprecated # Use the OpenTofu component or custom pipeline as shown here
Validate & Plan Best Practices:
  • Run terraform validate and fmt -check on every commit
  • Use -out=tfplan and pass the plan file to apply
  • Generate the terraform report artifact for MR display
  • Use rules to run plan on MRs and main
  • Make apply manual on main, or automatic only with approvals
GitLab-Managed Terraform State

Terraform state is critical: it maps your configuration to real-world resources and must be shared across pipeline runs. Storing it locally on a runner is fragile and dangerous. GitLab provides a managed Terraform state backend that uses the HTTP backend protocol, storing state in your GitLab project with locking, versioning, and encryption. This means you don't need to set up an S3 bucket or other external storage — GitLab handles it [citation:2][citation:10].

Configuring the backend is a matter of setting CI/CD variables and passing them to terraform init. The key variables are TF_STATE_NAME (which identifies the state file) and TF_ADDRESS (the GitLab API URL for the state). The backend configuration includes lock and unlock addresses, and authentication uses the CI job token. Once configured, every pipeline run reads and writes the same state, ensuring consistency [citation:2].

For multiple environments, each environment gets its own state name. A staging environment uses TF_STATE_NAME=staging and production uses TF_STATE_NAME=production. This isolates state between environments, preventing a staging apply from accidentally modifying production resources. The state name can be set via CI/CD variables scoped to each environment, making the pipeline dynamic without duplicating configuration [citation:10].

# GitLab-managed Terraform state configuration variables: TF_STATE_NAME: default TF_ADDRESS: ${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/terraform/state/${TF_STATE_NAME} before_script: - cd ${TF_ROOT} - | terraform init \ -backend-config="address=${TF_ADDRESS}" \ -backend-config="lock_address=${TF_ADDRESS}/lock" \ -backend-config="unlock_address=${TF_ADDRESS}/lock" \ -backend-config="username=gitlab-ci-token" \ -backend-config="password=${CI_JOB_TOKEN}" \ -backend-config="lock_method=POST" \ -backend-config="unlock_method=DELETE" # backend.tf (minimal, config comes from init) terraform { backend "http" { # Configuration provided via -backend-config } } # Environment-specific state names plan_staging: stage: plan variables: TF_STATE_NAME: staging script: - terraform init -backend-config="address=${TF_ADDRESS}" ... - terraform plan -out=tfplan environment: name: staging plan_production: stage: plan variables: TF_STATE_NAME: production script: - terraform init -backend-config="address=${TF_ADDRESS}" ... - terraform plan -out=tfplan environment: name: production rules: - if: $CI_COMMIT_BRANCH == "main" # View state in GitLab UI: # Operate > Terraform states # Shows all state files, versions, and lock status # State locking prevents concurrent applies # If a lock is stuck: # terraform force-unlock # External backend (S3) alternative: # -backend-config="bucket=${TF_BACKEND_BUCKET}" \ # -backend-config="key=${CI_PROJECT_PATH}/${TF_STATE_NAME}.tfstate" \ # -backend-config="region=${AWS_REGION}"
GitLab State Benefits: