
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_tokensen el job + IAM role / Workload Identity). La pipeline asume un rol de corta duración, sin clave estática. - Nunca un
creds/serviceaccount.jsonen 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
- Rama protegida = solo el Maintainer fusiona y dispara el apply.
- Lockfile (
.terraform.lock.hcl) versionado; en el CI,initno debería “soltar” proveedores a lo loco. - Un estado por entorno (
TF_STATE_NAME=staging/production), no un tfstate único para todo. terraform fmt -checken el validate — estilo roto no debería llegar al plan.- 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
- IaC con OpenTofu/Terraform en GitLab
- Estado gestionado por GitLab
- Componente OpenTofu (catálogo CI/CD)
- Backend HTTP (Terraform)
- OpenTofu
- Publicación original (2019)
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.