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:
- Explain declarative vs imperative IaC precisely, and predict how each behaves at 10 resources versus 200 — including where “imperative that produces a declarative artifact” (CDK) sits.
- Compare Terraform, OpenTofu, Pulumi, AWS CDK, Bicep, CloudFormation, ARM and Ansible on language, state, multi-cloud reach, drift handling, testing, blast radius and team fit — and pick the right one for a given boundary of infrastructure.
- Describe how state works (what’s in it, where it lives, locking, secrets exposure) and why CloudFormation/Bicep “have no state file” while Terraform/Pulumi do.
- Detect and remediate drift with the right command per tool, and design a workflow that prevents drift rather than chasing it.
- Structure modules and composition so they scale across dozens of teams without becoming a tangle of implicit dependencies.
- Choose a testing strategy across the pyramid — static analysis, unit/plan tests, policy checks, and ephemeral integration environments.
- Enforce policy as code (OPA/Sentinel/CDK Nag/
azure-policy) so non-compliant infrastructure fails the pipeline, not the audit. - Decide honestly when multi-cloud is a real requirement and when a cloud-native tool is the better engineering choice.
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.
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.
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
- Remote, locked, encrypted state — always. Never on a laptop, never in Git. S3+DynamoDB / Azure Blob lease / GCS / Pulumi Cloud. Locking is mandatory for team use; encryption protects the secrets state contains.
- The plan/what-if is the review gate. Every change runs
plan/preview/what-if/change-set in CI on the PR, and a destroy of anything stateful fails the build or requires explicit approval. No apply from a laptop with god rights. - Apply only from CI, with scoped credentials. The pipeline holds the deploy identity (least-privilege, short-lived/federated — see CI/CD Secrets and Credential Management); humans don’t run prod applies locally.
- Small, single-responsibility, version-pinned modules. Compose a thin root from narrow modules; pin every module to a tag/version so upstream changes can’t surprise twelve teams. Avoid the 40-input god module.
- Slice state by blast radius. One state per environment-and-component (network, data, compute), not one state for the whole company. Smaller blast radius, faster plans, safer changes.
- Policy as code on every plan, plus a control-plane backstop. Shift-left (OPA/Sentinel/Nag on the plan) for fast feedback; Azure Policy / SCP / Org Policy at the control plane to catch anything that bypasses the pipeline.
- Test across the pyramid. Lint + security scan on every commit, plan review on every PR, unit/synth assertions for generation logic, ephemeral integration tests on merge/nightly (always tear down).
- Pin provider and tool versions.
required_version, providerversionconstraints, and a committed lockfile so the same code produces the same result for everyone and in CI. - Detect drift on a schedule and lock prod console-write. A nightly
plan/what-ifthat alerts on divergence; production write access only via break-glass, so drift is the exception you investigate, not the norm. - Choose the tool by the boundary, not by power. Cloud-native for the cloud you live in; Terraform/Pulumi where multi-cloud or SaaS is real. Don’t pay the multi-cloud tax for an option you’ll never exercise.
- Manage secrets by reference, not by value. Pull from Key Vault / Secrets Manager / Pulumi ESC at deploy time; avoid generating secrets that then sit in plaintext state.
- Make modules and policies discoverable. A private registry, documented inputs/outputs, and a starter policy set turn “everyone reinvents it” into “everyone consumes the paved road.”
Security notes
- State is a secrets store — treat it like one. Terraform/Pulumi state can hold passwords, keys and connection strings in plaintext. Encrypt the backend, restrict read access to the few who need it, enable versioning for recovery, and never commit state to a repo. If a secret ever lands in state history (or Git history), rotate it — the leak is permanent until you do.
- Least-privilege deploy identity. The pipeline’s IaC credential should have exactly the rights to manage what it manages — not Owner/Admin on the whole subscription/account. Prefer short-lived, federated credentials (OIDC to the cloud) over long-lived secrets, so there’s no static key to steal.
- Policy as code as a security control. Encode your security baseline (no public data stores, encryption required, no 0.0.0.0/0 on admin ports, approved regions) as failing policies, enforced both shift-left and at the control plane. A guardrail that fails the build is stronger than a guideline in a wiki.
- Protect the module supply chain. Pin module versions and provider sources, scan modules you consume, and prefer a private registry over arbitrary Git URLs — a compromised or moved upstream module is a supply-chain risk that ships straight to production.
- Don’t expose secrets in outputs or logs. Mark sensitive outputs
sensitive = trueso they don’t print in plan/apply logs or CI output; redact provider debug logs, which can contain credentials. - Separate duties between plan and apply. A reviewer approves the plan; the pipeline applies. The person who writes a change shouldn’t be the only gate on it reaching production — the plan review is the four-eyes control.
- Audit and immutability. Because every change is a Git commit and a CI run, you get a complete audit trail of who changed what infrastructure when — preserve it (protected branches, CI logs) as your evidence for compliance.
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 tools. Terraform, OpenTofu, Pulumi (core), CDK, Bicep, CloudFormation and ARM are free to use. Paid tiers buy team features: HCP Terraform / Terraform Enterprise (remote state, Sentinel policy, run management — priced per resource/run), Pulumi Cloud (free for individuals, paid per resource for teams), and CI minutes for whichever pipeline runs them. CloudFormation and Azure deployments have no separate IaC charge.
- State backends are nearly free. An S3 bucket + DynamoDB lock table or an Azure Blob container cost rupees per month — negligible. The cost discipline here is operational, not financial: hosting and securing state is effort, not spend.
- Integration tests cost real money. Terratest/TaskCat spin up real resources; an hour of a few VMs and a managed DB per test run adds up if you run them on every commit. Run them on merge/nightly, and always tear down — a leaked test environment is the classic surprise on the bill.
- Policy and scanning (OPA/Conftest, tfsec, checkov, cfn-lint, cdk-nag) are open-source and free; they cost CI minutes, not licences. Cheap insurance against expensive misconfigurations.
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
- CDK and Pulumi let you write loops and conditionals in a real language — does that make them imperative or declarative? Explain the subtlety.
- Why do Terraform and Pulumi have a state file to manage while CloudFormation and Bicep do not?
- A routine
terraform applyjust 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? - Name the two places policy as code should be enforced, and what each one catches that the other misses.
- 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
- 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.
- 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.
- 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-ifthat alerts on drift so divergence is investigated deliberately, not reverted by surprise on the next deploy. - 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.
- 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
- Infrastructure as Code (IaC) — defining infrastructure in version-controlled files so it’s reviewable, testable and reproducible, with an engine that converges reality to the definition.
- Declarative — you describe the desired end state; the engine computes the diff and ordering (Terraform, CloudFormation, Bicep, ARM).
- Imperative — you describe the steps to take (Ansible); CDK/Pulumi are imperative authoring over a declarative engine.
- State — the engine’s record of what it created (Terraform/Pulumi keep an explicit file; CloudFormation/ARM keep it in the cloud control plane); can contain secrets in plaintext.
- Backend — where state is stored (S3+DynamoDB, Azure Blob, GCS, Pulumi Cloud); provides durability, access control and locking.
- Locking — an exclusive lock during an apply so concurrent applies can’t corrupt state; automatic for CloudFormation/ARM, configured for Terraform.
- Drift — reality diverging from the code (an out-of-band change); detected via
plan/what-if/drift-detection. - Provider — a plugin mapping resource types to a cloud/SaaS API (
azurerm,aws,google, Cloudflare, Datadog); Terraform/Pulumi have thousands. - Module / construct / component — a reusable, parameterised unit of infrastructure (Terraform module, CDK construct, Pulumi component, Bicep module).
- Plan / preview / what-if / change set — a dry-run showing exactly what a change will do before it does it; the review gate.
- Stack / workspace — an isolated instance of state; your unit of change and blast radius.
- HCL — HashiCorp Configuration Language, Terraform/OpenTofu’s declarative syntax.
- Bicep — Azure’s declarative DSL that transpiles to ARM JSON, with day-zero Azure feature support.
- CloudFormation — AWS’s native declarative templates (YAML/JSON) with managed state; CDK’s compile target.
- AWS CDK — imperative code (TS/Python/etc.) that synthesises CloudFormation.
- Pulumi — IaC in real languages over a declarative engine, with explicit state.
- OpenTofu — the MPL-2.0 community fork of Terraform after the BSL licence change; near-drop-in compatible.
- Ansible — agentless, idempotent imperative automation for configuration management and orchestration.
- Policy as code — machine-readable rules (OPA/Rego, Sentinel, CDK Nag, Azure Policy) that pass/fail a change; enforced shift-left and/or at the control plane.
- Idempotence — running the same definition twice produces the same result and makes no changes the second time.
- Day-zero feature support — a tool can use a cloud’s newest features immediately (cloud-native tools) versus waiting for a provider update (Terraform’s provider lag).
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:
- Next: CI/CD Pipelines Explained: From Code Commit to Production — the pipeline that runs your plan and gated apply.
- Related: Bicep vs ARM vs Terraform: Choosing the Right IaC Tool for Azure — the Azure-specific narrowing of this decision.
- Related: Terraform on Azure: Remote State in Blob Storage with State Locking & Workspaces — set up a production-grade, locked state backend.
- Related: Reusable Terraform Modules at Scale on Azure DevOps: Repo-per-Module, Versioning & Composition — module structure and versioning for many teams.
- Related: GitOps with Argo CD and Flux: Deliver from Git — the continuous-reconciliation model that auto-reverts drift for Kubernetes workloads.
- Related: CI/CD Secrets and Credential Management: Secure Your Pipelines — the least-privilege, federated credentials your IaC needs to authenticate.