Pipelines Terraform no GitLab

Pipelines Terraform no GitLab

Nota (2026): Artigo original de dezembro de 2019 (tradução de um post no Medium, com GKE e estado remoto “em outro texto”). O título e a URL foram mantidos; o procedimento abaixo é o fluxo atual — estado HTTP no próprio GitLab, 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_tokens no job + IAM role / Workload Identity). A pipeline assume um papel de curta duração, sem chave estática.
  • Nunca um creds/serviceaccount.json no 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

  1. Branch protegida = só Maintainer faz merge e dispara o apply.
  2. Lockfile (.terraform.lock.hcl) versionado; no CI, init não deveria “soltar” providers à toa.
  3. Um estado por ambiente (TF_STATE_NAME=staging / production), não um tfstate só para tudo.
  4. terraform fmt -check no validate — estilo quebrado não deveria chegar no plan.
  5. 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

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.