{"id":154,"date":"2019-12-05T12:33:00","date_gmt":"2019-12-05T15:33:00","guid":{"rendered":"https:\/\/www.linuxpro.com.br\/2019\/12\/terraform-pipelines-in-gitlab\/"},"modified":"2026-09-08T02:41:28","modified_gmt":"2026-09-08T05:41:28","slug":"terraform-pipelines-in-gitlab","status":"publish","type":"post","link":"https:\/\/www.linuxpro.com.br\/es\/2019\/12\/terraform-pipelines-in-gitlab\/","title":{"rendered":"Pipelines Terraform no GitLab"},"content":{"rendered":"<p><img loading=\"lazy\" decoding=\"async\" alt=\"Mascote do LinuxPro empurrando um est\u00e1gio da esteira da pipeline, entre a raposa do GitLab e o s\u00edmbolo do Terraform\" src=\"\/wp-content\/uploads\/2026\/09\/terraform-gitlab-v3.webp\" width=\"1052\" height=\"652\" \/><\/p>\n<div style=\"background:#fff8e1;border-left:4px solid #8b1a1a;padding:.8em 1em;margin:1em 0\"><strong>Nota (2026):<\/strong> Artigo original de dezembro de 2019 (tradu\u00e7\u00e3o de um post no Medium, com GKE e estado remoto \u201cem outro texto\u201d). O t\u00edtulo e a URL foram mantidos; o procedimento abaixo \u00e9 o fluxo atual \u2014 estado HTTP no pr\u00f3prio GitLab, <code>plan<\/code> no merge request e <code>apply<\/code> manual na branch padr\u00e3o. Os templates <code>Terraform.gitlab-ci.yml<\/code> sa\u00edram no GitLab 18.<\/div>\n<p>Infraestrutura como c\u00f3digo s\u00f3 vira rotina quando o <code>plan<\/code> e o <code>apply<\/code> rodam no mesmo lugar em que o c\u00f3digo vive. No GitLab isso cabe num <code>.gitlab-ci.yml<\/code>: cada push gera um plano, o merge request mostra o diff e o apply na <code>main<\/code> \u00e9 um clique manual \u2014 sem chave de nuvem no Git e sem <code>terraform.tfstate<\/code> na m\u00e1quina de ningu\u00e9m. Complementa o que j\u00e1 cobrimos de <a href=\"\/2019\/12\/devops-ci-cd\/\">CI\/CD<\/a> e de <a href=\"\/2026\/09\/automatizando-servidores-linux-com-ansible\/\">Ansible no servidor<\/a>.<\/p>\n<p><!-- more --><\/p>\n<h2>O que mudou desde 2019<\/h2>\n<ul>\n<li><strong>Estado remoto no GitLab<\/strong> \u2014 backend HTTP nativo, com lock. N\u00e3o precisa mais de bucket S3\/GCS s\u00f3 para o <code>.tfstate<\/code>.<\/li>\n<li><strong>Relat\u00f3rio de plan no MR<\/strong> \u2014 o job gera JSON e o GitLab desenha o diff de recursos no merge request.<\/li>\n<li><strong>Templates oficiais de Terraform sa\u00edram<\/strong> \u2014 no GitLab 18 o caminho recomendado \u00e9 o <a href=\"https:\/\/gitlab.com\/components\/opentofu\">componente OpenTofu<\/a>. Terraform HashiCorp continua, mas voc\u00ea monta o pipeline (imagem <code>hashicorp\/terraform<\/code>).<\/li>\n<li><strong>Licen\u00e7a<\/strong> \u2014 Terraform 1.6+ \u00e9 BSL (HashiCorp\/IBM). OpenTofu (MPL 2.0, Linux Foundation) \u00e9 o fork livre, compat\u00edvel com a linguagem. Os dois falam com o mesmo backend HTTP do GitLab.<\/li>\n<\/ul>\n<p>Em setembro de 2026: Terraform <strong>1.16.1<\/strong>, OpenTofu <strong>1.12.6<\/strong>. O exemplo abaixo usa Terraform 1.16; no final est\u00e1 o atalho com o componente OpenTofu.<\/p>\n<h2>Reposit\u00f3rio m\u00ednimo<\/h2>\n<p>Projeto novo no GitLab.com ou no seu GitLab self-managed. Layout:<\/p>\n<pre><code class=\"language-text\">.\n\u251c\u2500\u2500 .gitignore\n\u251c\u2500\u2500 .gitlab-ci.yml\n\u251c\u2500\u2500 backend.tf\n\u251c\u2500\u2500 main.tf\n\u2514\u2500\u2500 versions.tf\n<\/code><\/pre>\n<pre><code class=\"language-gitignore\">.terraform\/\n*.tfstate\n*.tfstate.*\ncrash.log\noverride.tf\n*.override.tf\n.terraformrc\nterraform.rc\n<\/code><\/pre>\n<p>Nunca commite <code>*.tfstate<\/code>, JSON de service account nem <code>override.tf<\/code>. Credencial vai em <strong>Settings \u2192 CI\/CD \u2192 Variables<\/strong> (masked + protected).<\/p>\n<h2>Backend HTTP no GitLab<\/h2>\n<p>O bloco fica vazio de prop\u00f3sito: o pipeline preenche endere\u00e7o, lock e token com vari\u00e1veis pr\u00e9-definidas (<code>CI_JOB_TOKEN<\/code>).<\/p>\n<pre><code class=\"language-hcl\"># backend.tf\nterraform {\n  backend \"http\" {}\n}\n<\/code><\/pre>\n<pre><code class=\"language-hcl\"># versions.tf\nterraform {\n  required_version = \"&gt;= 1.6.0\"\n  required_providers {\n    random = {\n      source  = \"hashicorp\/random\"\n      version = \"~&gt; 3.6\"\n    }\n  }\n}\n<\/code><\/pre>\n<pre><code class=\"language-hcl\"># main.tf \u2014 exemplo sem nuvem, s\u00f3 para o pipeline ter o que planejar\nresource \"random_id\" \"exemplo\" {\n  byte_length = 8\n}\n\noutput \"id\" {\n  value = random_id.exemplo.hex\n}\n<\/code><\/pre>\n<p>Troque o <code>random_id<\/code> pelos seus recursos (AWS, GCP, Proxmox\u2026) quando o fluxo estiver verde. O estado aparece em <strong>Operate \u2192 Terraform states<\/strong> (ou <em>Infrastructure \u2192 Terraform states<\/em>, conforme a vers\u00e3o do GitLab).<\/p>\n<p>Quem aplica precisa de papel <strong>Maintainer<\/strong> no projeto (Developer l\u00ea o estado com <code>-lock=false<\/code>, mas n\u00e3o grava).<\/p>\n<h2>Pipeline: fmt, validate, plan, apply<\/h2>\n<p><code>plan<\/code> em todo MR e na branch padr\u00e3o. <code>apply<\/code> s\u00f3 na padr\u00e3o, <strong>manual<\/strong>, reusando o artefato do plan \u2014 assim ningu\u00e9m aplica um plano que o MR n\u00e3o viu. <code>resource_group<\/code> evita dois applies no mesmo estado ao mesmo tempo.<\/p>\n<pre><code class=\"language-yaml\"># .gitlab-ci.yml\nvariables:\n  TF_ROOT: ${CI_PROJECT_DIR}\n  TF_STATE_NAME: default\n  TF_HTTP_ADDRESS: \"${CI_API_V4_URL}\/projects\/${CI_PROJECT_ID}\/terraform\/state\/${TF_STATE_NAME}\"\n  TF_HTTP_LOCK_ADDRESS: \"${TF_HTTP_ADDRESS}\/lock\"\n  TF_HTTP_UNLOCK_ADDRESS: \"${TF_HTTP_ADDRESS}\/lock\"\n  TF_HTTP_USERNAME: gitlab-ci-token\n  TF_HTTP_PASSWORD: ${CI_JOB_TOKEN}\n  TF_HTTP_LOCK_METHOD: POST\n  TF_HTTP_UNLOCK_METHOD: DELETE\n  TF_HTTP_RETRY_WAIT_MIN: \"5\"\n\ndefault:\n  image:\n    name: hashicorp\/terraform:1.16\n    entrypoint: [\"\"]\n  cache:\n    key: ${TF_STATE_NAME}\n    paths:\n      - ${TF_ROOT}\/.terraform\/\n\nstages: [validate, plan, apply]\n\nfmt:\n  stage: validate\n  script:\n    - terraform -chdir=\"$TF_ROOT\" fmt -check -recursive\n  allow_failure: true\n\nvalidate:\n  stage: validate\n  script:\n    - terraform -chdir=\"$TF_ROOT\" init -backend=false\n    - terraform -chdir=\"$TF_ROOT\" validate\n\nplan:\n  stage: plan\n  script:\n    - terraform -chdir=\"$TF_ROOT\" init\n    - terraform -chdir=\"$TF_ROOT\" plan -out=plan.cache\n    - terraform -chdir=\"$TF_ROOT\" show -json plan.cache &gt; plan.json\n  artifacts:\n    paths:\n      - ${TF_ROOT}\/plan.cache\n    reports:\n      terraform: ${TF_ROOT}\/plan.json\n    expire_in: 1 week\n  rules:\n    - if: $CI_PIPELINE_SOURCE == \"merge_request_event\"\n    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH\n\napply:\n  stage: apply\n  resource_group: ${TF_STATE_NAME}\n  environment:\n    name: production\n  script:\n    - terraform -chdir=\"$TF_ROOT\" init\n    - terraform -chdir=\"$TF_ROOT\" apply -auto-approve plan.cache\n  dependencies:\n    - plan\n  rules:\n    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH\n      when: manual\n<\/code><\/pre>\n<p>A imagem oficial tem <code>ENTRYPOINT terraform<\/code> \u2014 por isso o <code>entrypoint: [\"\"]<\/code>, sen\u00e3o o job quebra no primeiro comando.<\/p>\n<p>No merge request o GitLab mostra o widget de Terraform (creates\/updates\/deletes) gra\u00e7as a <code>reports: terraform<\/code>. Abra o MR, leia o plan, merge, e s\u00f3 ent\u00e3o clique em <em>Play<\/em> no job <code>apply<\/code>.<\/p>\n<h2>Credenciais de nuvem<\/h2>\n<p>Quando o HCL falar com AWS\/GCP\/Azure:<\/p>\n<ul>\n<li>Vari\u00e1veis <code>AWS_ACCESS_KEY_ID<\/code> \/ <code>AWS_SECRET_ACCESS_KEY<\/code> (ou equivalentes) em CI\/CD Variables, <strong>masked<\/strong> e <strong>protected<\/strong> \u2014 s\u00f3 a branch protegida as v\u00ea.<\/li>\n<li>Melhor ainda: <strong>OIDC<\/strong> (<code>id_tokens<\/code> no job + IAM role \/ Workload Identity). A pipeline assume um papel de curta dura\u00e7\u00e3o, sem chave est\u00e1tica.<\/li>\n<li>Nunca um <code>creds\/serviceaccount.json<\/code> no reposit\u00f3rio \u2014 era o anti-padr\u00e3o do texto de 2019.<\/li>\n<\/ul>\n<pre><code class=\"language-yaml\"># esbo\u00e7o OIDC no GitLab.com \u2192 AWS\nid_tokens:\n  GITLAB_OIDC_TOKEN:\n    aud: https:\/\/gitlab.com\n# no script: aws sts assume-role-with-web-identity --web-identity-token \"$GITLAB_OIDC_TOKEN\" ...\n<\/code><\/pre>\n<h2>Atalho: componente OpenTofu do GitLab<\/h2>\n<p>Se voc\u00ea pode (ou prefere) OpenTofu, o cat\u00e1logo oficial monta validate\/plan\/apply e j\u00e1 pluga o estado HTTP. Pinne a vers\u00e3o da <a href=\"https:\/\/gitlab.com\/components\/opentofu\/-\/releases\">p\u00e1gina de releases<\/a> \u2014 o <code>version<\/code> do <code>inputs<\/code> tem que ser o mesmo do <code>@<\/code> no include, sen\u00e3o a imagem n\u00e3o casa.<\/p>\n<pre><code class=\"language-yaml\">include:\n  - component: $CI_SERVER_FQDN\/components\/opentofu\/validate-plan-apply@4.1.0\n    inputs:\n      version: \"4.1.0\"\n      opentofu_version: \"1.11.2\"\n      root_dir: \".\"\n      state_name: default\n\nstages: [validate, build, deploy]\n<\/code><\/pre>\n<p>No self-managed o componente do gitlab.com n\u00e3o inclui direto: espelhe o projeto <code>components\/opentofu<\/code> na sua inst\u00e2ncia. O HCL continua o mesmo (<code>backend \"http\" {}<\/code>); o bin\u00e1rio passa a ser <code>tofu<\/code> em vez de <code>terraform<\/code>.<\/p>\n<h2>Checklist r\u00e1pido<\/h2>\n<ol>\n<li>Branch protegida = s\u00f3 Maintainer faz merge e dispara o apply.<\/li>\n<li>Lockfile (<code>.terraform.lock.hcl<\/code>) versionado; no CI, <code>init<\/code> n\u00e3o deveria \u201csoltar\u201d providers \u00e0 toa.<\/li>\n<li>Um estado por ambiente (<code>TF_STATE_NAME=staging<\/code> \/ <code>production<\/code>), n\u00e3o um tfstate s\u00f3 para tudo.<\/li>\n<li><code>terraform fmt -check<\/code> no validate \u2014 estilo quebrado n\u00e3o deveria chegar no plan.<\/li>\n<li>Expire o artefato do plan (aqui, 1 semana). Plan velho aplicado \u00e9 receita de drift.<\/li>\n<\/ol>\n<h2>V\u00eddeo original (2019)<\/h2>\n<p>A grava\u00e7\u00e3o abaixo \u00e9 do fluxo antigo (GitLab + Terraform na \u00e9poca do post). Os conceitos \u2014 um pipeline por commit, plan antes do apply \u2014 continuam; o YAML e o backend, n\u00e3o.<\/p>\n<p><iframe style=\"width:100%;aspect-ratio:16\/9;border:0\" src=\"https:\/\/www.youtube-nocookie.com\/embed\/wDjZGkfphbk\" allowfullscreen loading=\"lazy\"><\/iframe><\/p>\n<h2>Refer\u00eancias<\/h2>\n<ul>\n<li><a href=\"https:\/\/docs.gitlab.com\/user\/infrastructure\/iac\/\">IaC com OpenTofu\/Terraform no GitLab<\/a><\/li>\n<li><a href=\"https:\/\/docs.gitlab.com\/user\/infrastructure\/iac\/terraform_state\/\">Estado gerenciado pelo GitLab<\/a><\/li>\n<li><a href=\"https:\/\/gitlab.com\/components\/opentofu\">Componente OpenTofu (cat\u00e1logo CI\/CD)<\/a><\/li>\n<li><a href=\"https:\/\/developer.hashicorp.com\/terraform\/language\/backend\/http\">Backend HTTP (Terraform)<\/a><\/li>\n<li><a href=\"https:\/\/opentofu.org\/\">OpenTofu<\/a><\/li>\n<li><a href=\"https:\/\/medium.com\/@timhberry\/terraform-pipelines-in-gitlab-415b9d842596\">Post original (2019)<\/a><\/li>\n<\/ul>\n<p>Com isso o GitLab vira o lugar do c\u00f3digo, do plan e do estado. Na pr\u00f3xima mudan\u00e7a de infra: abre MR, l\u00ea o widget, merge, clica no apply.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>Infraestrutura como c\u00f3digo s\u00f3 vira rotina quando o plan e o apply rodam no mesmo lugar em que o c\u00f3digo vive. No GitLab isso cabe num .gitlab-ci.yml: cada push gera um plano, o merge request mostra o diff e o &#8230; <a title=\"Pipelines Terraform no GitLab\" class=\"read-more\" href=\"https:\/\/www.linuxpro.com.br\/es\/2019\/12\/terraform-pipelines-in-gitlab\/\" aria-label=\"Read more about Pipelines Terraform no GitLab\">Read more<\/a><\/p>","protected":false},"author":0,"featured_media":0,"comment_status":"closed","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[108,103,46,102],"tags":[105,106,47,48,104,143,142,144,107],"class_list":["post-154","post","type-post","status-publish","format-standard","hentry","category-ci-cd","category-cloud","category-devops","category-terraform","tag-ci-cd","tag-cloud","tag-devops","tag-git","tag-gitlab","tag-iac","tag-opentofu","tag-pipeline","tag-terraform"],"_links":{"self":[{"href":"https:\/\/www.linuxpro.com.br\/es\/wp-json\/wp\/v2\/posts\/154","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/www.linuxpro.com.br\/es\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/www.linuxpro.com.br\/es\/wp-json\/wp\/v2\/types\/post"}],"replies":[{"embeddable":true,"href":"https:\/\/www.linuxpro.com.br\/es\/wp-json\/wp\/v2\/comments?post=154"}],"version-history":[{"count":9,"href":"https:\/\/www.linuxpro.com.br\/es\/wp-json\/wp\/v2\/posts\/154\/revisions"}],"predecessor-version":[{"id":1085,"href":"https:\/\/www.linuxpro.com.br\/es\/wp-json\/wp\/v2\/posts\/154\/revisions\/1085"}],"wp:attachment":[{"href":"https:\/\/www.linuxpro.com.br\/es\/wp-json\/wp\/v2\/media?parent=154"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/www.linuxpro.com.br\/es\/wp-json\/wp\/v2\/categories?post=154"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/www.linuxpro.com.br\/es\/wp-json\/wp\/v2\/tags?post=154"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}