GuíasGuides · Nivel 4Level 4

Terraform: un módulo, estado remoto, el plan en el pipeline y alguien que lo cambió en el portalTerraform: a module, remote state, the plan in the pipeline and someone who changed it in the portal

Infraestructura como código para un equipo de sistemas: un módulo pequeño en Azure (grupo de recursos, red y VM), estado remoto con bloqueo, plan y apply en un pipeline con revisión, qué hacer con la deriva, cómo importar lo que ya existe y cómo mantener el estado limpio y los secretos fuera del repositorio.Infrastructure as code for an IT team: a small module on Azure (resource group, network and VM), remote state with locking, plan and apply in a pipeline with review, what to do about drift, how to import what already exists, and how to keep state clean and secrets out of the repository.

Qué vas a conseguir

Que la infraestructura de una sede o de un servicio salga de un repositorio revisado y no de clics en el portal. Un módulo reutilizable, un estado compartido que nadie pisa, un pipeline donde el cambio se lee antes de aplicarse y una forma ordenada de tratar lo que se cambió a mano o lo que ya existía antes de Terraform.

Antes de empezar

  • Terraform 1.10 o posterior y el proveedor azurerm 4.x.
  • Un repositorio Git con ramas protegidas: a main solo se llega por pull request revisado (guía de Git).
  • Una cuenta de almacenamiento para el estado, creada aparte, con versionado de blobs y eliminación temporal activados.
  • Una identidad para el pipeline: identidad administrada o aplicación de Entra ID con federación de identidades de carga de trabajo (OIDC), con rol Contributor solo sobre las suscripciones que gestiona. Sin secretos de cliente guardados.

1. El módulo: grupo de recursos, red y VM

Módulos en modules/ y una carpeta por entorno (envs/prod, envs/pruebas) que los usa con sus valores.

repo infra-azure
cat envs/prod/main.tfterraform {  required_version = ">= 1.10"  required_providers {    azurerm = { source = "hashicorp/azurerm", version = "~> 4.0" }  }  backend "azurerm" {    resource_group_name  = "rg-tfstate"    storage_account_name = "sttfstatecontoso01"    container_name       = "tfstate"    key                  = "prod/sede-vm.tfstate"    use_azuread_auth     = true  }} provider "azurerm" {  features {}  subscription_id = "00000000-0000-0000-0000-000000000000"} module "sede_madrid" {  source         = "../../modules/sede-vm"  nombre         = "mad"  ubicacion      = "spaincentral"  rango_red      = "10.50.0.0/16"  tamano_vm      = "Standard_B2ms"  admin_password = var.admin_password  etiquetas      = { entorno = "prod", responsable = "laura.prado" }}
  • El módulo sede-vm contiene azurerm_resource_group, azurerm_virtual_network, azurerm_subnet, azurerm_network_security_group, azurerm_network_interface y azurerm_windows_virtual_machine, con sus variables y salidas. Todo llega por variable.
  • Versiones acotadas (~> 4.0) y el .terraform.lock.hcl en el repositorio, para que todos usen el mismo proveedor.

2. Estado remoto con bloqueo

El estado dice qué recurso real corresponde a cada bloque. En el portátil de alguien no sirve para un equipo.

  • azurerm: el estado vive en un blob y el bloqueo usa el lease del blob, sin nada más que configurar. Con use_azuread_auth = true el acceso es con identidad de Entra ID (rol Storage Blob Data Contributor), no con la clave de la cuenta.
  • S3 (en AWS): desde Terraform 1.10, use_lockfile = true bloquea con un fichero en el propio bucket; la tabla de DynamoDB queda obsoleta.
  • Una clave de estado por entorno y componente (prod/sede-vm.tfstate, prod/red-core.tfstate). Un estado gigante hace que un error alcance a todo.

Si un apply se corta, el bloqueo puede quedarse puesto. terraform force-unlock <ID> solo tras confirmar que no hay otro apply en marcha: dos procesos escribiendo corrompen el estado.

3. Plan y apply en el pipeline, con revisión

Nadie ejecuta apply desde su equipo contra producción. El flujo:

  1. Pull request → el pipeline ejecuta terraform fmt -check, terraform validate y terraform plan -out=tfplan, y publica el plan como comentario o artefacto.
  2. Un compañero lee el plan, no solo el código. Lo que importa es qué se crea, qué cambia y, sobre todo, qué se destruye.
  3. Merge a main → el pipeline aplica ese mismo plan guardado (terraform apply tfplan) tras la aprobación del entorno (environments de Azure DevOps o de GitHub).
Pipeline · etapa plan (PR 214)
terraform init -input=falseterraform plan -input=false -out=tfplanTerraform used the selected providers to generate the following executionplan. Resource actions are indicated with the following symbols:  ~ update in-place Terraform will perform the following actions:   # module.sede_madrid.azurerm_network_security_group.vm will be updated in-place  ~ resource "azurerm_network_security_group" "vm" {        id   = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-sede-mad/providers/Microsoft.Network/networkSecurityGroups/nsg-srv-mad01"        name = "nsg-srv-mad01"      ~ security_rule = [          + {              + name                       = "allow-https-oficina"              + source_address_prefix      = "192.168.10.0/24"              + priority                   = 200                # (10 unchanged attributes hidden)            },        ]    } Plan: 0 to add, 1 to change, 0 to destroy.

Una regla útil en la revisión: cualquier plan con destroy o con -/+ (replace) en un recurso con datos (VM, disco, base de datos) se para y se habla.

4. "Alguien lo cambió en el portal": la deriva

Un viernes por la noche alguien amplió la VM desde el portal para salir del paso. El siguiente plan lo detecta:

terraform plan -refresh-only
terraform plan -refresh-onlyNote: Objects have changed outside of Terraform Terraform detected the following changes made outside of Terraform since thelast "terraform apply" which may have affected this plan:   # module.sede_madrid.azurerm_windows_virtual_machine.app has changed  ~ resource "azurerm_windows_virtual_machine" "app" {        id   = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-sede-mad/providers/Microsoft.Compute/virtualMachines/srv-mad01"        name = "srv-mad01"      ~ size = "Standard_B2ms" -> "Standard_D4s_v5"        tags = {            "entorno"     = "prod"            "responsable" = "laura.prado"        }    }

Hay dos salidas, y se decide con el responsable del servicio, no en solitario:

  • El cambio era necesario: se lleva al código (tamano_vm = "Standard_D4s_v5") por pull request. El plan siguiente queda vacío.
  • No lo era: un plan normal propone devolverla a Standard_B2ms, y se aplica en una ventana (cambiar el tamaño reinicia la VM).

Un plan programado cada noche contra cada entorno, que avisa si sale algo distinto de No changes, convierte la deriva en un aviso en lugar de una sorpresa. Y sin rol Contributor permanente para las personas en producción (lectura más PIM), la deriva es la excepción.

5. Importar lo que ya existe

El servidor de impresión se creó a mano hace años. Desde Terraform 1.5, la importación se declara en el código y pasa por el mismo plan revisado:

import.tf + plan
cat import.tfimport {  to = azurerm_windows_virtual_machine.impresion  id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-sede-mad/providers/Microsoft.Compute/virtualMachines/srv-print01"}terraform plan -generate-config-out=generado.tfazurerm_windows_virtual_machine.impresion: Preparing import... [id=/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-sede-mad/providers/Microsoft.Compute/virtualMachines/srv-print01] Terraform will perform the following actions:   # azurerm_windows_virtual_machine.impresion will be imported  # (config will be generated) Plan: 1 to import, 0 to add, 0 to change, 0 to destroy.

generado.tf es un punto de partida, no código final: se limpia (fuera atributos por defecto, valores a variables), se repite el plan hasta que diga 1 to import, 0 to change y se aplica.

6. Higiene del estado y secretos

  • Mover sin destruir: al renombrar un recurso o meterlo en un módulo, un bloque moved { from = ... to = ... } evita que Terraform lo destruya y lo cree de nuevo. Para dejar de gestionar algo sin borrarlo, un bloque removed (1.7+). Ambos pasan por revisión, a diferencia de terraform state mv o state rm.
  • El estado no se edita a mano. Si algo se rompe, el versionado del blob permite volver a la versión anterior.
  • Secretos fuera del código: nada de contraseñas en .tfvars dentro del repositorio. La contraseña de administración se genera con random_password y se guarda en Key Vault, o llega como variable sensitive desde el almacén de secretos del pipeline.
  • Ojo: sensitive oculta el valor en la salida, pero el estado lo guarda en claro. El acceso al contenedor del estado se restringe como el de Key Vault; los argumentos write-only (Terraform 1.11+) evitan que lleguen al estado cuando el proveedor los admite.

7. Lo que revisas cada mes

  • El plan nocturno de deriva: entornos con cambios pendientes y por qué.
  • Quién tiene permisos de escritura sobre el contenedor del estado y sobre las suscripciones, además del pipeline.
  • Recursos en el portal que no están en ningún estado (Azure Resource Graph por etiqueta gestionado-por): candidatos a importar o a borrar.

Siguientes pasos

  • La guía de Amazon AWS: la misma disciplina con el backend S3 y el proveedor aws.
  • La guía de Jenkins (CI/CD) para montar el pipeline si no usáis Azure DevOps o GitHub Actions, y la de Ansible para configurar dentro de las VM lo que Terraform crea.
  • La guía de Kubernetes para crear el clúster AKS con este mismo flujo.

¿Falta algo o en tu entorno es distinto? Escríbeme y lo corrijo.

What you'll get

Infrastructure for a site or a service that comes out of a reviewed repository and not from clicks in the portal. A reusable module, a shared state nobody tramples on, a pipeline where the change is read before it is applied, and an orderly way of dealing with what was changed by hand or already existed before Terraform.

Before you start

  • Terraform 1.10 or later and the azurerm 4.x provider.
  • A Git repository with protected branches: main is reached only through a reviewed pull request (Git guide).
  • A storage account for state, created separately, with blob versioning and soft delete enabled.
  • An identity for the pipeline: managed identity or Entra ID application with workload identity federation (OIDC), with Contributor only on the subscriptions it manages. No stored client secrets.

1. The module: resource group, network and VM

Modules in modules/ and one folder per environment (envs/prod, envs/test) that uses them with its own values.

repo infra-azure
cat envs/prod/main.tfterraform {  required_version = ">= 1.10"  required_providers {    azurerm = { source = "hashicorp/azurerm", version = "~> 4.0" }  }  backend "azurerm" {    resource_group_name  = "rg-tfstate"    storage_account_name = "sttfstatecontoso01"    container_name       = "tfstate"    key                  = "prod/site-vm.tfstate"    use_azuread_auth     = true  }} provider "azurerm" {  features {}  subscription_id = "00000000-0000-0000-0000-000000000000"} module "site_madrid" {  source         = "../../modules/site-vm"  name           = "mad"  location       = "spaincentral"  address_space  = "10.50.0.0/16"  vm_size        = "Standard_B2ms"  admin_password = var.admin_password  tags           = { environment = "prod", owner = "laura.prado" }}
  • The site-vm module contains azurerm_resource_group, azurerm_virtual_network, azurerm_subnet, azurerm_network_security_group, azurerm_network_interface and azurerm_windows_virtual_machine, with their variables and outputs. Everything comes in through variables.
  • Constrained versions (~> 4.0) and .terraform.lock.hcl committed, so everyone uses the same provider.

2. Remote state with locking

State records which real resource corresponds to each block. On someone's laptop it is no use to a team.

  • azurerm: state lives in a blob and locking uses the blob lease, with nothing else to configure. With use_azuread_auth = true access uses an Entra ID identity (Storage Blob Data Contributor role), not the account key.
  • S3 (on AWS): since Terraform 1.10, use_lockfile = true locks with a file in the bucket itself; the DynamoDB table is deprecated.
  • One state key per environment and component (prod/site-vm.tfstate, prod/core-network.tfstate). A giant state lets one mistake reach everything.

If an apply is cut off, the lock can stay in place. terraform force-unlock <ID> only after confirming no other apply is running: two processes writing will corrupt the state.

3. Plan and apply in the pipeline, with review

Nobody runs apply from their own machine against production. The flow:

  1. Pull request → the pipeline runs terraform fmt -check, terraform validate and terraform plan -out=tfplan, and publishes the plan as a comment or artefact.
  2. A colleague reads the plan, not just the code. What matters is what gets created, what changes and, above all, what gets destroyed.
  3. Merge to main → the pipeline applies that same saved plan (terraform apply tfplan) after the environment approval (Azure DevOps or GitHub environments).
Pipeline · plan stage (PR 214)
terraform init -input=falseterraform plan -input=false -out=tfplanTerraform used the selected providers to generate the following executionplan. Resource actions are indicated with the following symbols:  ~ update in-place Terraform will perform the following actions:   # module.site_madrid.azurerm_network_security_group.vm will be updated in-place  ~ resource "azurerm_network_security_group" "vm" {        id   = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-site-mad/providers/Microsoft.Network/networkSecurityGroups/nsg-srv-mad01"        name = "nsg-srv-mad01"      ~ security_rule = [          + {              + name                       = "allow-https-office"              + source_address_prefix      = "192.168.10.0/24"              + priority                   = 200                # (10 unchanged attributes hidden)            },        ]    } Plan: 0 to add, 1 to change, 0 to destroy.

A useful review rule: any plan with destroy or -/+ (replace) on a resource holding data (VM, disk, database) stops and gets discussed.

4. "Someone changed it in the portal": drift

On a Friday night someone resized the VM from the portal to get by. The next plan spots it:

terraform plan -refresh-only
terraform plan -refresh-onlyNote: Objects have changed outside of Terraform Terraform detected the following changes made outside of Terraform since thelast "terraform apply" which may have affected this plan:   # module.site_madrid.azurerm_windows_virtual_machine.app has changed  ~ resource "azurerm_windows_virtual_machine" "app" {        id   = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-site-mad/providers/Microsoft.Compute/virtualMachines/srv-mad01"        name = "srv-mad01"      ~ size = "Standard_B2ms" -> "Standard_D4s_v5"        tags = {            "environment" = "prod"            "owner"       = "laura.prado"        }    }

There are two ways out, and you decide with the service owner, not alone:

  • The change was needed: it goes into the code (vm_size = "Standard_D4s_v5") through a pull request. The next plan comes out empty.
  • It wasn't: a normal plan proposes putting it back to Standard_B2ms, and it is applied in a window (resizing restarts the VM).

A nightly scheduled plan against each environment, alerting if anything other than No changes comes out, turns drift into an alert instead of a surprise. And with no standing Contributor for people in production (read plus PIM), drift is the exception.

5. Importing what already exists

The print server was created by hand years ago. Since Terraform 1.5, import is declared in code and goes through the same reviewed plan:

import.tf + plan
cat import.tfimport {  to = azurerm_windows_virtual_machine.print  id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-site-mad/providers/Microsoft.Compute/virtualMachines/srv-print01"}terraform plan -generate-config-out=generated.tfazurerm_windows_virtual_machine.print: Preparing import... [id=/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-site-mad/providers/Microsoft.Compute/virtualMachines/srv-print01] Terraform will perform the following actions:   # azurerm_windows_virtual_machine.print will be imported  # (config will be generated) Plan: 1 to import, 0 to add, 0 to change, 0 to destroy.

generated.tf is a starting point, not final code: clean it up (drop default attributes, move values to variables), repeat the plan until it says 1 to import, 0 to change and apply.

6. State hygiene and secrets

  • Move without destroying: when renaming a resource or moving it into a module, a moved { from = ... to = ... } block stops Terraform destroying and recreating it. To stop managing something without deleting it, a removed block (1.7+). Both go through review, unlike terraform state mv or state rm.
  • State is not edited by hand. If something breaks, blob versioning lets you go back to the previous version.
  • Secrets out of the code: no passwords in .tfvars inside the repository. The admin password is generated with random_password and stored in Key Vault, or arrives as a sensitive variable from the pipeline's secret store.
  • Careful: sensitive hides the value in output, but state stores it in clear text. Access to the state container is restricted like Key Vault's; write-only arguments (Terraform 1.11+) keep secrets out of state where the provider supports them.

7. What you review each month

  • The nightly drift plan: environments with pending changes and why.
  • Who has write access to the state container and to the subscriptions, besides the pipeline.
  • Resources in the portal that aren't in any state (Azure Resource Graph by a managed-by tag): candidates to import or delete.

Next steps

  • The Amazon AWS guide: the same discipline with the S3 backend and the aws provider.
  • The Jenkins (CI/CD) guide to build the pipeline if you don't use Azure DevOps or GitHub Actions, and the Ansible guide to configure inside the VMs what Terraform creates.
  • The Kubernetes guide to create the AKS cluster with this same flow.

Is something missing, or different in your environment? Get in touch and I'll fix it.

Tech Career Lab · newsletter

¿Te ha servido? Cada dos semanas envío una idea práctica para perfiles IT: LinkedIn, proyectos, networking y procesos de selección.Found this useful? Every two weeks I send one practical idea for IT professionals: LinkedIn, projects, networking and hiring processes.

Apúntate y llévate las 33 ideasSign up and get the 33 ideas →