
plan in the merge request and apply manually on the default branch. The templates Terraform.gitlab-ci.yml were removed in GitLab 18.Infrastructure as code only becomes routine when plan and the apply run in the same place the code lives. In GitLab, that fits in a .gitlab-ci.yml: each push generates a plan, the merge request shows the diff, and apply on main is a manual click — no cloud key in Git and no terraform.tfstate on anyone’s machine. It complements what we have already covered about CI/CD and Ansible on the server.
What changed since 2019
- Remote state in GitLab — native HTTP backend, with locking. No more need for an S3/GCS bucket just for
.tfstate. - Plan report in the MR — the job generates JSON and GitLab displays the resource diff in the merge request.
- Official Terraform templates were removed — in GitLab 18, the recommended path is the OpenTofu component. HashiCorp Terraform still works, but you build the pipeline (image
hashicorp/terraform). - License — Terraform 1.6+ is BSL (HashiCorp/IBM). OpenTofu (MPL 2.0, Linux Foundation) is the free software fork, compatible with the language. Both talk to the same GitLab HTTP backend.
In September 2026: Terraform 1.16.1, OpenTofu 1.12.6. The example below uses Terraform 1.16; the shortcut with the OpenTofu component is at the end.
Minimal repository
A new project on GitLab.com or your self-managed GitLab. Layout:
.
├── .gitignore
├── .gitlab-ci.yml
├── backend.tf
├── main.tf
└── versions.tf
.terraform/
*.tfstate
*.tfstate.*
crash.log
override.tf
*.override.tf
.terraformrc
terraform.rc
Never commit *.tfstate, service account JSON, or override.tf. Credentials go in Settings → CI/CD → Variables (masked + protected).
HTTP backend in GitLab
The block is empty on purpose: the pipeline fills in the address, lock, and token with predefined variables (CI_JOB_TOKEN).
# backend.tf
terraform {
backend "http" {}
}
# versions.tf
terraform {
required_version = ">= 1.6.0"
required_providers {
random = {
source = "hashicorp/random"
version = "~> 3.6"
}
}
}
# main.tf — exemplo sem nuvem, só para o pipeline ter o que planejar
resource "random_id" "exemplo" {
byte_length = 8
}
output "id" {
value = random_id.exemplo.hex
}
Replace the random_id with your resources (AWS, GCP, Proxmox…) once the workflow is green. State appears in Operate → Terraform states (or Infrastructure → Terraform states, depending on the GitLab version).
Whoever applies needs the role of Maintainer in the project (Developer reads state with -lock=false, but cannot write it).
Pipeline: fmt, validate, plan, apply
plan in every MR and on the default branch. apply only on the default branch, manual, reusing the plan artifact — so nobody applies a plan the MR did not see. resource_group prevents two applies on the same state at the same time.
# .gitlab-ci.yml
variables:
TF_ROOT: ${CI_PROJECT_DIR}
TF_STATE_NAME: default
TF_HTTP_ADDRESS: "${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/terraform/state/${TF_STATE_NAME}"
TF_HTTP_LOCK_ADDRESS: "${TF_HTTP_ADDRESS}/lock"
TF_HTTP_UNLOCK_ADDRESS: "${TF_HTTP_ADDRESS}/lock"
TF_HTTP_USERNAME: gitlab-ci-token
TF_HTTP_PASSWORD: ${CI_JOB_TOKEN}
TF_HTTP_LOCK_METHOD: POST
TF_HTTP_UNLOCK_METHOD: DELETE
TF_HTTP_RETRY_WAIT_MIN: "5"
default:
image:
name: hashicorp/terraform:1.16
entrypoint: [""]
cache:
key: ${TF_STATE_NAME}
paths:
- ${TF_ROOT}/.terraform/
stages: [validate, plan, apply]
fmt:
stage: validate
script:
- terraform -chdir="$TF_ROOT" fmt -check -recursive
allow_failure: true
validate:
stage: validate
script:
- terraform -chdir="$TF_ROOT" init -backend=false
- terraform -chdir="$TF_ROOT" validate
plan:
stage: plan
script:
- terraform -chdir="$TF_ROOT" init
- terraform -chdir="$TF_ROOT" plan -out=plan.cache
- terraform -chdir="$TF_ROOT" show -json plan.cache > plan.json
artifacts:
paths:
- ${TF_ROOT}/plan.cache
reports:
terraform: ${TF_ROOT}/plan.json
expire_in: 1 week
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
apply:
stage: apply
resource_group: ${TF_STATE_NAME}
environment:
name: production
script:
- terraform -chdir="$TF_ROOT" init
- terraform -chdir="$TF_ROOT" apply -auto-approve plan.cache
dependencies:
- plan
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
when: manual
The official image has ENTRYPOINT terraform — so the entrypoint: [""], otherwise the job fails on the first command.
In the merge request, GitLab shows the Terraform widget (creates/updates/deletes) thanks to reports: terraform. Open the MR, read the plan, merge, and only then click Play on the job apply.
Cloud credentials
When the HCL talks to AWS/GCP/Azure:
- Variables
AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY(or equivalents) in CI/CD Variables, masked and protected — only the protected branch sees them. - Even better: OIDC (
id_tokensin the job + IAM role / Workload Identity). The pipeline assumes a short-lived role, with no static key. - Never a
creds/serviceaccount.jsonin the repository — that was the anti-pattern in the 2019 text.
# esboço OIDC no GitLab.com → AWS
id_tokens:
GITLAB_OIDC_TOKEN:
aud: https://gitlab.com
# no script: aws sts assume-role-with-web-identity --web-identity-token "$GITLAB_OIDC_TOKEN" ...
Shortcut: GitLab’s OpenTofu component
If you can use (or prefer) OpenTofu, the official catalog sets up validate/plan/apply and already connects HTTP state. Pin the version from the releases page — the version of inputs must be the same as @ in the include, otherwise the image will not match.
include:
- component: $CI_SERVER_FQDN/components/opentofu/validate-plan-apply@4.1.0
inputs:
version: "4.1.0"
opentofu_version: "1.11.2"
root_dir: "."
state_name: default
stages: [validate, build, deploy]
On self-managed instances, the gitlab.com component cannot be included directly: mirror the project components/opentofu on your instance. The HCL stays the same (backend "http" {}); the binary becomes tofu instead of terraform.
Quick checklist
- Protected branch = only Maintainers can merge and trigger apply.
- Lockfile (
.terraform.lock.hcl) versioned; in CI,initshould not casually “unpin” providers. - One state per environment (
TF_STATE_NAME=staging/production), not one tfstate for everything. terraform fmt -checkin validate — broken style should not reach plan.- Expire the plan artifact (here, 1 week). Applying an old plan is a recipe for drift.
Original video (2019)
The recording below shows the old workflow (GitLab + Terraform at the time of the post). The concepts — a pipeline per commit, plan before apply — remain; the YAML and backend do not.
References
- IaC with OpenTofu/Terraform in GitLab
- GitLab-managed state
- OpenTofu component (CI/CD catalog)
- HTTP backend (Terraform)
- OpenTofu
- Original post (2019)
With this, GitLab becomes the home for code, plans, and state. For the next infrastructure change: open an MR, read the widget, merge, click apply.