Terraform pipelines in GitLab

Mascote do LinuxPro empurrando um estágio da esteira da pipeline, entre a raposa do GitLab e o símbolo do Terraform

Note (2026): Original article from December 2019 (a translation of a Medium post, with GKE and remote state “in another article”). The title and URL have been preserved; the procedure below is the current workflow — HTTP state in GitLab itself, 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_tokens in the job + IAM role / Workload Identity). The pipeline assumes a short-lived role, with no static key.
  • Never a creds/serviceaccount.json in 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

  1. Protected branch = only Maintainers can merge and trigger apply.
  2. Lockfile (.terraform.lock.hcl) versioned; in CI, init should not casually “unpin” providers.
  3. One state per environment (TF_STATE_NAME=staging / production), not one tfstate for everything.
  4. terraform fmt -check in validate — broken style should not reach plan.
  5. 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

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.