Pipelines Terraform en GitLab

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

Nota (2026): Artículo original de diciembre de 2019 (traducción de un post en Medium, con GKE y estado remoto “en otro texto”). El título y la URL se han mantenido; el procedimiento de abajo es el flujo actual — estado HTTP en el propio GitLab, plan en el merge request y apply manual en la rama por defecto. Las plantillas Terraform.gitlab-ci.yml salieron en GitLab 18.

Infraestructura como código solo se convierte en rutina cuando los plan y el apply se ejecutan en el mismo lugar donde vive el código. En GitLab esto cabe en un .gitlab-ci.yml: cada push genera un plan, el merge request muestra el diff y el apply en la main es un clic manual — sin clave de nube en el Git y sin terraform.tfstate en la máquina de nadie. Complementa lo que ya hemos cubierto de CI/CD y de Ansible en el servidor.

Lo que cambió desde 2019

  • Estado remoto en GitLab — backend HTTP nativo, con lock. Ya no hace falta un bucket S3/GCS solo para el .tfstate.
  • Informe de plan en el MR — el job genera JSON y GitLab dibuja el diff de recursos en el merge request.
  • Las plantillas oficiales de Terraform salieron — en GitLab 18 la ruta recomendada es el componente OpenTofu. Terraform de HashiCorp continúa, pero tú montas el pipeline (imagen hashicorp/terraform).
  • Licencia — Terraform 1.6+ es BSL (HashiCorp/IBM). OpenTofu (MPL 2.0, Linux Foundation) es el fork libre, compatible con el lenguaje. Los dos hablan con el mismo backend HTTP de GitLab.

En septiembre de 2026: Terraform 1.16.1, OpenTofu 1.12.6. El ejemplo de abajo usa Terraform 1.16; al final está el atajo con el componente OpenTofu.

Repositorio mínimo

Proyecto nuevo en GitLab.com o en tu 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 commitees *.tfstate, JSON de cuenta de servicio ni override.tf. La credencial va en Settings → CI/CD → Variables (masked + protected).

Backend HTTP en GitLab

El bloque queda vacío a propósito: el pipeline rellena la dirección, el lock y el token con variables predefinidas (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
}

Sustituye el random_id por tus recursos (AWS, GCP, Proxmox…) cuando el flujo esté en verde. El estado aparece en Operate → Terraform states (o Infrastructure → Terraform states, según la versión de GitLab).

Quien aplica necesita el rol Maintainer en el proyecto (Developer lee el estado con -lock=false, pero no escribe).

Pipeline: fmt, validate, plan, apply

plan en todo MR y en la rama por defecto. apply solo en la por defecto, manual, reutilizando el artefacto del plan — así nadie aplica un plan que el MR no vio. resource_group evita dos applies en el mismo estado al mismo tiempo.

# .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

La imagen oficial tiene ENTRYPOINT terraform — por eso el entrypoint: [""], si no el job falla en el primer comando.

En el merge request GitLab muestra el widget de Terraform (creates/updates/deletes) gracias a reports: terraform. Abre el MR, lee el plan, haz merge, y solo entonces haz clic en Play en el job apply.

Credenciales de nube

Cuando el HCL hable con AWS/GCP/Azure:

  • Variables AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY (o equivalentes) en CI/CD Variables, masked e protected — solo la rama protegida las ve.
  • Mejor aún: OIDC (id_tokens en el job + IAM role / Workload Identity). La pipeline asume un rol de corta duración, sin clave estática.
  • Nunca un creds/serviceaccount.json en el repositorio — era el anti-patrón del 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" ...

Atajo: componente OpenTofu de GitLab

Si puedes (o prefieres) OpenTofu, el catálogo oficial monta validate/plan/apply y ya enchufa el estado HTTP. Fija la versión de la página de releases — el version del inputs tiene que ser el mismo que el @ en el include, si no la imagen no encaja.

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]

En el self-managed el componente de gitlab.com no se incluye directamente: refleje el proyecto components/opentofu en su instancia. El HCL sigue siendo el mismo (backend "http" {}); el binario pasa a ser tofu en lugar de terraform.

Lista de verificación rápida

  1. Rama protegida = solo el Maintainer fusiona y dispara el apply.
  2. Lockfile (.terraform.lock.hcl) versionado; en el CI, init no debería “soltar” proveedores a lo loco.
  3. Un estado por entorno (TF_STATE_NAME=staging / production), no un tfstate único para todo.
  4. terraform fmt -check en el validate — estilo roto no debería llegar al plan.
  5. Expira el artefacto del plan (aquí, 1 semana). Un plan antiguo aplicado es receta de drift.

Vídeo original (2019)

La grabación de abajo es del flujo antiguo (GitLab + Terraform en la época del post). Los conceptos — un pipeline por commit, plan antes del apply — siguen; el YAML y el backend, no.

Referencias

Con esto, GitLab se convierte en el lugar del código, del plan y del estado. En el próximo cambio de infraestructura: abre un MR, lee el widget, haz merge, haz clic en apply.