DevOps IaC

Infrastructure as Code: Terraform, Pulumi, CDK and Cloud-Native Options

A team provisioned a production environment through the cloud console — a VPC here, a managed database there, a load balancer wired up by hand over three weeks of Friday afternoons. When the engineer who built it left, the runbook left with them. Six months later a region-wide incident forced a rebuild, and nobody could recreate the environment because nobody knew exactly what it contained: which subnet had the extra route, why one security group allowed an odd port, what the database parameter group overrode. Recovery took four days. The fix was not heroics; it was Infrastructure as Code (IaC) — every resource defined in a file, reviewed in a pull request, deployed by a pipeline, and reproducible from an empty subscription in twenty minutes.

That is the promise. The hard part is the choice. Terraform, OpenTofu, Pulumi, AWS CDK, Azure Bicep, AWS CloudFormation, Azure Resource Manager (ARM) templates, Google Cloud Deployment / Config Connector, and Ansible all let you describe infrastructure as text and converge reality to match — but they make opposite trade-offs on language, state, multi-cloud reach, drift handling, testing and blast radius. Pick wrong and you inherit a state file nobody can unlock during an incident, a DSL your team can’t read, or a cloud lock-in you can’t escape when the second region (or the second cloud) arrives. This article is the decision framework a 22-year cloud architect uses: not “Terraform good,” but which tool, for which boundary of infrastructure, on which team, against which constraints — and exactly what each one costs you.

By the end you will be able to defend a tool choice in an architecture review with specifics: declarative versus imperative and why it matters at 200 modules; how state, locking and drift actually work and where they bite; how modules and composition scale (or rot) across dozens of teams; how to test infrastructure before it touches production; how to enforce guardrails with policy as code; and when multi-cloud is a real requirement versus a slogan. Every claim comes with real HCL, TypeScript or Bicep, real commands, real failure modes, and tables you can scan mid-decision.

What problem this solves

Manual infrastructure is undocumented, inconsistent and fragile. The console is a write-only medium: you can make a change, but you cannot diff it, review it, or prove that staging matches production. Three engineers click three slightly different VPCs and call them “the same.” A misconfiguration ships because there was no plan to review. An environment drifts because someone hot-fixed a security group at 2am and never told anyone. When the incident comes, the gap between “what we think is deployed” and “what is actually deployed” is where the outage lives.

IaC closes that gap by making infrastructure versioned, reviewable, testable and reproducible. The desired state lives in Git; changes go through pull requests and a CI plan that shows exactly what will change before it changes; an apply converges reality to the code; and the same definition builds dev, staging and production identically. The infrastructure stops being tribal knowledge in one person’s head and becomes an artifact the whole team owns — one that also happens to build things.

What breaks without it: disaster recovery that takes days because no one can recreate the environment; configuration drift that makes every environment a unique snowflake; security regressions that ship because nobody reviewed a network change; and onboarding that takes weeks because the only documentation is the running system. Who hits this hardest: any team past a handful of resources, anyone running more than one environment, and every regulated organization that must prove what is deployed. The tool you pick determines how much of that pain IaC actually removes — and how much new pain (state files, DSL learning curves, lock-in) it adds. Choosing well is the whole game.

To frame the field before the deep dive, here is every tool this article weighs, the one sentence that defines it, and the single situation it is the best answer for:

Tool What it is in one line Language Multi-cloud Best single fit
Terraform Declarative provisioning with a provider model and explicit state HCL Yes (4,000+ providers) The portable default for multi-cloud, team-scale infra
OpenTofu Open-source (MPL) fork of Terraform after the license change HCL Yes Terraform users who need a permissive licence + community governance
Pulumi IaC in real languages (TS/Python/Go/C#) over the same provider engine TS/Python/Go/C#/Java Yes Code-centric teams wanting loops, types, tests, abstractions
AWS CDK Imperative code that synthesises CloudFormation TS/Python/Java/Go/C# AWS-only AWS-deep teams who want code ergonomics with native state
Azure Bicep Declarative DSL that transpiles to ARM Bicep DSL Azure-only Azure-deep teams wanting clean syntax + day-zero feature support
AWS CloudFormation AWS-native declarative templates with managed state YAML/JSON AWS-only AWS shops wanting no state file to manage, deep service integration
ARM templates Azure-native declarative JSON (Bicep’s compile target) JSON Azure-only Legacy/edge Azure cases Bicep doesn’t cover; the substrate
GCP Config Connector / Deployment GCP-native IaC (KRM via Config Connector; legacy Deployment Manager) YAML (KRM) GCP-only GKE/Kubernetes-native GCP platforms managing GCP via CRDs
Ansible Imperative/idempotent automation for config + some provisioning YAML (playbooks) Yes (modules) Config management, OS/app setup, agentless orchestration

Learning objectives

By the end of this article you can:

Prerequisites & where this fits

You should already be comfortable with the cloud you target (basic VPC/VNet, IAM/RBAC, a managed database, a load balancer), with Git and pull-request workflow, and with a CI/CD system that can run commands and store secrets. Familiarity with at least one IaC tool helps but is not required — this article assumes you are choosing, not migrating. You should know what a “state” or “desired-config” model means abstractly, and be able to read both YAML/JSON and a typed language like TypeScript at a glance.

This sits at the foundation of the platform engineering track. IaC is the substrate everything else stands on: your CI/CD Pipelines Explained: From Code Commit to Production is what runs the plan and apply; GitOps with Argo CD and Flux: Deliver from Git applies the same Git-as-source-of-truth principle to Kubernetes workloads; CI/CD Secrets and Credential Management: Secure Your Pipelines governs the credentials your IaC needs to authenticate; and Deployment Strategies: Blue-Green, Canary and Rolling Updates is what you do after the infrastructure exists. On the Azure side specifically, Bicep vs ARM vs Terraform: Choosing the Right IaC Tool for Azure is the narrower companion to this cross-cloud decision.

A quick map of where each concern lives, so you know which layer a problem belongs to:

Layer Concern What lives here Failure if you get it wrong
Definition The desired state HCL / TS / Bicep / YAML source in Git Unreviewed change ships; drift goes undocumented
Engine Plan + converge terraform, pulumi, aws cloudformation, az deployment Wrong order, no preview, surprise destroys
State What’s deployed tfstate / Pulumi state / managed by cloud Lost/corrupt state; concurrent-apply corruption
Backend Where state lives S3+DynamoDB / Azure Blob / Pulumi Cloud / cloud-managed No locking → two applies race and corrupt
Policy Guardrails OPA/Sentinel/CDK Nag/Azure Policy Non-compliant infra reaches production
Pipeline Automation CI plan + gated apply Local apply from a laptop with god rights

Core concepts

Six mental models make every later comparison obvious. Get these and the tool table reads itself.

Desired state, not steps. The defining idea of modern IaC is that you describe what you want — a VNet with this CIDR, a database of this SKU — and the engine computes the diff against reality and makes the minimum set of changes to converge. You do not write “create the VNet, then create the subnet”; you declare both and let the engine order them by dependency. This is declarative. The opposite, imperative, is writing the steps. The subtlety that trips people up: CDK and Pulumi let you write imperative code (loops, conditionals, functions) that produces a declarative artifact — the code runs once to generate the desired state, then a declarative engine converges it. So “imperative vs declarative” is really two questions: what does the author write, and what does the engine converge?

State is the engine’s memory. To compute a diff, the engine needs to know what it created last time. Terraform and Pulumi keep this in an explicit state file (terraform.tfstate / Pulumi’s state object) that maps your resources to real cloud IDs. CloudFormation, ARM, Bicep and Config Connector keep equivalent state inside the cloud control plane — the “stack” or “deployment” resource — so there is no file for you to manage, lock, or lose. This single difference drives a huge amount of the operational trade-off: explicit state is portable and inspectable but yours to secure and lock; managed state is one less thing to break but ties you to one cloud’s control plane.

Drift is reality diverging from code. Someone changes a resource outside IaC — a hot-fix in the console, an autoscaler, another tool — and now the running system no longer matches the definition. Every tool can detect drift; they differ in how, and in what they do about it. Drift is the silent killer of IaC’s value: once code and reality disagree, your “documentation” is lying, and the next apply may revert a critical hot-fix or fail outright.

Idempotence and convergence. Running the same definition twice should produce the same result and make no changes the second time. This is idempotence, and it is what makes IaC safe to run repeatedly in a pipeline. Declarative tools get it for free (the diff is empty if nothing changed). Imperative tools like Ansible achieve it through idempotent modules (each task checks current state before acting) — which is why Ansible can be “imperative-looking but idempotent,” a third category.

Providers and the resource graph. Terraform/Pulumi talk to clouds through providers (plugins that map resource types to APIs — azurerm, aws, google, plus 4,000+ others including Cloudflare, Datadog, GitHub). The engine builds a dependency graph from references between resources and walks it in order, parallelising independent branches. Cloud-native tools have an implicit single provider (their own cloud) and the cloud’s control plane builds the graph. More providers = more reach and more multi-cloud; one provider = deeper, day-zero support for that cloud’s newest features.

Blast radius and the unit of change. The stack/state/workspace is your unit of change and your blast radius. Put the whole company in one state and a bad apply can take everything down (and the plan takes ten minutes). Split too finely and you drown in cross-stack wiring. How a tool lets you slice state — Terraform workspaces and -target, Pulumi stacks, CloudFormation nested stacks and StackSets, Bicep modules and deployment scopes — determines how safely you can change one thing without risking everything.

The vocabulary in one table

Pin down every moving part before the deep sections. The glossary at the end repeats these for lookup; this is the mental model side by side:

Concept One-line definition Where it lives Why it matters
Declarative Describe the desired end state Source files Engine computes the diff; order is inferred
Imperative Describe the steps to take Source files / code Full control + logic; you own ordering & idempotence
State The engine’s record of what it made tfstate / Pulumi / cloud control plane Enables diffing; drives locking & secrets concerns
Backend Where state is stored S3/Blob/GCS/Pulumi Cloud/managed Determines locking, durability, team access
Drift Reality ≠ code The running cloud Silent IaC failure; apply may revert or break
Provider / resource type Plugin mapping types → cloud APIs Engine plugins Reach (multi-cloud) and feature freshness
Module A reusable, parameterised unit of infra Source / registry Reuse, standardisation; over-abstraction risk
Plan / preview Dry-run of what will change CI / CLI The review gate; catches surprise destroys
Stack / workspace An isolated instance of state Per env/component Blast radius and the unit of change
Policy as code Rules that pass/fail a change OPA/Sentinel/Nag/Azure Policy Guardrails enforced in the pipeline, not in audit

Declarative vs imperative: the fork that decides everything

This is the first and biggest fork. It is not a style preference — it changes how your team reads, reviews and scales infrastructure, and it predicts which failures you will hit.

Declarative (Terraform/HCL, CloudFormation/YAML, Bicep, ARM, Config Connector/KRM): you write what, the engine figures out how and in what order. The file is the documentation of desired state. Reviews are easy because the diff of the file maps to the diff of reality. The cost: expressing logic (loops, conditionals, computed values) is awkward in a config language — HCL has count, for_each, dynamic blocks and functions, but complex generation gets ugly fast, and there is no real type system or test harness around it.

Imperative (AWS CDK, Pulumi, Ansible): you write code in a real language. You get loops, functions, classes, types, unit tests, IDE autocomplete and your team’s existing skills. The subtlety: CDK and Pulumi are imperative authoring over a declarative engine — your code runs to produce a desired-state document (CloudFormation template for CDK; a resource graph for Pulumi), which a declarative engine then converges. So you get code ergonomics and a previewable diff. Ansible is the purer imperative case: tasks run in order, each idempotent, with no single converged-graph diff — closer to “automated runbook” than “desired-state engine.”

The trap most teams fall into: treating imperative as strictly better because “it’s real code.” Real code means real complexity — a CDK construct with three layers of inheritance and dynamic logic is harder to review than the YAML it generates, and a Pulumi program with side effects can do things a plan can’t fully predict. Declarative’s “limitation” (you can’t easily write a loop that calls an API mid-plan) is also a guardrail: the engine can reason about the whole graph because you didn’t smuggle imperative side effects into it.

Where each model lands on the dimensions that matter:

Dimension Declarative (Terraform/Bicep/CFN) Imperative-authoring (CDK/Pulumi) Imperative-runbook (Ansible)
What you write Desired-state config Real code that emits desired state Ordered idempotent tasks
Logic (loops/conditionals) Limited (for_each, count, functions) Full language power Full language power (loops, when)
Type safety Weak (HCL) / none (JSON) Strong (TS/Go/C#) None (YAML)
Unit testing Hard (plan-based, external tools) Native (language test frameworks) molecule / assertions
Review difficulty Low — config diff = reality diff Higher — must reason about code paths Medium — task order matters
Plan/preview fidelity High — full graph diff High (engine still diffs the output) Lower — --check is best-effort
Drift handling First-class First-class (CDK via CFN drift) Re-run converges (no diff artifact)
Best when Standardised infra, broad review audience Complex generation, strong-typed teams OS/app config, agentless orchestration
Worst when Heavy dynamic generation needed Team treats infra like an app and over-builds Whole-environment desired-state provisioning

The decision rule: default to declarative for the infrastructure substrate (networks, identity, data stores) where review clarity and a faithful plan matter most, and reach for imperative authoring (CDK/Pulumi) where you genuinely need generation logic, strong types, or to share abstractions as libraries. Use Ansible for what it is best at — configuring the inside of machines and orchestrating sequenced operations — not as your primary cloud-provisioning engine.

The tool-by-tool comparison

Now the head-to-head. Each tool gets the same lens: language, state model, cloud reach, day-zero feature support, testing story, ecosystem, and the situation it wins. Read the matrix, then the per-tool detail for whichever rows you are choosing between.

Tool Author language Engine paradigm State model Cloud reach Day-zero new-feature support Testing story Licence
Terraform HCL Declarative Explicit (tfstate) Any (4,000+ providers) Provider lag (days–weeks) terraform test, Terratest, OPA BSL 1.1 (since 1.6)
OpenTofu HCL Declarative Explicit (tfstate) Any (same providers) Provider lag tofu test, Terratest, OPA MPL 2.0 (open)
Pulumi TS/Python/Go/C#/Java Declarative engine, imperative author Explicit (Pulumi state) Any (TF + native providers) Native providers near day-zero Native unit tests + integration Apache 2.0
AWS CDK TS/Python/Java/Go/C# Imperative author → CFN Managed (CloudFormation) AWS only Day-zero (L1 from CFN spec) Native unit + assertions + Nag Apache 2.0
Bicep Bicep DSL Declarative → ARM Managed (ARM deployments) Azure only Day-zero (ARM-backed) what-if + Pester + ARM-TTK MIT
CloudFormation YAML/JSON Declarative Managed (CFN stacks) AWS only Slight lag behind APIs cfn-lint, Guard, TaskCat Proprietary (free)
ARM templates JSON Declarative Managed (ARM deployments) Azure only Day-zero (the substrate) what-if + ARM-TTK MIT-ish (free)
Config Connector YAML (KRM) Declarative (k8s reconcile) In-cluster (CRDs) + GCP GCP only Resource coverage lag kubeval / policy controller Apache 2.0
Ansible YAML playbooks Imperative/idempotent Stateless (queries live) Any (modules) Module lag molecule, assert GPLv3

Terraform (and OpenTofu)

Terraform is the portable default and the reason most people learn IaC. You write HCL, declare resources from a provider (azurerm, aws, google, plus thousands more), and terraform apply converges. Its superpowers are breadth (one tool and one language for every cloud and most SaaS) and a vast module ecosystem (the public Registry has thousands of vetted modules). Its taxes are explicit state (you must host and lock a tfstate backend), HCL’s ceiling (logic gets awkward), and provider lag (a brand-new cloud feature may not have a provider attribute for days or weeks).

In 2023 HashiCorp changed Terraform’s licence to the Business Source License (BSL 1.1), which restricts certain commercial uses. The community responded with OpenTofu, an MPL-2.0 fork that is a near-drop-in replacement (same HCL, same providers, tofu CLI). For most teams the choice is: stay on Terraform for HashiCorp’s ecosystem and HCP Terraform features, or move to OpenTofu for a permissive licence and community governance. They are interchangeable enough that you can prototype on one and switch.

A minimal but real Terraform module — an Azure resource group, VNet and subnet — showing variables, for_each, outputs and a remote backend:

terraform {
  required_version = ">= 1.6.0"
  required_providers {
    azurerm = { source = "hashicorp/azurerm", version = "~> 3.110" }
  }
  backend "azurerm" {
    resource_group_name  = "rg-tfstate"
    storage_account_name = "sttfstateprod"
    container_name       = "tfstate"
    key                  = "platform/network.tfstate"
    use_azuread_auth     = true
  }
}

provider "azurerm" {
  features {}
}

variable "location" { type = string, default = "centralindia" }
variable "subnets" {
  type    = map(string)   # name -> CIDR
  default = { app = "10.10.1.0/24", data = "10.10.2.0/24" }
}

resource "azurerm_resource_group" "this" {
  name     = "rg-platform-network"
  location = var.location
}

resource "azurerm_virtual_network" "this" {
  name                = "vnet-platform"
  location            = azurerm_resource_group.this.location
  resource_group_name = azurerm_resource_group.this.name
  address_space       = ["10.10.0.0/16"]
}

resource "azurerm_subnet" "this" {
  for_each             = var.subnets
  name                 = "snet-${each.key}"
  resource_group_name  = azurerm_resource_group.this.name
  virtual_network_name = azurerm_virtual_network.this.name
  address_prefixes     = [each.value]
}

output "subnet_ids" {
  value = { for k, s in azurerm_subnet.this : k => s.id }
}

The for_each over a map is the idiomatic way to generate N similar resources; the output exposes computed IDs for other modules to consume. Note the remote backend — never keep prod state on a laptop.

Pulumi

Pulumi runs the same kind of declarative engine as Terraform (it can even consume Terraform providers) but you author in a real language — TypeScript, Python, Go, C# or Java. You get loops, functions, classes, static types, IDE autocomplete, and your team’s existing test frameworks. State is explicit (hosted in Pulumi Cloud by default, or a self-managed backend like S3/Blob/GCS). Pulumi wins for code-centric teams who want to share infrastructure as typed libraries, express complex generation cleanly, and unit-test their infra logic with the same tools they test application code.

The same network, in Pulumi TypeScript — note the loop is just a normal language loop, and the types are real:

import * as azure from "@pulumi/azure-native";
import * as pulumi from "@pulumi/pulumi";

const cfg = new pulumi.Config();
const location = cfg.get("location") ?? "centralindia";
const subnets: Record<string, string> = { app: "10.10.1.0/24", data: "10.10.2.0/24" };

const rg = new azure.resources.ResourceGroup("platform", {
  resourceGroupName: "rg-platform-network",
  location,
});

const vnet = new azure.network.VirtualNetwork("platform", {
  resourceGroupName: rg.name,
  location: rg.location,
  addressSpace: { addressPrefixes: ["10.10.0.0/16"] },
});

const subnetIds = Object.entries(subnets).map(([name, cidr]) =>
  new azure.network.Subnet(name, {
    resourceGroupName: rg.name,
    virtualNetworkName: vnet.name,
    subnetName: `snet-${name}`,
    addressPrefix: cidr,
  }).id
);

export const ids = subnetIds;

The catch: “real code” invites real complexity. A Pulumi program can call APIs and have side effects that a preview cannot fully model, and a team that treats infra like an app can over-engineer it. Discipline matters — keep programs declarative-in-spirit even though the language allows otherwise.

AWS CDK

The AWS Cloud Development Kit (CDK) is imperative authoring (TS/Python/Java/Go/C#) that synthesises a CloudFormation template, which CloudFormation then deploys. You get code ergonomics — high-level L2/L3 constructs that encode AWS best practices (a Bucket with sane defaults, an ApplicationLoadBalancedFargateService that wires up a dozen resources) — and CloudFormation’s managed state (no tfstate to host). Because L1 constructs are generated from the CloudFormation resource spec, CDK gets day-zero support for new AWS resources. The cost: AWS-only, and you inherit CloudFormation’s quirks (stack update behaviours, the occasional resource that needs replacement, slower rollbacks).

A CDK stack in TypeScript — a VPC and an S3 bucket, showing how much a few lines of L2 constructs expand into:

import { Stack, StackProps, RemovalPolicy } from "aws-cdk-lib";
import { Construct } from "constructs";
import * as ec2 from "aws-cdk-lib/aws-ec2";
import * as s3 from "aws-cdk-lib/aws-s3";

export class PlatformStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);

    const vpc = new ec2.Vpc(this, "PlatformVpc", {
      maxAzs: 2,
      natGateways: 1,                       // one line ≈ subnets + route tables + NAT + IGW
    });

    new s3.Bucket(this, "Assets", {
      encryption: s3.BucketEncryption.S3_MANAGED,
      blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
      versioned: true,
      removalPolicy: RemovalPolicy.RETAIN,  // never auto-delete a data bucket
    });
  }
}

That Vpc construct expands to ~20 CloudFormation resources. Power and footgun in one: convenient, but you must cdk synth and read the template to know exactly what you are about to create.

Azure Bicep (and ARM)

Bicep is Azure’s clean declarative DSL that transpiles to ARM JSON. It is to ARM what TypeScript is to a verbose intermediate: far more readable, with modules, loops, conditionals, and day-zero support for every Azure resource (because it compiles to ARM, which is the substrate every Azure API speaks). State is managed by the ARM control plane as deployments — no file to host. Bicep wins for Azure-deep teams: cleanest possible syntax, native tooling, what-if preview, and you are never waiting on a third-party provider to expose a new property.

ARM templates (JSON) are Bicep’s compile target — you rarely write them by hand anymore, but they remain the ground truth for the rare edge case Bicep doesn’t model and for tools that consume ARM directly.

The same network in Bicep — notice loops via for and the managed-state model (no backend block exists because there is no state file):

@description('Azure region')
param location string = 'centralindia'
param subnets object = {
  app:  '10.10.1.0/24'
  data: '10.10.2.0/24'
}

resource vnet 'Microsoft.Network/virtualNetworks@2023-11-01' = {
  name: 'vnet-platform'
  location: location
  properties: {
    addressSpace: { addressPrefixes: [ '10.10.0.0/16' ] }
    subnets: [for item in items(subnets): {
      name: 'snet-${item.key}'
      properties: { addressPrefix: item.value }
    }]
  }
}

output subnetIds array = [for item in items(subnets): {
  name: item.key
  id: resourceId('Microsoft.Network/virtualNetworks/subnets', vnet.name, 'snet-${item.key}')
}]

Deploy and preview with az deployment group what-if before create — the equivalent of terraform plan, covered in depth in Bicep What-If as a CI Gate.

CloudFormation, Config Connector and Ansible

CloudFormation is AWS’s native declarative templates (YAML/JSON) with fully managed state (the stack). No tfstate to host, deep service integration, drift detection built in, and StackSets for multi-account/region rollout. The taxes: AWS-only, verbose YAML, occasionally awkward update/rollback semantics, and a slight lag behind the newest APIs. It is the right pick for AWS shops that want zero state management and tight native integration — and it is what CDK produces, so the two coexist.

GCP Config Connector manages Google Cloud resources as Kubernetes Custom Resources (KRM) reconciled by a controller in a GKE cluster — IaC for teams who already live in Kubernetes and want GCP managed the same way as their workloads. (The older Deployment Manager is legacy and being wound down; new GCP IaC is usually Terraform or Config Connector.)

Ansible is the odd one out: imperative, idempotent playbooks (YAML) that excel at configuration management — installing packages, templating config files, sequencing operations across fleets — agentlessly over SSH/WinRM. It can provision cloud resources via modules, but it has no converged-graph plan and no real state file (it queries live state each run), so it is a weaker fit for whole-environment provisioning and a strong fit for the layer inside machines and for orchestration. Many shops pair Terraform for provisioning with Ansible for configuration.

How the managed-vs-explicit-state tools compare on the operational concerns that actually differ:

Concern Terraform/Pulumi (explicit state) CloudFormation/CDK (AWS managed) Bicep/ARM (Azure managed)
State you host Yes (tfstate / Pulumi backend) No (in the stack) No (in the deployment)
Locking You configure (DynamoDB / Blob lease) Automatic (per stack) Automatic (per deployment)
Drift detection terraform plan / pulumi preview CFN drift detection what-if (closest equivalent)
Multi-cloud Yes No No
Secrets in state Stored (must encrypt backend) Not exposed as a file Not exposed as a file
Rollback on failure Manual / partial Automatic stack rollback Last-successful redeploy
Day-zero new features Provider-dependent lag Slight lag Day-zero
Portability of the artifact High (HCL is portable) Low (CFN/AWS-bound) Low (ARM/Azure-bound)

The same five verbs in every tool, so you can move between them — init, preview, apply, inspect, destroy:

Verb Terraform / OpenTofu Pulumi AWS CDK Bicep CloudFormation
Initialise terraform init pulumi stack init cdk bootstrap (none — az login) (none — aws configure)
Preview the diff terraform plan pulumi preview cdk diff az deployment group what-if create change set
Apply / converge terraform apply pulumi up cdk deploy az deployment group create aws cloudformation deploy
Inspect what exists terraform state list pulumi stack (CFN console / describe-stacks) az deployment group show describe-stack-resources
Destroy terraform destroy pulumi destroy cdk destroy delete RG / resources delete-stack

State: the engine’s memory, and where it bites

State is the single most operationally consequential concept in IaC, and the place teams cause their worst incidents. The engine needs a record of what it created so it can diff; how that record is stored and protected determines your failure modes.

What’s in state. A Terraform/Pulumi state file maps each declared resource to its real cloud ID, its last-known attributes, and metadata (dependencies, provider versions). Critically, it can contain sensitive values — a generated DB password, a private key, a connection string — in plaintext within the state. This is why a state backend must be encrypted and access-controlled: anyone who can read your tfstate may be reading your secrets. CloudFormation/ARM keep the equivalent inside the cloud control plane, so there is no file to leak — but the same secret-exposure care applies to outputs and parameters.

Where it lives (the backend). Never keep production state on a laptop or in Git. Use a remote backend: S3 + DynamoDB (locking) for Terraform on AWS, Azure Blob with a lease for Terraform on Azure (see Terraform on Azure: Remote State in Blob Storage with State Locking & Workspaces), GCS for GCP, or Pulumi Cloud / HCP Terraform as managed services. The backend gives you durability, team access and — most importantly — locking.

Locking prevents the worst corruption. If two pipelines run apply against the same state simultaneously, they can both read the old state, both write, and corrupt it — leaving the engine’s memory inconsistent with reality, which is miserable to repair. A locking backend takes an exclusive lock for the duration of an apply so the second run waits. CloudFormation/ARM lock per stack/deployment automatically; with Terraform you must configure it (DynamoDB table for S3, blob lease for Azure). The classic incident: a CI apply and a panicked manual apply collide and corrupt state at the worst possible moment.

State surgery is sometimes unavoidable. When state and reality diverge in ways a plan can’t reconcile, you use targeted operations: terraform import to bring an existing resource under management (covered for Azure brownfield in Importing Brownfield Azure Resources into Terraform Without Downtime), terraform state mv to rename/move a resource in state without recreating it, and terraform state rm to forget a resource (without destroying it) — for example before handing it to another stack. These are sharp tools; always back up state first.

The state operations every Terraform practitioner must know, and exactly when each applies:

Operation Command What it does When you need it Danger
Inspect terraform state list / show List/inspect tracked resources Auditing what’s managed None (read-only)
Import terraform import <addr> <id> Bring an existing resource into state Brownfield adoption Must match config or next plan churns
Move/rename terraform state mv <a> <b> Re-address without recreating Refactoring modules Wrong target = orphaned/duplicated
Remove terraform state rm <addr> Forget a resource (don’t destroy) Hand-off to another stack Resource now unmanaged/leaks
Replace terraform apply -replace=<addr> Force recreate one resource Tainted/corrupt resource Downtime on that resource
Refresh terraform refresh / plan refresh Sync state with reality After out-of-band changes May mask drift if misused
Unlock terraform force-unlock <id> Release a stuck lock Crashed apply left a lock Only if you’re certain no apply runs

A worked failure: an engineer renames a module from module.db to module.database in code and runs apply. Terraform sees the old address gone and the new one absent from state, so it plans to destroy the database and create a new one — a data-loss apply hiding behind a rename. The fix is terraform state mv module.db module.database before applying, so the engine re-addresses in place. This is why the plan is the review gate: a careful reader sees “1 to destroy” on a database and stops.

Drift: when reality stops matching the code

Drift is reality diverging from your definition: someone edits a resource in the console, an autoscaler changes an instance count, a different tool touches a tag. The moment it happens, your code is lying, and the next apply faces a choice — revert the out-of-band change (possibly undoing a critical hot-fix) or fail.

Detecting drift is a per-tool command. Terraform/OpenTofu surface it in terraform plan (which refreshes state against reality and shows the delta) — a plan that shows changes you didn’t author is drift. Pulumi has pulumi preview and pulumi refresh. CloudFormation has explicit drift detection (detect-stack-drift / the console “Detect drift”). Bicep/ARM’s closest equivalent is what-if, which previews the delta the deployment would make. Ansible “detects” only by re-running and seeing which tasks report changed.

Why drift is dangerous, not just untidy: an apply after undetected drift can revert an emergency fix (someone widened a connection limit at 2am; your next apply narrows it back and the outage returns), or it can fail because a resource is now in an unexpected state. The deeper damage is to trust: once teams learn that “the code doesn’t match reality,” they stop trusting the code and go back to clicking — and you’ve lost the entire value of IaC.

Preventing drift beats chasing it. The durable fix is a workflow where all changes go through code: lock down console write access in production, run plan on a schedule in CI to alert on drift, deny direct production access except break-glass, and treat any drift as an incident to reconcile (either import the change into code or revert it). GitOps takes this furthest — a controller continuously reconciles reality to Git and auto-reverts drift — which is exactly the model in GitOps with Argo CD and Flux.

How each tool handles the drift lifecycle, end to end:

Tool Detect drift Reconcile (code → reality) Adopt drift (reality → code) Continuous option
Terraform / OpenTofu plan (refresh shows delta) apply reverts to code import then edit config Scheduled CI plan + alert
Pulumi preview / refresh up reverts to code import to code Pulumi deployments + drift detection
CloudFormation Drift detection Update stack to template Import resources into stack EventBridge + scheduled detect
CDK CFN drift (underlying) cdk deploy Import via CFN Same as CloudFormation
Bicep / ARM what-if (delta preview) Redeploy template Author resource, redeploy what-if in CI gate
Ansible Re-run (changed=) Re-run converges Edit playbook to match Scheduled playbook run

The decision table for what to do when a plan shows changes you didn’t write:

If the plan shows… It’s probably… Do this
A tag/attribute you didn’t set Out-of-band console edit Decide: adopt (edit code) or revert (apply)
A resource about to be destroyed A rename or state mismatch Stop; use state mv/import before applying
Constant churn on the same field Provider/API normalising a value ignore_changes (lifecycle) or align the value
A whole resource Terraform “doesn’t know” Created outside IaC import it (or delete and recreate via code)
Nothing, but reality is wrong State diverged from reality refresh carefully; reconcile, then re-plan

Modules and composition: scaling past one team

A single Terraform file is fine for a demo. The moment you have multiple environments and teams, modules — parameterised, reusable units of infrastructure — are what keep IaC from rotting into copy-paste. Every tool has the equivalent: Terraform modules, Pulumi component resources, CDK constructs, Bicep modules, CloudFormation nested stacks. The mechanics differ; the discipline is the same.

A good module is a sharp, narrow contract. It exposes a small set of inputs (variables/parameters), encapsulates a coherent piece of infrastructure (a “secure storage account,” a “standard VNet,” a “tagged database”), and returns a few outputs for callers. The anti-pattern is the god module — one module that takes fifty inputs and provisions an entire environment — because it has a huge blast radius, a slow plan, and a change to one corner risks everything. Prefer small modules composed by a thin root: a network module, a database module, a compute module, each independently testable, wired together at the top.

Versioning is non-negotiable at scale. Pin module versions (source = "...//modules/network?ref=v2.3.0" or a registry version constraint) so an upstream change doesn’t silently alter twelve teams’ infrastructure on their next apply. Treat modules like libraries: semantic versioning, a changelog, and a registry. On Azure, Azure Verified Modules for Bicep and the broader Reusable Terraform Modules at Scale on Azure DevOps walk through repo-per-module and versioning patterns specifically.

Composition over inheritance. Wire modules together by passing outputs as inputs, not by reaching into another module’s internals. Explicit data flow (module A’s subnet_id → module B’s input) keeps the dependency graph readable; implicit coupling (module B looks up a resource module A made by naming convention) creates spooky action at a distance that breaks when names change.

The module-equivalent in each tool, and the version mechanism:

Tool Reuse unit How you consume it Versioning mechanism Registry / sharing
Terraform Module module "x" { source=…, version=… } Registry version constraint / Git ?ref=tag Public + private Terraform Registry
OpenTofu Module Same as Terraform Same OCI registry / Git / public registry
Pulumi Component resource Import as a typed class Package version (npm/PyPI/NuGet) Package managers + Pulumi Registry
AWS CDK Construct (L1/L2/L3) Import a construct class Library version (npm/PyPI) Construct Hub + package managers
Bicep Module module x '...' = { params } Registry tag (ACR/MCR) Bicep public + private (ACR) registry
CloudFormation Nested stack / module AWS::CloudFormation::Stack S3 path / version S3 / CloudFormation Registry
Ansible Role / collection roles: / include_role Galaxy version Ansible Galaxy / private

The structural rules that keep a module library healthy at dozens of teams:

Rule Why Smell when violated
One responsibility per module Small blast radius, testable 40-input “environment” module
Pin every module version Upstream change can’t surprise you source with no ref/version
Outputs are the only contract Callers don’t reach into internals Cross-module name-convention lookups
Sane defaults, few required inputs Easy to consume correctly Every call repeats the same 20 params
Validate inputs Fail fast on bad parameters Garbage CIDR reaches the cloud API
Document inputs/outputs Discoverable, reviewable Readers must read the source to use it

A composition pattern in Terraform — a thin root wiring three small modules, passing outputs as inputs:

module "network" {
  source  = "git::https://example.com/modules/network.git?ref=v2.3.0"
  cidr    = "10.20.0.0/16"
  subnets = { app = "10.20.1.0/24", data = "10.20.2.0/24" }
}

module "database" {
  source        = "git::https://example.com/modules/postgres.git?ref=v1.7.1"
  subnet_id     = module.network.subnet_ids["data"]   # explicit output → input
  sku_name      = "GP_Standard_D2s_v3"
  ha_enabled    = true
}

module "app" {
  source        = "git::https://example.com/modules/app-service.git?ref=v3.0.0"
  subnet_id     = module.network.subnet_ids["app"]
  db_host       = module.database.fqdn
}

Each module is versioned and independently testable; the root file is the only place the wiring lives, so the whole environment’s shape is reviewable on one screen.

Testing infrastructure before it touches production

“It’s just config, what’s to test?” is how teams ship a security group open to the world. Infrastructure code carries the same risk as application code and deserves the same test pyramid — adapted to the fact that the “unit under test” provisions real resources.

Static analysis (cheapest, run on every commit). Lint and security-scan the source before anything runs: terraform validate/fmt, tflint, tfsec/checkov/trivy for misconfig scanning, cfn-lint/cfn_nag for CloudFormation, bicep build/linter and ARM-TTK for Azure, cdk-nag for CDK. These catch “this S3 bucket is public,” “this NSG allows 0.0.0.0/0 on 22,” and style issues in seconds, with no cloud calls.

Plan/preview as a test (the highest-value, cheapest gate). The single most valuable test is reviewing the plan/what-if in CI on every PR: it shows exactly what will change, and a destroy of anything stateful (a database, a storage account) should fail the build or require explicit approval. Terraform’s plan, Pulumi’s preview, CloudFormation change sets, and Bicep what-if are all this gate. Wire policy on top so the plan must also be compliant, not just non-destructive.

Unit tests (fast, no real resources). Terraform 1.6+ ships terraform test (HCL-native assertions); Pulumi and CDK use their language’s test frameworks to assert the synthesised resources have the right properties (CDK’s Template.fromStack(...).hasResourceProperties(...)), without deploying. These run in seconds and catch logic bugs in your generation code.

Integration tests (slow, real resources, ephemeral). The gold standard: spin up the infrastructure for real in a throwaway environment, assert it actually works (the endpoint responds, the DB accepts a connection), then tear it down. Terratest (Go) is the canonical tool; Pulumi has native integration testing; CloudFormation has TaskCat. These are slow and cost real money, so run them on merge or nightly, not every commit, and always destroy at the end.

The infrastructure test pyramid, with what each layer catches and what it costs:

Layer Tools What it catches Speed Real cloud cost Run on
Format/lint fmt, tflint, cfn-lint, bicep linter Style, obvious errors Seconds None Every commit
Security scan tfsec, checkov, trivy, cfn_nag, cdk-nag Misconfig (public bucket, open SG) Seconds None Every commit
Policy as code OPA/Conftest, Sentinel, Azure Policy Org guardrail violations Seconds None Every PR (on plan)
Plan/what-if review plan, preview, change sets, what-if Surprise destroys, unintended diff Seconds–min None Every PR
Unit / synth assertions terraform test, CDK assertions, Pulumi unit Generation logic bugs Seconds None Every commit
Integration (ephemeral) Terratest, TaskCat, Pulumi integ “Does it actually work?” Minutes–hours Real (tear down!) Merge / nightly

A CDK unit assertion — proving the bucket blocks public access without deploying anything:

import { Template } from "aws-cdk-lib/assertions";
import { App } from "aws-cdk-lib";
import { PlatformStack } from "../lib/platform-stack";

test("assets bucket blocks all public access", () => {
  const app = new App();
  const stack = new PlatformStack(app, "Test");
  const template = Template.fromStack(stack);

  template.hasResourceProperties("AWS::S3::Bucket", {
    PublicAccessBlockConfiguration: {
      BlockPublicAcls: true, BlockPublicPolicy: true,
      IgnorePublicAcls: true, RestrictPublicBuckets: true,
    },
  });
});

This runs in a normal test suite in milliseconds and fails the build if someone weakens the bucket — the kind of regression a plan review might miss but an assertion never will.

Policy as code: guardrails the pipeline enforces

The difference between “we have a policy that prod databases must be encrypted” and “an unencrypted prod database cannot be created” is policy as code — machine-readable rules that evaluate a plan and pass or fail the pipeline. It moves compliance from a quarterly audit (where you find violations after they shipped) to the PR (where the violation never merges).

The major engines. Open Policy Agent (OPA) with Rego (often run via Conftest against a terraform show -json plan) is the cloud-neutral standard. HashiCorp Sentinel is the policy language for HCP Terraform/Enterprise. CDK Nag applies rule packs (AWS Solutions, HIPAA, NIST) to a CDK app at synth time. Azure Policy enforces at the control-plane level (Azure rejects a non-compliant create regardless of how it was requested) and is complemented by what-if and pre-deploy checks. AWS CloudFormation Guard validates templates/change sets against policy.

Where the check runs matters. Two complementary places: shift-left (evaluate the plan in CI, fail the PR before apply — fast feedback, but only catches what goes through your pipeline) and control-plane (Azure Policy / AWS SCPs / GCP Org Policy reject non-compliant resources regardless of tool or path — the backstop that catches console clicks and rogue pipelines). Mature platforms run both: shift-left for fast developer feedback, control-plane as the non-bypassable enforcement.

A Rego policy (run via Conftest against a Terraform plan JSON) that denies any storage account allowing public network access:

package terraform.azure.storage

# Deny Azure storage accounts that permit public network access
deny[msg] {
  resource := input.resource_changes[_]
  resource.type == "azurerm_storage_account"
  resource.change.after.public_network_access_enabled == true
  msg := sprintf("storage account '%s' must disable public network access", [resource.address])
}

# Require infrastructure encryption on every storage account
deny[msg] {
  resource := input.resource_changes[_]
  resource.type == "azurerm_storage_account"
  not resource.change.after.infrastructure_encryption_enabled
  msg := sprintf("storage account '%s' must enable infrastructure encryption", [resource.address])
}

Wired into CI as terraform plan -out=tf.plan && terraform show -json tf.plan | conftest test -, this fails the build the instant someone tries to ship a public storage account — no human reviewer required to catch it.

The policy-as-code engines compared on what they evaluate and where they enforce:

Engine Language What it evaluates Enforcement point Best fit
OPA / Conftest Rego terraform show -json plan, any JSON/YAML CI (shift-left) Cloud-neutral, any IaC tool
Sentinel Sentinel Terraform plan/state/config HCP Terraform/Enterprise HashiCorp-stack teams
CDK Nag Rule packs (TS) CDK construct tree at synth CI (synth time) CDK / AWS teams
CloudFormation Guard Guard DSL CFN templates / change sets CI / pre-deploy CloudFormation teams
Azure Policy JSON policy defs Azure resources (control plane) Control plane (deny/audit) Azure org-wide backstop
AWS SCP / GCP Org Policy JSON / constraints Account/org actions Control plane Org-wide guardrails
checkov / tfsec Built-in + custom Static IaC source CI (pre-plan) Fast security scanning

What good guardrail policies actually enforce — a starter set every platform should have:

Guardrail Catches Typical rule
No public data stores Open S3/Blob/buckets Deny public network access / public ACLs
Encryption at rest required Unencrypted DBs/disks/storage Require CMK or platform encryption flags
Approved regions only Data-residency violations Allow-list of regions
Mandatory tags Untracked, un-billable resources Require owner, cost-center, env
No 0.0.0.0/0 on admin ports Internet-open SSH/RDP Deny ingress 22/3389 from any
Approved SKUs/sizes Runaway cost Allow-list of instance/SKU types
No destroy of stateful prod Accidental data loss Block plan that destroys DB/storage in prod

Multi-cloud: real requirement or slogan?

“We need multi-cloud” is the most over-claimed requirement in infrastructure, and it drives a lot of tool choices that cost more than they return. Be honest about which case you are in, because it changes the answer.

What “multi-cloud” actually means — three different things. (1) Portability: the ability to move to another cloud later, even if you run on one today. (2) Concurrent multi-cloud: genuinely running workloads across AWS and Azure and GCP at once (rare, usually from acquisitions, regulatory mandates, or specific best-of-breed services). (3) Cloud-plus-SaaS: managing one cloud and Cloudflare, Datadog, GitHub, Auth0 — which is extremely common and where Terraform/Pulumi’s broad provider model shines even for single-cloud shops.

Where multi-cloud tools win. Terraform/OpenTofu and Pulumi are genuinely multi-cloud and multi-SaaS: one language, one workflow, 4,000+ providers. If you run more than one cloud, manage SaaS alongside cloud, or want a credible exit option, they are the right substrate. The catch: a Terraform configuration is not magically portable across clouds — an aws_instance is not an azurerm_virtual_machine. What’s portable is the tool, language and workflow, not the resource definitions. True cross-cloud abstraction (write once, deploy to any cloud) requires a heavy abstraction layer that usually leaks and rarely pays off.

Where cloud-native tools win. If you are deeply on one cloud and intend to stay, Bicep (Azure) or CDK/CloudFormation (AWS) give you day-zero feature support, native tooling, no third-party provider lag, and managed state. Choosing Terraform “for multi-cloud” on a single-cloud team that will never leave is paying a real tax (extra state management, provider lag) for an option you’ll never exercise. The honest engineering call is often: cloud-native for the cloud you live in, Terraform only when the second cloud or the SaaS sprawl is real.

The multi-cloud decision, by which case you are actually in:

Your situation Real need Best tool Why
One cloud, staying there Day-zero features, simplicity Cloud-native (Bicep / CDK / CFN) No provider lag, managed state, native tooling
One cloud + lots of SaaS One workflow for cloud + SaaS Terraform / Pulumi 4,000+ providers cover Cloudflare/Datadog/etc.
Genuinely two+ clouds One language/workflow across clouds Terraform / Pulumi Single tool, single state model, shared modules
Want a future exit option Portability of skills/workflow Terraform / Pulumi Tool/workflow portable (not the resource defs)
Kubernetes-centric platform Manage cloud as k8s objects Crossplane / Config Connector Cloud resources as CRDs, reconciled in-cluster
Config inside machines OS/app setup across fleets Ansible Agentless, idempotent, cloud-agnostic

The portability myth, stated plainly: what is and isn’t portable in a “multi-cloud” tool:

Portable across clouds NOT portable across clouds
The CLI and workflow (plan/apply) Resource definitions (aws_*azurerm_*)
HCL/the language and its patterns Service capabilities and limits
Your team’s skills and modules’ shape Networking, IAM, and quota models
State and locking concepts The actual modules (rewrite per cloud)
Policy-as-code approach Pricing and SKU mappings

Architecture at a glance

The first diagram lays the tools out side by side so you can see the structural choice, not just a feature list. Read it as four lanes converging on the same cloud. The Terraform / OpenTofu lane authors HCL, talks to clouds through providers, and keeps an explicit state file in a remote, locked backend — the broadest reach and the only lane that is truly multi-cloud and multi-SaaS. The Pulumi lane authors in real languages (TS/Python/Go/C#) over a similar engine and explicit state — code ergonomics and types, multi-cloud. The cloud-native lane splits by cloud: AWS CDK → CloudFormation and Azure Bicep → ARM, where you author code or a clean DSL that compiles to the cloud’s native template and the cloud control plane holds the state (no file to host), buying day-zero features at the cost of single-cloud lock-in. The Ansible lane sits apart: imperative idempotent playbooks for the configuration inside machines, agentless, with no converged-graph state.

Notice what every lane shares downstream: a plan/preview step (the review gate), a policy check, and a converge that makes the minimum changes to reach desired state. The lane you pick decides your language, your state model and your cloud reach — but the workflow shape (author → plan → policy → apply → drift-check) is identical across all of them. That shared shape is why the skills transfer even when the tool doesn’t.

Infrastructure as Code tools side by side — a Terraform/OpenTofu lane authoring HCL through providers with explicit remote state, a Pulumi lane authoring in TypeScript/Python/Go/C# over the same engine with explicit state, a cloud-native lane splitting into AWS CDK to CloudFormation and Azure Bicep to ARM with state held by the cloud control plane, and an Ansible lane of imperative idempotent playbooks for in-machine configuration — all four lanes converging on a shared workflow of plan/preview, policy check, converge to desired state, and drift detection

The second diagram is the decision flow you walk when choosing. It forks on the questions that actually decide the tool: How many clouds, really? (one vs more-than-one routes you toward cloud-native vs Terraform/Pulumi); Does your team want a real language with types and tests, or a declarative DSL? (Pulumi/CDK vs Terraform/Bicep); Do you want to host state, or have the cloud manage it? (Terraform/Pulumi vs CloudFormation/Bicep); and Is this provisioning, or configuration-inside-machines? (everything above vs Ansible). Follow the branches to the leaf that matches your constraints — and remember the meta-rule the flow encodes: pick by the boundary of infrastructure and the team you have, not by the most powerful tool on paper.

Decision flow for choosing IaC tooling — branching first on how many clouds you genuinely target (one cloud routing toward cloud-native Bicep or CDK/CloudFormation, multiple clouds or heavy SaaS routing toward Terraform/OpenTofu or Pulumi), then on whether the team prefers a real typed language with tests (Pulumi/CDK) or a declarative DSL (Terraform/Bicep), then on whether you host state yourself or let the cloud control plane manage it, with a separate branch sending configuration-inside-machines work to Ansible — each path ending at the tool that fits that boundary of infrastructure and team

Real-world scenario

Meridian Logistics is a mid-size freight company, 60 engineers across six product teams, primarily on Azure with a small but growing AWS footprint inherited from a 2024 acquisition, plus heavy SaaS (Cloudflare for DNS/WAF, Datadog for monitoring, GitHub for repos). When the platform team formed, infrastructure was a mess of console-built resources, three half-finished Terraform repos with state files committed to Git (one with a database password in plaintext history), and one team that had gone all-in on Bicep. The mandate: standardise IaC without a year-long rewrite, and stop the next 2am incident from becoming a four-day rebuild.

The first instinct — “pick one tool, migrate everything” — was wrong, and the architect killed it. The honest analysis: the Azure-deep teams loved Bicep’s day-zero feature support and zero state management, and forcing them onto Terraform would buy a multi-cloud option they didn’t individually need while costing them velocity. The acquired AWS workloads were already in CloudFormation and worked. And the cross-cutting concerns — SaaS (Cloudflare, Datadog, GitHub), the shared landing zone spanning both clouds, and DNS — genuinely needed one tool that spoke to everything. So the decision was boundary-based, not tool-based.

The resulting standard: Bicep for Azure-native application infrastructure (each product team owns its Bicep, deployed via Bicep What-If as a CI Gate); CDK for the AWS workloads (the acquired team’s language was TypeScript, and CDK gave them code ergonomics over their existing CloudFormation); and Terraform/OpenTofu for the cross-cutting layer — the multi-cloud landing zone, all SaaS providers, and DNS — owned by the platform team, with remote state in Azure Blob (locked via lease) and the leaked password rotated and purged. OpenTofu specifically, for the permissive licence, since this was net-new.

The first disaster they prevented proved the model. A product team’s Bicep PR included a what-if in CI that showed a storage account about to be recreated (a name change had slipped in) — caught and fixed before merge, no data loss. Then they layered policy: Azure Policy at the control plane (deny public storage, require encryption, allow-list centralindia/southeastasia) as the non-bypassable backstop, plus Conftest/OPA on the Terraform plans for the SaaS layer (every Cloudflare record must have a comment, every Datadog monitor must tag team). The control-plane policy caught a contractor who clicked a public blob in the console — Azure simply rejected it, regardless of IaC.

Six months on: disaster-recovery rebuild time went from “four days, maybe” to a tested 35 minutes for a full environment from empty subscriptions. Drift dropped because production console-write was locked to break-glass and a nightly what-if/plan alerted on any divergence. The lesson on the wall: “Don’t pick the most powerful IaC tool. Pick the right tool for each boundary of infrastructure, and make the pipeline — not a human — enforce the rules.” The multi-cloud tool earned its place exactly where multi-cloud was real (the cross-cutting layer), and nowhere it wasn’t.

The decisions as a table, because the reasoning is the transferable part:

Boundary Tool chosen Why this tool Why not the alternatives
Azure app infrastructure Bicep Day-zero features, no state to host, team fluency Terraform = needless state mgmt + provider lag for single-cloud teams
Acquired AWS workloads AWS CDK TS team, code ergonomics, native state, already CFN Rewriting to Terraform = migration risk for no portability gain
Cross-cloud landing zone Terraform / OpenTofu Genuinely spans Azure + AWS Cloud-native can’t span both clouds
SaaS (Cloudflare/Datadog/GitHub) Terraform / OpenTofu 4,000+ providers, one workflow No cloud-native tool covers SaaS
Org-wide guardrails Azure Policy + SCP + OPA Control-plane backstop + shift-left Shift-left alone misses console clicks

Advantages and disadvantages

IaC as a discipline is almost unambiguously worth it; the trade-offs live in which tool and how much rigor. Weigh the model honestly:

Advantages (across all IaC) Disadvantages / costs (per tool, mostly)
Versioned, reviewable infrastructure — every change is a diff in a PR A learning curve per tool (HCL, Bicep DSL, CDK constructs) before productivity
Reproducible environments — dev/staging/prod from one definition Explicit-state tools (Terraform/Pulumi) add state hosting, locking and secrets-in-state burden
Disaster recovery in minutes from an empty account A bad apply can destroy real resources fast — the plan gate is essential, not optional
Drift detection turns “is prod what we think?” into a command Drift still happens; chasing it without locked console access is a treadmill
Modules standardise and reuse battle-tested patterns Over-modularisation (god modules, deep abstraction) can be harder to read than raw config
Policy-as-code enforces guardrails in the pipeline, not the audit Multi-cloud tools’ portability is the workflow, not the resource defs — the lock-in just moves
One workflow can manage cloud and SaaS (Terraform/Pulumi) Cloud-native tools (Bicep/CDK/CFN) are single-cloud by design
Cloud-native tools get day-zero features and managed state Imperative tools (CDK/Pulumi) invite app-style over-engineering of infrastructure

When each model matters: declarative cloud-native (Bicep/CDK/CFN) wins for single-cloud teams who value day-zero features and zero state management; declarative multi-cloud (Terraform/OpenTofu) wins when you genuinely span clouds or manage SaaS, and want a portable workflow; imperative-authoring (Pulumi/CDK) wins for strongly-typed teams needing generation logic and native tests; Ansible wins for configuration inside machines and agentless orchestration. The disadvantages are all manageable — remote locked state, a plan gate, version-pinned small modules, and control-plane policy — but only if you build them in deliberately, which is the entire point of choosing well up front.

Hands-on lab

Provision a real Azure VNet with Terraform, see the plan as a gate, then deliberately cause drift and watch Terraform detect and reconcile it — the core IaC loop end to end. Free-tier-friendly (a VNet costs nothing; we delete at the end). Run in Azure Cloud Shell (Bash), which has terraform and az pre-installed and is already authenticated.

Step 1 — Set up a working directory and a backend storage account for state.

RG=rg-iac-lab
LOC=centralindia
SA=stiaclab$RANDOM      # globally-unique storage account name
az group create -n $RG -l $LOC -o table
az storage account create -n $SA -g $RG -l $LOC --sku Standard_LRS -o table
az storage container create --account-name $SA -n tfstate -o table
echo "Backend SA: $SA"   # note this for main.tf

Expected: the resource group, a storage account, and a tfstate container created.

Step 2 — Write a minimal Terraform config with a remote backend. Create main.tf (replace <SA> with the name printed above):

terraform {
  required_providers { azurerm = { source = "hashicorp/azurerm", version = "~> 3.110" } }
  backend "azurerm" {
    resource_group_name  = "rg-iac-lab"
    storage_account_name = "<SA>"
    container_name       = "tfstate"
    key                  = "lab.tfstate"
  }
}
provider "azurerm" { features {} }

resource "azurerm_virtual_network" "lab" {
  name                = "vnet-iac-lab"
  location            = "centralindia"
  resource_group_name = "rg-iac-lab"
  address_space       = ["10.50.0.0/16"]
  tags                = { env = "lab", owner = "you" }
}

Step 3 — Initialise and run the first plan (the gate).

terraform init      # downloads provider, configures the Azure Blob backend
terraform plan      # shows: 1 to add, 0 to change, 0 to destroy

Expected: Plan: 1 to add, 0 to change, 0 to destroy. Read it — this is the review gate. Nothing has been created yet.

Step 4 — Apply and confirm convergence.

terraform apply -auto-approve   # creates the VNet
terraform plan                  # now shows: No changes. Infrastructure matches configuration.

Expected: after apply, the second plan reports “No changes” — proof of idempotence and that state matches reality.

Step 5 — Cause drift on purpose (out-of-band change), then detect it. Change a tag directly with az (simulating a console hot-fix), then re-plan:

az network vnet update -g $RG -n vnet-iac-lab --set tags.env=PRODUCTION-HOTFIX -o table
terraform plan   # Terraform now shows the tag drift it wants to revert

Expected: the plan shows ~ update in-place on the tags block — Terraform detected the out-of-band change. This is drift detection working: reality diverged from code, and the plan tells you exactly what.

Step 6 — Decide: revert (code wins) or adopt (reality wins). To revert to the code’s desired state:

terraform apply -auto-approve   # reverts the tag back to env=lab
terraform plan                  # "No changes" again — reconciled

Expected: the apply reverts the tag; the final plan is clean. (To adopt the change instead, you’d edit main.tf’s tag to match and re-apply — that is the “adopt drift into code” path.)

Validation checklist. You hosted state in a locked remote backend, used the plan as a gate (saw exactly what would change before it changed), proved idempotence (second plan = no changes), then deliberately drifted reality and watched Terraform detect and reconcile it. That author → plan → apply → drift-detect → reconcile loop is the entire discipline.

Step What you did What it proves Real-world analogue
1–2 Remote backend + config State belongs in a locked backend, not Git/laptop Every production setup
3 First plan The review gate shows changes before they happen PR plan review
4 apply then re-plan Idempotence: no changes the second time Safe re-runs in CI
5 Out-of-band az edit + plan Drift detection: reality vs code delta The 2am console hot-fix
6 apply to reconcile Code is the source of truth; drift is reversible Reconciling drift as policy

Cleanup (avoid lingering charges and orphaned state).

terraform destroy -auto-approve   # remove the VNet
az group delete -n $RG --yes --no-wait   # remove RG incl. state storage

Cost note. A VNet and an LRS storage container are effectively free; an hour of this lab is well under ₹10. terraform destroy then deleting the resource group removes everything, including the state backend.

Common mistakes & troubleshooting

The failures that bite real teams, as a scannable table first, then the reasoning for the ones that hurt most.

# Symptom Root cause Confirm (exact cmd / signal) Fix
1 Error acquiring the state lock A previous apply crashed and left a lock Lock ID/holder in the error; check CI for a killed job Confirm no apply runs, then terraform force-unlock <id>
2 Plan wants to destroy + recreate a database A resource was renamed in code (address changed) plan shows -/+ or “1 to destroy” on a stateful resource terraform state mv <old> <new> before applying
3 Secrets visible in terraform.tfstate State stores attribute values incl. generated secrets grep state for the secret; it’s there in plaintext Encrypt backend; restrict access; rotate exposed secret; never commit state
4 Every plan shows the same change, forever Provider/API normalises a value you set differently Repeated ~ on one attribute with no edits lifecycle { ignore_changes = [that_attr] } or align the value
5 Two pipelines corrupt state No locking backend configured State inconsistent after concurrent applies Add DynamoDB lock (S3) / blob lease (Azure); serialise applies
6 terraform apply fails: resource already exists Resource created outside IaC (console/another tool) API error “already exists” on create terraform import <addr> <id> to adopt it
7 CloudFormation stack stuck UPDATE_ROLLBACK_FAILED A resource couldn’t roll back cleanly Stack events show the failed resource continue-update-rollback skipping the resource; then fix
8 Bicep/ARM deploy: InvalidTemplateDeployment Bad reference, scope mismatch, or quota az deployment group what-if; deployment error detail Fix the reference/scope; check quota; redeploy
9 CDK cdk deploy replaces a resource unexpectedly A change forced replacement (immutable property) cdk diff shows “(may be replaced)” / “[-]” Use RemovalPolicy.RETAIN on data; plan the replacement
10 Drift undetected; apply reverts a 2am hot-fix No scheduled drift check; console write open in prod The reverted change reappears as an incident Scheduled CI plan/what-if alert; lock prod console
11 Module change silently alters many environments Module consumed without a pinned version source with no ref/version constraint Pin ?ref=<tag> / version; treat modules as versioned libs
12 Auth fails: 401/403 from the provider Stale token or under-permissioned SPN/identity Provider auth error; check identity & role Re-auth; grant least-priv role (see the Terraform auth article)

The expanded reasoning for the ones that cause the worst incidents:

1. Error acquiring the state lock. A crashed or killed apply (CI runner OOM’d, network dropped) left the lock held, and now every run blocks. Confirm: the error names the lock ID and who/what holds it; verify in CI that no apply is genuinely running. Fix: once you are certain no apply is in flight, terraform force-unlock <lock-id>. Forcing a lock while an apply is running risks the exact corruption the lock prevents — so confirm first.

2. Plan wants to destroy and recreate a database. You renamed a resource or module in code; Terraform sees the old address gone and the new one absent from state, so it plans to destroy and create. On a database that’s data loss hiding behind a refactor. Confirm: the plan shows -/+ (destroy then create) or “N to destroy” on a stateful resource. Fix: terraform state mv <old.address> <new.address> to re-address in state before applying, so it’s an in-place no-op. This is precisely why a destroy of anything stateful must fail the build or demand explicit approval.

3. Secrets in state. Terraform/Pulumi store resource attribute values in state, including a generated DB password or a private key — in plaintext. Anyone who can read the backend can read the secret. Confirm: grep the state file for the value; it’s there. Fix: encrypt the backend (S3 SSE / Blob encryption), tightly restrict who can read it, prefer references to a secret store over generating secrets in IaC, and never commit state to Git. If it leaked, rotate the secret — encryption after the fact doesn’t un-leak it.

5. Two pipelines corrupt state. Without a locking backend, two concurrent applies both read the old state and both write, leaving it inconsistent with reality — miserable to repair. Confirm: state shows resources that don’t match reality after overlapping runs. Fix: configure locking (DynamoDB table for S3, blob lease for Azure — automatic for CloudFormation/ARM) and serialise applies in CI (a concurrency group). Locking is not optional for team use.

10. Drift reverts a hot-fix. Someone widened a connection limit in the console at 2am to stop an outage; nobody put it in code; the next routine apply narrows it back and the outage returns — now “caused by the deploy.” Confirm: the reverted value reappears as a fresh incident right after an apply. Fix: lock production console-write to break-glass, run a scheduled plan/what-if that alerts on drift, and make adopting-or-reverting drift a deliberate decision — not a surprise side effect of the next apply.

Best practices

Security notes

The security controls that also make IaC more reliable — secure and robust pull the same direction:

Control Mechanism Secures against Also prevents
Encrypted, access-controlled state Backend encryption + RBAC Secret leakage from state Accidental state corruption by the wrong hands
Least-priv federated deploy identity OIDC + scoped role Stolen long-lived keys; over-broad blast radius A runaway apply taking down unrelated resources
Policy as code (shift-left + control plane) OPA/Sentinel + Azure Policy/SCP Non-compliant infra reaching prod Console-click drift bypassing the pipeline
Pinned modules/providers + private registry Version constraints + registry Supply-chain tampering Surprise breaking changes from moved tags
Sensitive outputs redacted sensitive = true Secrets in CI logs Noisy diffs leaking internal values
Plan review (four-eyes) PR approval on the plan Malicious/accidental destructive change Data-loss applies (destroy on stateful resources)

Cost & sizing

IaC tooling itself is mostly free or cheap; the cost lives in the managed services and in the resources you provision. Where the money actually goes:

The real lever IaC gives you on cost is policy-enforced sizing: allow-list SKUs/instance types, require autoscale bounds, mandate cost-center tags, and block oversized resources in the plan — so cost control is enforced before deploy, not discovered on the invoice. A rough monthly picture for a mid-size team:

Cost driver What you pay for Rough INR / month Notes
IaC core tools Terraform/OpenTofu/Pulumi/CDK/Bicep/CFN ₹0 Open-source / free to use
State backend (self-hosted) S3+DynamoDB / Azure Blob / GCS ~₹100–500 Negligible; effort > spend
HCP Terraform / Pulumi Cloud (team) Managed state, policy, runs ~₹0–per-resource Free tiers exist; scales with resource count
CI minutes for plan/apply Pipeline compute ~₹500–5,000 Depends on plan frequency & runners
Integration test resources Real cloud during tests ~₹500–5,000 Run nightly, tear down, or it balloons
Policy/scan tooling OPA/tfsec/checkov/cdk-nag ₹0 Open-source; CI minutes only

The sizing rule: the tool choice barely moves the bill — the resources your IaC provisions do. Put your cost effort into policy-enforced sizing and tagging so the infrastructure your code creates stays right-sized, and into tearing down ephemeral test environments so they don’t leak. Choosing Terraform over Bicep (or vice versa) is a velocity and lock-in decision, not a cost one.

Interview & exam questions

1. Explain declarative vs imperative IaC, and where CDK and Pulumi sit. Declarative (Terraform, CloudFormation, Bicep) describes the desired end state and the engine computes the diff and ordering. Imperative (Ansible) describes the steps. CDK and Pulumi are imperative authoring over a declarative engine: you write real code (loops, types) that generates a desired-state artifact (CloudFormation template / resource graph), which a declarative engine then converges — so you get code ergonomics and a previewable diff.

2. What is in a Terraform state file, and why must it be protected? State maps each declared resource to its real cloud ID and last-known attributes — and can contain sensitive values (generated passwords, keys) in plaintext. It must be in a remote backend that is encrypted, access-controlled and locked, never committed to Git. Anyone who can read state may be reading your secrets; a leaked secret must be rotated.

3. Why do CloudFormation and Bicep “have no state file” while Terraform does? CloudFormation/ARM/Bicep keep the engine’s record of deployed resources inside the cloud control plane (the stack/deployment), so there’s no file for you to host, lock, or lose. Terraform/Pulumi keep it in an explicit state object you manage. The trade-off: managed state is one less thing to break (and locks automatically) but ties you to one cloud; explicit state is portable and inspectable but yours to secure and lock.

4. What is drift and why is it dangerous? Drift is reality diverging from code — someone edits a resource outside IaC. It’s dangerous because the next apply may revert a critical out-of-band hot-fix (re-causing an outage) or fail, and because once code and reality disagree your “documentation” is lying and teams stop trusting it. Detect with plan/what-if/drift-detection; prevent by locking console-write and scheduling drift alerts.

5. A terraform plan shows your database will be destroyed and recreated after a code refactor. What happened and what do you do? A resource or module was renamed (its address changed); Terraform sees the old address gone and the new one absent from state, so it plans destroy + create — data loss behind a rename. Fix with terraform state mv <old> <new> to re-address in state before applying, making it an in-place no-op. This is why a destroy of stateful resources must gate the build.

6. When is multi-cloud a real reason to choose Terraform/Pulumi, and when isn’t it? Real when you genuinely run more than one cloud, or manage lots of SaaS (Cloudflare, Datadog, GitHub) alongside one cloud, or need a portable workflow as an exit option. Not real for a single-cloud team that will stay put — there, cloud-native (Bicep/CDK/CFN) gives day-zero features and managed state without the state-hosting and provider-lag tax. And note: the resource definitions aren’t portable across clouds even with Terraform; only the tool, language and workflow are.

7. How do you prevent two concurrent applies from corrupting state? Use a locking backend: DynamoDB table for S3, blob lease for Azure Blob, or the automatic per-stack locking CloudFormation/ARM provide. The lock makes a second apply wait until the first releases. Also serialise applies in CI with a concurrency group. Without locking, two applies can both read old state and both write, leaving it inconsistent.

8. What is policy as code, and where should it be enforced? Machine-readable rules (OPA/Rego, Sentinel, CDK Nag, Azure Policy) that pass or fail a change, moving compliance from audit to the PR. Enforce in two places: shift-left (evaluate the plan in CI — fast feedback) and control plane (Azure Policy/SCP/Org Policy reject non-compliant resources regardless of tool or path — the non-bypassable backstop that catches console clicks).

9. Describe a sound infrastructure testing strategy. A pyramid: lint + security scan (tfsec/checkov/cdk-nag) on every commit; plan/what-if review on every PR (the cheapest high-value gate, failing on surprise destroys); unit/synth assertions for generation logic (terraform test, CDK assertions); and ephemeral integration tests (Terratest/TaskCat) on merge/nightly that deploy real resources, assert they work, and tear down. Cheap/static runs often; expensive/real runs rarely.

10. What makes a good module, and what’s the god-module anti-pattern? A good module has one responsibility, a small set of inputs with sane defaults, outputs as its only contract, and a pinned version. The god module takes dozens of inputs and provisions a whole environment — huge blast radius, slow plan, a change to one corner risks everything. Compose small versioned modules with a thin root instead.

11. Terraform vs OpenTofu — what changed and how do you choose? HashiCorp moved Terraform to the BSL 1.1 licence (restricting some commercial uses) in 2023; the community forked OpenTofu under MPL 2.0 (permissive, community-governed, same HCL and providers). Choose Terraform for HashiCorp’s ecosystem/HCP features, OpenTofu for a permissive licence and open governance — they’re near-drop-in interchangeable.

12. Why might you use Ansible alongside Terraform rather than instead of it? Terraform excels at provisioning cloud resources (desired-state, plan, graph); Ansible excels at configuration inside machines (installing packages, templating config, sequencing operations) agentlessly and idempotently. A common pattern is Terraform to build the infrastructure, then Ansible to configure what runs on it — each used where it’s strongest, since Ansible lacks a converged-graph plan and Terraform isn’t built for in-OS config.

These map to HashiCorp Terraform Associate (state, modules, providers, workflow), AWS DevOps Engineer Professional and AWS SAP (CloudFormation, CDK, change sets, StackSets), Azure AZ-400 / AZ-104 (Bicep/ARM, what-if, deployment scopes), and GCP Professional Cloud DevOps (Terraform on GCP, Config Connector). A compact cert mapping:

Question theme Primary cert Objective area
State, modules, providers, workflow Terraform Associate Core Terraform operation
CloudFormation/CDK/change sets/StackSets AWS DevOps Pro / SAP IaC on AWS
Bicep/ARM, what-if, deployment scopes Azure AZ-400 / AZ-104 IaC on Azure
Policy as code, guardrails AZ-400 / AWS DevOps Pro Governance & compliance
Drift, testing, CI integration AZ-400 / AWS DevOps Pro Pipeline & quality
Multi-cloud / Config Connector GCP DevOps / Terraform Associate Cross-cloud provisioning

Quick check

  1. CDK and Pulumi let you write loops and conditionals in a real language — does that make them imperative or declarative? Explain the subtlety.
  2. Why do Terraform and Pulumi have a state file to manage while CloudFormation and Bicep do not?
  3. A routine terraform apply just reverted a connection-limit change someone made in the console at 2am, re-causing an outage. What’s the underlying problem and the durable fix?
  4. Name the two places policy as code should be enforced, and what each one catches that the other misses.
  5. Your team is single-cloud on Azure and intends to stay. What’s the honest argument against choosing Terraform “for multi-cloud,” and what would you pick instead?

Answers

  1. Both, at different layers. You author imperatively (real code with loops/types), but that code generates a declarative artifact (a CloudFormation template for CDK; a resource graph for Pulumi) which a declarative engine then converges. So they give you code ergonomics and a previewable diff — imperative authoring over a declarative engine.
  2. Terraform/Pulumi keep the engine’s record of deployed resources in an explicit state object you host (tfstate / Pulumi state), so it’s diffable and portable but yours to secure and lock. CloudFormation/ARM/Bicep keep the equivalent inside the cloud control plane (the stack/deployment), so there’s no file to host or lose — and it locks automatically — at the cost of single-cloud lock-in.
  3. Undetected drift. The hot-fix lived only in reality, not in code, so the next apply reverted it. Durable fix: lock production console-write to break-glass so changes go through code, and run a scheduled plan/what-if that alerts on drift so divergence is investigated deliberately, not reverted by surprise on the next deploy.
  4. Shift-left (OPA/Sentinel/Nag evaluating the plan in CI) gives fast developer feedback but only catches what goes through your pipeline. Control plane (Azure Policy / AWS SCP / GCP Org Policy) rejects non-compliant resources regardless of tool or path — catching console clicks and rogue pipelines the shift-left check never sees. Run both.
  5. Choosing Terraform for an option you’ll never exercise pays a real tax — hosting/locking/securing state and provider lag behind new Azure features — for portability that, in any case, only covers the workflow (the resource definitions aren’t portable across clouds anyway). For a staying-put Azure team, pick Bicep: day-zero feature support, no state file to manage, and native tooling.

Glossary

Next steps

You can now choose an IaC tool by the boundary of infrastructure and the team you have, and run the author → plan → policy → apply → drift-check loop safely. Build outward:

DevOpsIaCTerraformPulumiAWS CDKBicepCloudFormationPolicy as Code
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading