← All TILs · terraform

OpenTofu in a GitLab pipeline: GitLab-managed state and the OpenTofu CI/CD component

terraform - 2026-10-23

Fourth entry in the Running IaC and CaC from a controller series. The first entry set out the roles: a GitLab runner picks up a pipeline's jobs, and the machine or container a job runs in does the work. This entry puts OpenTofu in that place. A project's pipeline checks, plans and applies its OpenTofu code, and GitLab keeps the state, OpenTofu's record of what it created (the fundamentals entry explains why it needs one). All of it ran on the series' lab: GitLab CE 19.5.1 and GitLab Runner 19.5.1 in Docker, from the companion repository abdelhousni/iac-controller-series, example 04-opentofu-gitlab-state.

The GitLab terms used below (pipeline, stage, job, runner) are defined in the GitLab CI entry. Three more:

Two files

The OpenTofu code declares one resource and an empty http backend. terraform_data is built into OpenTofu, so the example needs no provider to download:

terraform {
  required_version = ">= 1.12"

  # GitLab-managed state: the pipeline's gitlab-tofu fills in the address
  # and the credentials (the job token) from CI variables.
  backend "http" {}
}

resource "terraform_data" "release" {
  input = {
    environment = var.environment
    version     = "1.0.0"
  }
}

The pipeline is three lines and a list of stages:

include:
  - component: $CI_SERVER_FQDN/components/opentofu/validate-plan-apply@4.9.0
    inputs:
      version: 4.9.0
      opentofu_version: 1.12.6

stages: [validate, build, deploy]

validate-plan-apply is one of the templates of GitLab's OpenTofu CI/CD component, version 4.9.0 (31 August 2026). Its jobs run in the component's own image, registry.gitlab.com/components/opentofu/gitlab-opentofu:4.9.0-opentofu1.12.6-alpine, which holds tofu and a wrapper script, gitlab-tofu. The wrapper is what connects OpenTofu to GitLab: its source sets the HTTP backend's address to $CI_API_V4_URL/projects/$CI_PROJECT_ID/terraform/state/<state name> and its credentials to the user gitlab-ci-token with the password $CI_JOB_TOKEN, the short-lived token GitLab gives every job. The version input repeats the component's version because, as the template's own comment says, a component can't yet read the version it was included at, and the image tag is built from it.

On a self-managed GitLab, the component has to be copied in first

$CI_SERVER_FQDN is the GitLab instance running the pipeline, so the include looks for components/opentofu on that instance. On gitlab.com it exists. On a self-managed GitLab it doesn't, and the component's README says so: "GitLab CI/CD components are not yet distributed and available on self-managed GitLab instances", and an include of gitlab.com/components/opentofu/… "won't work from a self-managed instance". Its answer is to mirror the project, at the same path, with a pull mirror: a GitLab project that fetches another repository on a schedule.

A pull mirror needs the GitLab server to reach gitlab.com. The lab's GitLab has no internet access, so the example copies the component in by pushing from a clone instead:

git clone --single-branch --branch main https://gitlab.com/components/opentofu.git
git -C opentofu fetch origin refs/tags/4.9.0:refs/tags/4.9.0
git -C opentofu push -o ci.skip http://<lab-gitlab>/components/opentofu.git main refs/tags/4.9.0

-o ci.skip matters: the component's repository has its own .gitlab-ci.yml, which builds and signs the job images, and a push would otherwise start that pipeline on the lab's runner. Including the component by a release tag (@4.9.0) rather than ~latest is what its README asks for: "Please make sure to use a released version of this CI/CD component."

The pipeline

Run on main, after the copy:

Before apply:
  validate / fmt: success
  validate / validate: success
  build / plan: success
  deploy / apply: manual
Plan: 1 to add, 0 to change, 0 to destroy.
Apply: success, Apply complete! Resources: 1 added, 0 changed, 0 destroyed.

The state GitLab keeps

Each project can hold several named states. Through GitLab's GraphQL API, and the state itself through the REST API:

State default: serial 1, written by job apply, locked: false
Resources: terraform_data.release
Output release: {"environment":"lab","version":"1.0.0"}

The serial is the state's version number: every write adds one, and GitLab keeps the earlier versions. The lock is OpenTofu's state lock (see the state locking entry), which GitLab implements behind the HTTP backend's lock address, so a plan or an apply started outside the pipeline fails while one inside it holds the lock, or waits with -lock-timeout.

GitLab's documentation adds three things worth knowing before relying on it (GitLab-managed OpenTofu state):

The same state from outside the pipeline

The state isn't tied to CI. Setting the HTTP backend's variables by hand, with a personal access token in place of the job token, gives an engineer's machine the same view. In the example, from another container on the lab network:

export TF_HTTP_ADDRESS=http://gitlab/api/v4/projects/<id>/terraform/state/default
export TF_HTTP_USERNAME=root TF_HTTP_PASSWORD=<personal access token>
tofu init && tofu output -json release
{"environment":"lab","version":"1.0.0"}

That's also how to inspect or repair a state when a pipeline can't, with the role rules above: a Maintainer's token can lock and write, a Developer's only read.

The commands above ran in a Linux container: GitLab CE 19.5.1, GitLab Runner 19.5.1 with the Docker executor, and the component's OpenTofu 1.12.6 image, from the companion repository's lab.

Created 2026-10-23T00:11:55+02:00 · Edit