
plan no merge request e apply manual na branch padrão. Os templates Terraform.gitlab-ci.yml saíram no GitLab 18.Infraestrutura como código só vira rotina quando o plan e o apply rodam no mesmo lugar em que o código vive. No GitLab isso cabe num .gitlab-ci.yml: cada push gera um plano, o merge request mostra o diff e o apply na main é um clique manual — sem chave de nuvem no Git e sem terraform.tfstate na máquina de ninguém. Complementa o que já cobrimos de CI/CD e de Ansible no servidor.
O que mudou desde 2019
- Estado remoto no GitLab — backend HTTP nativo, com lock. Não precisa mais de bucket S3/GCS só para o
.tfstate. - Relatório de plan no MR — o job gera JSON e o GitLab desenha o diff de recursos no merge request.
- Templates oficiais de Terraform saíram — no GitLab 18 o caminho recomendado é o componente OpenTofu. Terraform HashiCorp continua, mas você monta o pipeline (imagem
hashicorp/terraform). - Licença — Terraform 1.6+ é BSL (HashiCorp/IBM). OpenTofu (MPL 2.0, Linux Foundation) é o fork livre, compatível com a linguagem. Os dois falam com o mesmo backend HTTP do GitLab.
Em setembro de 2026: Terraform 1.16.1, OpenTofu 1.12.6. O exemplo abaixo usa Terraform 1.16; no final está o atalho com o componente OpenTofu.
Repositório mínimo
Projeto novo no GitLab.com ou no seu GitLab self-managed. Layout:
.
├── .gitignore
├── .gitlab-ci.yml
├── backend.tf
├── main.tf
└── versions.tf
.terraform/
*.tfstate
*.tfstate.*
crash.log
override.tf
*.override.tf
.terraformrc
terraform.rc
Nunca commite *.tfstate, JSON de service account nem override.tf. Credencial vai em Settings → CI/CD → Variables (masked + protected).
Backend HTTP no GitLab
O bloco fica vazio de propósito: o pipeline preenche endereço, lock e token com variáveis pré-definidas (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
}
Troque o random_id pelos seus recursos (AWS, GCP, Proxmox…) quando o fluxo estiver verde. O estado aparece em Operate → Terraform states (ou Infrastructure → Terraform states, conforme a versão do GitLab).
Quem aplica precisa de papel Maintainer no projeto (Developer lê o estado com -lock=false, mas não grava).
Pipeline: fmt, validate, plan, apply
plan em todo MR e na branch padrão. apply só na padrão, manual, reusando o artefato do plan — assim ninguém aplica um plano que o MR não viu. resource_group evita dois applies no mesmo estado ao mesmo tempo.
# .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
A imagem oficial tem ENTRYPOINT terraform — por isso o entrypoint: [""], senão o job quebra no primeiro comando.
No merge request o GitLab mostra o widget de Terraform (creates/updates/deletes) graças a reports: terraform. Abra o MR, leia o plan, merge, e só então clique em Play no job apply.
Credenciais de nuvem
Quando o HCL falar com AWS/GCP/Azure:
- Variáveis
AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY(ou equivalentes) em CI/CD Variables, masked e protected — só a branch protegida as vê. - Melhor ainda: OIDC (
id_tokensno job + IAM role / Workload Identity). A pipeline assume um papel de curta duração, sem chave estática. - Nunca um
creds/serviceaccount.jsonno repositório — era o anti-padrão do texto de 2019.
# 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" ...
Atalho: componente OpenTofu do GitLab
Se você pode (ou prefere) OpenTofu, o catálogo oficial monta validate/plan/apply e já pluga o estado HTTP. Pinne a versão da página de releases — o version do inputs tem que ser o mesmo do @ no include, senão a imagem não casa.
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]
No self-managed o componente do gitlab.com não inclui direto: espelhe o projeto components/opentofu na sua instância. O HCL continua o mesmo (backend "http" {}); o binário passa a ser tofu em vez de terraform.
Checklist rápido
- Branch protegida = só Maintainer faz merge e dispara o apply.
- Lockfile (
.terraform.lock.hcl) versionado; no CI,initnão deveria “soltar” providers à toa. - Um estado por ambiente (
TF_STATE_NAME=staging/production), não um tfstate só para tudo. terraform fmt -checkno validate — estilo quebrado não deveria chegar no plan.- Expire o artefato do plan (aqui, 1 semana). Plan velho aplicado é receita de drift.
Vídeo original (2019)
A gravação abaixo é do fluxo antigo (GitLab + Terraform na época do post). Os conceitos — um pipeline por commit, plan antes do apply — continuam; o YAML e o backend, não.
Referências
- IaC com OpenTofu/Terraform no GitLab
- Estado gerenciado pelo GitLab
- Componente OpenTofu (catálogo CI/CD)
- Backend HTTP (Terraform)
- OpenTofu
- Post original (2019)
Com isso o GitLab vira o lugar do código, do plan e do estado. Na próxima mudança de infra: abre MR, lê o widget, merge, clica no apply.