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:
| Stage | Trigger | Purpose |
|---|---|---|
validate | every push | fmt -check and validate — fail fast on syntax/format errors |
plan | every push / MR | produce and store a plan.tfplan artifact, render it in the MR |
apply | default branch only | apply 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=falsein 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-statewhen you outgrow it.
Best Practices
- Run
fmt -checkandvalidatein a fastvalidatestage so trivial errors never reach the plan step. - Always
applya saved plan artifact rather than re-runningplaninside the apply job, so what’s reviewed is exactly what’s executed. - Keep
applymanual and restricted to the default branch withrules, 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_TOKENusage 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.