🆚 Terraform frente a ARM, Bicep, Pulumi y Ansible: tres preguntas que deciden¶
1. Las tres preguntas, herramienta por herramienta¶
ARM y Bicep comparten fila: Bicep es un lenguaje que compila a ARM JSON y se despliega con el mismo servicio. Terraform y OpenTofu también: OpenTofu es una bifurcación compatible con distinta licencia.
| Pregunta | Terraform / OpenTofu | ARM / Bicep | Pulumi | Ansible |
|---|---|---|---|---|
| ¿Dónde vive el estado? | Fichero explícito —local o backend remoto—. Sabe qué recurso es suyo y guarda todo lo que la API devolvió. | En Azure: los recursos mismos y el historial de despliegues. No hay fichero que proteger… ni que consultar. | Fichero explícito, en Pulumi Cloud o en un backend propio —Azure Blob, fichero—. | No hay. Cada ejecución consulta la realidad tarea por tarea. |
| ¿Quién calcula la diferencia? | plan: código frente a estado frente a realidad, con acciones por recurso. Se guarda como fichero y se aplica ese mismo fichero. |
what-if: ARM evalúa la plantilla contra lo existente. Informativo; el despliegue posterior recalcula, y hay "ruido" documentado. |
preview: mismo modelo que Terraform. |
--check --diff: cada módulo dice si cambiaría algo. Sin grafo, sin visión de conjunto. |
| ¿Qué pasa al quitar un recurso del código? | El plan dice destroy. Quitar código es una orden de borrado —página 1, paso 8—. |
Modo Incremental —por defecto—: nada, el recurso sigue. Complete: borra todo lo del grupo que no esté en la plantilla, tuyo o no. Deployment stacks: borra lo que el stack gestionaba —actionOnUnmanage—. |
Como Terraform. | Nada. Hay que escribir una tarea con state: absent y ejecutarla. |
| Alcance | Cualquier API con provider, en el mismo grafo: Azure, Entra ID, GitHub, DNS, Kubernetes, contraseñas aleatorias, claves TLS. Recursos de día 0 con azapi. |
Azure Resource Manager, con soporte de día 0 de cada API. Extensiones —Microsoft Graph— en expansión. | Como Terraform: providers nativos más los de Terraform vía puente. | Colecciones cloud —azure.azcollection—, y su fuerte: lo que hay dentro de la máquina. |
| Lenguaje y pruebas | HCL. terraform test nativo —1.6+—, tflint, trivy, conftest; Terratest en Go. |
Bicep —DSL— o JSON. Linter de Bicep, ARM-TTK, PSRule, what-if. | TypeScript, Python, Go, C#, Java, YAML. Pruebas con el framework del lenguaje; CrossGuard para políticas. | YAML. ansible-lint, Molecule. |
| Licencia y coste | Terraform: BSL 1.1 desde 1.6 —gratis salvo para productos que compitan con HashiCorp—. OpenTofu: MPL 2.0, Linux Foundation. HCP Terraform: freemium. | Incluido en Azure; Bicep es MIT. | CLI y providers Apache 2.0; Pulumi Cloud freemium; backend propio gratis. | GPL 3.0; Ansible Automation Platform de pago. |
| ¿Funciona con Topaz? | Sí: es lo que usa el curso. | az bicep build/lint siempre —son locales—. az deployment group según la versión implemente Microsoft.Resources/deployments; stacks, no. |
El provider azure-native no documenta un endpoint ARM personalizado: Azure real. |
cloud_environment admite una URL de metadatos —pensado para Azure Stack—; sin garantía: Azure real. |
2. El mismo recurso en cinco sintaxis¶
El storage de moodledata con las dos propiedades de seguridad que el curso
exige en todas partes: TLS 1.2 mínimo y sin claves compartidas. Compara qué es
igual —las propiedades son las de la API de Azure en todos los casos— y qué no
—cómo se expresa la dependencia con el grupo—.
Terraform —HCL—¶
resource "azurerm_resource_group" "moodle" {
name = "rg-moodle"
location = "eastus"
}
resource "azurerm_storage_account" "datos" {
name = "stmoodledata01"
resource_group_name = azurerm_resource_group.moodle.name # la dependencia es la referencia
location = azurerm_resource_group.moodle.location
account_tier = "Standard"
account_replication_type = "LRS"
min_tls_version = "TLS1_2"
shared_access_key_enabled = false
}
Bicep¶
// Alcance: grupo de recursos. El grupo lo crea otra cosa
// (az, o una plantilla de alcance suscripción).
param location string = resourceGroup().location
param nombre string = 'stmoodledata01'
resource datos 'Microsoft.Storage/storageAccounts@2023-05-01' = {
// Tipo y versión de API explícitos: soporte de día 0.
name: nombre
location: location
sku: {
name: 'Standard_LRS'
}
kind: 'StorageV2'
properties: {
minimumTlsVersion: 'TLS1_2'
allowSharedKeyAccess: false
}
}
ARM JSON —lo que Bicep compila; recortado—¶
{
"$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#",
"contentVersion": "1.0.0.0",
"parameters": {
"location": {
"type": "string",
"defaultValue": "[resourceGroup().location]"
},
"nombre": {
"type": "string",
"defaultValue": "stmoodledata01"
}
},
"resources": [
{
"type": "Microsoft.Storage/storageAccounts",
"apiVersion": "2023-05-01",
"name": "[parameters('nombre')]",
"location": "[parameters('location')]",
"sku": {
"name": "Standard_LRS"
},
"kind": "StorageV2",
"properties": {
"minimumTlsVersion": "TLS1_2",
"allowSharedKeyAccess": false
}
}
]
}
Es el formato que ARM recibe siempre: Bicep lo genera con az bicep build.
Escribirlo a mano ya no es la recomendación de Microsoft; leerlo sí conviene,
porque es lo que aparece en el portal —Exportar plantilla— y en los errores de
despliegue.
Pulumi —TypeScript, provider azure-native—¶
import * as resources from "@pulumi/azure-native/resources";
import * as storage from "@pulumi/azure-native/storage";
const moodle = new resources.ResourceGroup("moodle", {
resourceGroupName: "rg-moodle",
location: "eastus",
});
const datos = new storage.StorageAccount("datos", {
accountName: "stmoodledata01",
resourceGroupName: moodle.name, // la dependencia es la referencia, como en Terraform
location: moodle.location,
sku: {
name: storage.SkuName.Standard_LRS,
},
kind: storage.Kind.StorageV2,
minimumTlsVersion: storage.MinimumTlsVersion.TLS1_2,
allowSharedKeyAccess: false,
});
// Sintaxis de programa, modelo declarativo: el código construye un grafo;
// "pulumi preview" calcula la diferencia con el estado.
// Un bucle "for" aquí es un "for_each" en HCL, no una repetición de llamadas.
Ansible —colección azure.azcollection—¶
- hosts: localhost
connection: local
tasks:
- name: Grupo de recursos
azure.azcollection.azure_rm_resourcegroup:
name: rg-moodle
location: eastus
- name: Storage de moodledata
azure.azcollection.azure_rm_storageaccount:
resource_group: rg-moodle # la dependencia es el orden de las tareas
name: stmoodledata01
account_type: Standard_LRS
kind: StorageV2
minimum_tls_version: TLS1_2
allow_shared_key_access: false
state: present # para borrar: state: absent, y volver a ejecutar. Quitar la tarea no borra nada.
🔷 Lo que es igual y lo que no
Las propiedades —Standard_LRS, TLS1_2, sin claves compartidas— son las
mismas en las cinco: todas hablan con la misma API.
Cambia cómo se nombra el tipo —Terraform y Ansible con nombres propios del
provider; Bicep, ARM y Pulumi con el tipo y la versión de la API—, cómo se
declara la dependencia —referencia, orden de tareas o dependsOn— y, sobre
todo, qué pasa cuando el bloque desaparece. Eso es lo que prueba el
laboratorio.
3. Elegir por escenario, con razones que se puedan defender¶
| Escenario | Herramienta | Razón real |
|---|---|---|
| Azure y algo más en el mismo despliegue: GitHub —repos, environments—, Entra ID —grupos, aplicaciones—, DNS externo, Kubernetes, contraseñas generadas. | Terraform / OpenTofu | Un solo grafo y un solo plan para todo. Es el caso de este curso: el pipeline de la página 13 crea la identidad federada, el repositorio y los recursos en la misma configuración. |
| 100 % Azure, equipo de plataforma que quiere cada API el día que sale. | Bicep | Soporte de día 0, sin fichero de estado que proteger, integración con deployment stacks, Policy y Template Specs. Aquí Bicep es mejor que Terraform, y hay que decirlo. |
| Plantillas exportadas del portal, Marketplace, Azure Quickstart. | ARM JSON —leer—, Bicep —escribir— | az bicep decompile convierte JSON en Bicep. Nadie debería escribir JSON a mano en 2026. |
| Equipo de desarrollo que quiere pruebas unitarias, tipos y abstracciones de su lenguaje. | Pulumi | Mismo modelo que Terraform con TypeScript, Python, Go o C#. El riesgo es la tentación de meter lógica imperativa en lo que debería ser una declaración. |
| Lo que hay dentro de la máquina: paquetes, ficheros, servicios, Moodle instalado y configurado. | Ansible —o cloud-init— | Complemento, no alternativa: Terraform crea la VM; Ansible o cloud-init la configuran. El curso usa cloud-init por no añadir otra herramienta —página 8—. |
| Organización con normas de licencia estrictas sobre código abierto. | OpenTofu o Bicep | Terraform es BSL desde 1.6; OpenTofu —MPL 2.0— y Bicep —MIT— son código abierto sin restricciones de uso. |
| Ya hay un equipo experto en una de ellas. | Esa | La diferencia entre Terraform y Bicep bien usados es menor que la diferencia entre cualquiera de las dos bien usada y mal usada. |
4. Por qué este curso usa Terraform, y qué se pierde¶
El original dice "liderazgo en el mercado, soporte multi-cloud e integración con el ecosistema HashiCorp". Ninguna de las tres es la razón de verdad: el curso no despliega en dos nubes ni usa Vault o Consul. Las razones son estas cuatro.
- El estado explícito enseña más. Tener que decidir dónde vive, cómo se bloquea y quién lo lee —página 4— obliga a entender qué es y qué contiene. Con Bicep, ese conocimiento se puede posponer, y suele posponerse hasta el primer incidente.
- El plan es un artefacto. Se guarda, se comenta en la PR, se evalúa con
políticas y se aplica exactamente ese fichero —páginas 13 a 15—.
what-ifes un informe; el despliegue recalcula. - Un grafo para todo. La identidad federada en Entra ID, el environment en GitHub y el Key Vault en Azure se crean y se destruyen juntos, con dependencias entre ellos.
- Quitar código borra. Un entorno de pruebas se destruye con un comando, sin modo Complete que arrastre lo que no era tuyo —2.5, paso 3—.
Lo que se pierde, y conviene saberlo: soporte de día 0 de cada API —azapi lo
mitiga, página 8—, un fichero de estado que proteger —página 4—, y una licencia
que no es código abierto —OpenTofu si importa—. Todo lo que el curso enseña
vale para Pulumi sin cambios de concepto, y para Bicep con un cambio: donde aquí
se dice "estado", allí se dice "Azure".
5. Laboratorio en Topaz¶
El mismo storage con Terraform y con Bicep, cada uno en su grupo. Después se retira del código en los dos y se observa qué hace cada herramienta.
az bicep build, lint y decompile son locales y funcionan siempre;
az deployment group depende de que la versión de Topaz implemente
Microsoft.Resources/deployments: si devuelve un error de tipo no soportado,
el bloque de Bicep pasa al de Azure real y el de Terraform sigue siendo válido.
az account show --query environmentName -o tsv # Topaz
mkdir -p ~/tf-vs/{tf,bicep} && cd ~/tf-vs/tf && cp ~/tf-st/providers.tf .
# ─── 1. Terraform: crear, retirar del código, ver el plan ────────────────────────
cat > main.tf <<'EOF'
resource "azurerm_resource_group" "moodle" {
name = "rg-vs-tf"
location = "eastus"
}
resource "azurerm_storage_account" "datos" {
name = "stvstf01"
resource_group_name = azurerm_resource_group.moodle.name
location = azurerm_resource_group.moodle.location
account_tier = "Standard"
account_replication_type = "LRS"
min_tls_version = "TLS1_2"
shared_access_key_enabled = false
}
EOF
terraform init >/dev/null && terraform apply -auto-approve
# El estado sabe qué dos recursos son suyos.
terraform state list
cp main.tf main.tf.completo
sed -i '/resource "azurerm_storage_account" "datos"/,/^}/d' main.tf
# Plan: 0 to add, 0 to change, 1 to destroy.
# Quitar código es una orden.
terraform plan
terraform apply -auto-approve
# Vacío.
az storage account list \
-g rg-vs-tf \
--query "[].name" \
-o tsv
# Lo volvemos a crear para el paso 4.
cp main.tf.completo main.tf && terraform apply -auto-approve
# ─── 2. Bicep: lo local funciona siempre ─────────────────────────────────────────
cd ../bicep
cat > main.bicep <<'EOF'
param location string = resourceGroup().location
param nombre string = 'stvsbicep01'
resource datos 'Microsoft.Storage/storageAccounts@2023-05-01' = {
name: nombre
location: location
sku: {
name: 'Standard_LRS'
}
kind: 'StorageV2'
properties: {
minimumTlsVersion: 'TLS1_2'
allowSharedKeyAccess: false
}
}
output id string = datos.id
EOF
# Sin avisos.
az bicep lint -f main.bicep
# El JSON que ARM recibe.
az bicep build -f main.bicep &&
jq '.resources[0] | {type, apiVersion, properties}' main.json
# Decompile sobre lo compilado: nombres de símbolo cambian, semántica no.
az bicep decompile -f main.json --force &&
diff <(sed 's/ *$//' main.bicep) <(sed 's/ *$//' main.bicep) >/dev/null &&
echo "ida y vuelta"
# ─── 3. Bicep: crear, retirar del código, ver qué pasa ───────────────────────────
az group create -n rg-vs-bicep -l eastus -o none
# + Microsoft.Storage/storageAccounts/stvsbicep01
# El "plan" de ARM.
az deployment group what-if \
-g rg-vs-bicep \
-f main.bicep
az deployment group create \
-g rg-vs-bicep \
-f main.bicep \
-n primero \
-o none
# El historial: aquí vive la memoria de ARM.
az deployment group list \
-g rg-vs-bicep \
--query "[].{nombre:name, modo:properties.mode, estado:properties.provisioningState}" \
-o table
cat > vacio.bicep <<'EOF'
output nota string = 'plantilla sin recursos'
EOF
# "No change" o "Ignore": Incremental no toca lo que no nombra.
az deployment group what-if \
-g rg-vs-bicep \
-f vacio.bicep
az deployment group create \
-g rg-vs-bicep \
-f vacio.bicep \
-n segundo \
-o none
# stvsbicep01 SIGUE AHÍ: quitar código no borra.
az storage account list \
-g rg-vs-bicep \
--query "[].name" \
-o tsv
# - Microsoft.Storage/storageAccounts/stvsbicep01: ahora sí.
az deployment group what-if \
-g rg-vs-bicep \
-f vacio.bicep \
--mode Complete
az deployment group create \
-g rg-vs-bicep \
-f vacio.bicep \
-n tercero \
--mode Complete \
-o none
# Vacío.
az storage account list \
-g rg-vs-bicep \
--query "[].name" \
-o tsv
# Complete borra TODO lo del grupo que no esté en la plantilla,
# lo haya creado esta plantilla, otra, el portal o un compañero.
#
# Terraform borra lo que está en SU estado y ha desaparecido de SU código.
# Son dos respuestas distintas a la tercera pregunta.
# ─── 4. Dónde vive el estado, lado a lado ────────────────────────────────────────
cd ../tf
# Fichero local: sabe TLS, id, todo.
terraform show -json |
jq '.values.root_module.resources[] | {address, tls: .values.min_tls_version}'
# ARM: sabe qué ids creó ese despliegue, nada más.
az deployment group show \
-g rg-vs-bicep \
-n primero \
--query "properties.outputResources[].id" \
-o tsv
# Para saber el TLS del storage de Bicep hay que preguntar al recurso:
# az storage account show.
#
# No hay "estado" que leer, porque el estado es Azure.
# ─── 5. Limpiar ──────────────────────────────────────────────────────────────────
terraform destroy -auto-approve
az group delete -n rg-vs-bicep --yes --no-wait
cd ~ && rm -rf ~/tf-vs
Solo Azure real¶
# ─── Solo Azure real ─────────────────────────────────────────────────────────────
# A. Deployment stacks: la respuesta moderna de Bicep a "quitar código borra",
# sin el peligro de Complete.
az stack group create \
-n moodle \
-g rg-vs-bicep \
-f main.bicep \
--action-on-unmanage deleteResources \
--deny-settings-mode none
az stack group create \
-n moodle \
-g rg-vs-bicep \
-f vacio.bicep \
--action-on-unmanage deleteResources \
--deny-settings-mode none
# Vacío: el stack borró lo que él gestionaba, y solo eso.
az storage account list \
-g rg-vs-bicep \
--query "[].name" \
-o tsv
# --deny-settings-mode denyDelete añade además lo que en Terraform+Azure
# son los bloqueos de la página 15, gestionado por el propio stack.
az stack group delete \
-n moodle \
-g rg-vs-bicep \
--action-on-unmanage deleteResources \
--yes
# B. Pulumi: mismo modelo que Terraform, estado en un Blob propio.
# Backend propio: sin Pulumi Cloud.
pulumi login 'azblob://estado-pulumi?storage_account=stestadocurso'
pulumi new azure-typescript \
-y \
--name moodle-vs
# El código de 2.2.
cp ~/curso/ejemplos/02/index.ts .
# + 2 to create: el "plan".
pulumi preview
# El estado, exportable y legible como el de Terraform.
pulumi up -y &&
pulumi stack export |
jq '.deployment.resources[].urn'
# - 1 to delete: quitar código borra, como en Terraform.
sed -i '/new storage.StorageAccount/,/^});/d' index.ts &&
pulumi preview
pulumi destroy -y
# C. Ansible: comprobación sin grafo, borrado explícito.
ansible-galaxy collection install azure.azcollection &&
pip install \
-r ~/.ansible/collections/ansible_collections/azure/azcollection/requirements.txt
# Cada tarea dice "changed" o "ok":
# es el "plan" de Ansible, tarea a tarea.
ansible-playbook storage.yml --check --diff
# Segunda vez: ok=2 changed=0. Idempotente.
ansible-playbook storage.yml &&
ansible-playbook storage.yml
# Quitar la tarea: ok=1, el storage sigue.
sed -i '/name: Storage de moodledata/,$d' storage.yml &&
ansible-playbook storage.yml
# Para borrarlo: state: absent en la tarea, y ejecutar.
# Ansible no recuerda lo que hizo; solo sabe lo que le dices ahora.
6. Errores comunes¶
Danger
| Idea o síntoma | Qué pasa en realidad |
|---|---|
| "Bicep no tiene estado" —el original—. | Azure es el estado: los recursos y el historial de despliegues. No hay fichero que proteger, pero tampoco una vista completa que consultar sin preguntar recurso a recurso —paso 4—. |
| "Terraform es código abierto" —el original—. | BSL 1.1 desde agosto de 2023 —versión 1.6—. Gratis para casi todos los usos; no cumple la definición de código abierto. OpenTofu sí —MPL 2.0—. |
| "Terraform solo se prueba con Terratest" —el original—. | terraform test es nativo desde 1.6 y escribe las pruebas en HCL —página 9—. Terratest sigue existiendo para pruebas de integración en Go. |
| "Ansible no tiene planificación" —el original—. | --check --diff muestra qué cambiaría cada tarea. Lo que no hay es grafo ni borrados implícitos: no puede decir "esto sobra". |
| Recomendar ARM JSON a "equipos con experiencia en ARM" —el original—. | Bicep compila a ARM JSON: la experiencia se conserva y la sintaxis mejora. az bicep decompile convierte lo existente. |
| "Pulumi es de pago" —el original: freemium—. | CLI y providers Apache 2.0. Lo freemium es Pulumi Cloud, el backend gestionado; el estado puede ir a un Blob propio —bloque B—. |
Usar --mode Complete para "limpiar" un grupo compartido. |
Borra todo lo que la plantilla no nombra, lo haya creado quien lo haya creado. Con deployment stacks se borra solo lo gestionado; en Terraform, solo lo del estado. |
| Retirar un recurso de una plantilla Bicep y creer que desaparecerá. | En Incremental —por defecto— sigue ahí —paso 3—. Es la diferencia práctica más importante con Terraform y la causa habitual de recursos "fantasma" facturados. |
Tratar what-if como un plan aplicable. |
Es un informe; el despliegue recalcula, y hay diferencias documentadas —"ruido"— en algunos tipos. El plan de Terraform se guarda y se aplica ese mismo fichero —página 14—. |
| Mezclar dos herramientas sobre los mismos recursos. | Cada una cree que el recurso es suyo y revierte lo que hizo la otra: deriva perpetua. Una herramienta por recurso; si conviven, por grupo de recursos o por capa —Terraform la infraestructura, Ansible el interior de la VM—. |
Un bucle for en Pulumi que llama a la API directamente. |
Es imperativo dentro de lo declarativo: el estado no lo ve. El bucle debe construir recursos —new storage.StorageAccount—, no ejecutar acciones. |
En Topaz: az deployment group create falla con un tipo no soportado. |
La versión no implementa Microsoft.Resources/deployments. bicep build/lint/decompile siguen funcionando —son locales—; el paso 3 pasa al bloque de Azure real. El de Terraform no se ve afectado. |
7. Autoevaluación¶
-
¿Cuáles son las tres preguntas que distinguen los modelos?
Dónde vive el estado, quién calcula la diferencia y qué pasa al quitar un recurso del código.
-
¿Dónde vive el estado en Bicep?
En Azure: los recursos y el historial de despliegues. No hay fichero; tampoco una vista agregada sin consultar recurso a recurso.
-
¿Qué pasa al retirar un recurso de una plantilla Bicep en modo Incremental? ¿Y en Complete?
Incremental: nada, sigue existiendo. Complete: se borra, y también todo lo del grupo que la plantilla no nombre, sea de quien sea.
-
¿Qué resuelven los deployment stacks?
Borrar al retirar código sin el peligro de Complete: el stack recuerda qué gestiona y solo actúa sobre eso —
actionOnUnmanage—, y puede añadir protección de borrado. -
¿En qué se diferencia
what-ifdeterraform plan?What-if es un informe; el despliegue recalcula. El plan es un fichero que se guarda, se evalúa y se aplica tal cual.
-
¿Es Terraform código abierto?
No desde 1.6: BSL 1.1, gratis para casi todo uso pero con restricciones. OpenTofu —MPL 2.0— es la bifurcación abierta compatible.
-
¿Por qué Pulumi y Terraform se comportan igual al quitar código?
Comparten modelo: estado explícito y diferencia calculada. Cambia la sintaxis —lenguaje general frente a HCL—, no el modelo.
-
¿Cómo se borra un recurso con Ansible?
Con una tarea
state: absentejecutada. Quitar la tarea no hace nada: Ansible no recuerda lo que hizo. -
Da un escenario donde Bicep sea mejor opción que Terraform.
100 % Azure con necesidad de cada API el día que sale, sin querer gestionar un fichero de estado, con stacks, Policy y Template Specs integrados.
-
¿Cuáles son las razones reales por las que este curso usa Terraform?
El estado explícito enseña más; el plan es un artefacto revisable; un grafo para Azure, Entra ID y GitHub; quitar código borra sin modo Complete.
-
¿Qué es igual en las cinco sintaxis de 2.2?
Las propiedades del recurso: todas hablan con la misma API. Cambia cómo se nombra el tipo, cómo se declara la dependencia y qué pasa al retirar el bloque.
-
¿Qué parte del laboratorio depende de la versión de Topaz?
az deployment group—paso 3—, que necesitaMicrosoft.Resources/deployments. Build, lint y decompile son locales; Terraform no se ve afectado.
8. Referencias¶
- Terraform frente a Bicep —Microsoft Learn; comparación oficial, honesta con ambos—.
- Modos de despliegue de ARM —Incremental y Complete— y Deployment stacks.
- What-if en Bicep —incluye la sección sobre resultados imprecisos—.
- Descompilar ARM JSON a Bicep.
- Preguntas frecuentes sobre la licencia BSL —HashiCorp— y OpenTofu —Linux Foundation—.
terraform testy Terraform frente a otras herramientas —HashiCorp—.- Pulumi azure-native y backends de estado de Pulumi —incluido Azure Blob—.
- Colección
azure.azcollectiony modo check y diff. - Azure Local Emulator —Topaz—.