Skip to content
Infrastructure as Code iac cicd 4 min read

GitLab CI

GitLab CI is a natural home for Terraform because pipelines, merge requests, manual gates, and a managed state backend all live in one platform. A typical workflow validates and plans changes on every push, surfaces the plan in the merge request, and then applies it only after a human clicks a manual gate on the default branch. Because the apply step is irreversible, the design goal is to make plan automatic and cheap while making apply deliberate and auditable. Everything below uses modern Terraform (1.5+, HCL2) and works identically with OpenTofu — just swap the binary.

Pipeline stages

A Terraform pipeline maps cleanly onto three GitLab CI stages that run in order:

StageTriggerPurpose
validateevery pushfmt -check and validate — fail fast on syntax/format errors
planevery push / MRproduce and store a plan.tfplan artifact, render it in the MR
applydefault branch onlyapply the saved plan, gated behind a manual click

The key idea is that apply consumes the exact plan artifact produced by plan. This guarantees the reviewed plan and the applied plan are byte-identical — no drift sneaks in between approval and execution.

A worked .gitlab-ci.yml

The example below uses GitLab’s official Terraform image and stores state in GitLab’s HTTP backend (covered in the next section). The before_script runs once per job to initialise the backend.

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

stages:
  - validate
  - plan
  - apply

variables:
  TF_ROOT: ${CI_PROJECT_DIR}/environments/prod
  TF_STATE_NAME: prod
  TF_ADDRESS: ${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/terraform/state/${TF_STATE_NAME}

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

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-config="retry_wait_min=5"

validate:
  stage: validate
  script:
    - terraform fmt -check -recursive
    - terraform validate

plan:
  stage: plan
  script:
    - terraform plan -out=plan.tfplan
    - terraform show -json plan.tfplan > plan.json
  artifacts:
    paths:
      - ${TF_ROOT}/plan.tfplan
    reports:
      terraform: ${TF_ROOT}/plan.json

apply:
  stage: apply
  script:
    - terraform apply -input=false plan.tfplan
  dependencies:
    - plan
  when: manual
  rules:
    - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'

The reports: terraform: key tells GitLab to parse plan.json and render an add/change/destroy summary directly in the merge request widget, so reviewers see the impact without opening logs.

Output:

Terraform will perform the following actions:

  # aws_s3_bucket.assets will be created
  + resource "aws_s3_bucket" "assets" {
      + bucket = "devcraftly-assets-prod"
      + id     = (known after apply)
      + arn    = (known after apply)
    }

Plan: 1 to add, 0 to change, 0 to destroy.

Saved the plan to: plan.tfplan

Manual apply gates

The apply job uses two safeguards working together. The rules block restricts it to the default branch so feature branches can never reach production, and when: manual means the job never starts on its own — a maintainer must open the pipeline and press play. Combined with protected-branch and protected-environment settings, this gives you a clear, attributable audit trail of who approved each change.

You can tighten the gate further by attaching an environment, which lets GitLab enforce approval rules and show a deployment history:

apply:
  stage: apply
  script:
    - terraform apply -input=false plan.tfplan
  environment:
    name: production
  when: manual
  rules:
    - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'

Always pass -input=false in CI. Without it, a prompt for a missing variable hangs the job until it times out instead of failing loudly.

GitLab-managed Terraform state

GitLab ships a built-in HTTP state backend, so you don’t need a separate S3 bucket and DynamoDB table just to get started. State is stored per project, encrypted at rest, versioned, and locked automatically via the lock_address/unlock_address endpoints shown above. Authentication uses the short-lived CI_JOB_TOKEN, which is injected and rotated by GitLab for every job.

The matching backend declaration in your configuration stays minimal because the pipeline supplies the address and credentials at init time:

terraform {
  required_version = ">= 1.5"

  backend "http" {}

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}

To inspect or migrate state from a workstation, configure the GitLab CLI helper and authenticate with a personal access token scoped to api:

terraform init \
  -backend-config="address=https://gitlab.com/api/v4/projects/123/terraform/state/prod" \
  -backend-config="username=devcraftly" \
  -backend-config="password=$GITLAB_TOKEN"

GitLab’s HTTP backend is a standard protocol, so OpenTofu consumes it the same way — point tofu init at the same -backend-config flags.

Managed state is convenient, but for multi-team or cross-project sharing a dedicated S3/GCS backend with fine-grained IAM is often a better long-term fit. Migrate with terraform init -migrate-state when you outgrow it.

Best Practices

  • Run fmt -check and validate in a fast validate stage so trivial errors never reach the plan step.
  • Always apply a saved plan artifact rather than re-running plan inside the apply job, so what’s reviewed is exactly what’s executed.
  • Keep apply manual and restricted to the default branch with rules, and protect that branch and environment.
  • Use the reports: terraform: artifact so reviewers see the change summary in the merge request widget.
  • Scope CI_JOB_TOKEN usage to the project and prefer protected environments for approval audit trails.
  • Pin the Terraform/OpenTofu image to an exact version to keep pipelines reproducible across runs.
Last updated June 14, 2026
Was this helpful?