Platform Engineering in Depth · Infrastructure as Code & Control Planes

Infrastructure as Code & Control Planes

Somewhere behind every self-service button is a promise: “ask for a database, a network, a whole cluster — and get it, correctly, every time.” Infrastructure as Code is how a platform keeps that promise without a human clicking through a cloud console at 2am. But there are two very different philosophies for doing it. The first runs a tool to completion — you type a command, it makes the world match your files, and then it walks away. The second installs an always-on control plane that watches your infrastructure forever and quietly repairs it. This page goes deep on both: the mechanics of Terraform, OpenTofu, and Pulumi; the control-plane world of Crossplane and Cluster API; the cloud-native operators that sit between them; and — most importantly — when to reach for which, and how each one behaves on the bad days.

☺ Explain it like I’m 10

Imagine building with Lego from an instruction sheet. One way: you follow the sheet once, build the castle, and leave. If your little brother knocks off a tower, it stays knocked off until you sit down and follow the sheet again. The other way: you hire a tiny tireless robot who keeps the instruction sheet in its pocket, glances at the castle every few seconds, and instantly snaps any missing brick back on. Both build the same castle from the same sheet — but the first is a one-time job and the second is a forever-guard. “Infrastructure as Code” is writing the sheet; the big question of this whole page is whether you want the one-time builder (Terraform, Pulumi) or the forever-guard (Crossplane, Cluster API).

🦋🦫Your hosts for this topic: Mira the Butterfly & Benny the Beaver — Benny lays the classic IaC rails (the run-to-completion tools that build the world from a file), and Mira turns raw infrastructure into friendly Kubernetes APIs (the control-plane approach). 🤖 Recon the Robot drops in whenever “continuous reconciliation” is the point.

What Infrastructure as Code actually is

☺ Like you’re 10: Instead of clicking buttons in a cloud website to make servers, you write down what you want in a file, and a program builds it for you — the same way, every time.

Before IaC, infrastructure was clickops: an engineer logged into the cloud console and clicked their way to a VPC, a subnet, a database. It worked once, but it left no record of what was built or why, it couldn’t be reviewed or repeated, and every environment slowly became a unique “snowflake” nobody dared touch. IaC replaces the clicking with files you commit to Git. Those files are the truth; a tool reads them and makes the cloud match. Suddenly infrastructure is reviewable in a pull request, versioned in history, testable in CI, and reproducible from scratch. This is the same move GitOps makes for applications, applied one layer down to the plumbing.

Declarative desired state, not a script of steps

☺ Like you’re 10: You describe the finished picture — “three servers, one database” — not the ten steps to get there. The tool figures out the steps.

The heart of modern IaC is declarative configuration. You don’t write “create a server, then attach a disk, then open a firewall port.” You describe the end state you want — the desired shape of the world — and the tool computes the actions needed to reach it from wherever things currently stand. This is the difference between a recipe (imperative: do these steps in order) and a photograph (declarative: make it look like this). Declarative wins for infrastructure because the same file works whether you’re building from nothing, changing one field, or repairing half-broken state — the tool always compares desired to actual and does only the delta.

Idempotency and the plan/apply ritual

☺ Like you’re 10: Running it twice doesn’t build two databases. If it’s already right, the tool shrugs and does nothing.

Idempotency is the property that applying the same desired state twice produces the same result — the second run is a no-op because reality already matches. That’s what makes IaC safe to run on a schedule or in a pipeline: it converges toward the target instead of blindly re-creating things. Most tools split the work into two phases. Plan is a dry run: the tool reads your files, inspects the real world, and prints a diff — what it will create, change, replace, or destroy — without touching anything. Apply executes that plan. The plan is your seatbelt: a reviewer reads it in a pull request and catches “wait, why does this want to destroy the production database?” before it happens.

◆ Key idea

Every IaC tool answers the same three questions: what do I want? (the declarative config), what exists now? (discovered from the provider, and often cached in state), and what’s the smallest set of changes to close the gap? (the plan). Master those three and every tool on this page becomes a variation on one theme.

State, drift, and why they haunt you

☺ Like you’re 10: The tool keeps a little notebook of what it built. If the real world quietly changes without updating the notebook, that mismatch is called “drift.”

To compute a plan, a tool needs to know which real cloud resources correspond to which lines in your config — the database in your file is this specific RDS instance with that ARN. Many tools record this mapping in a state file. State is powerful (it lets the tool track resources it can’t always look up, and compute dependency order) but it is also the single biggest source of pain in classic IaC, as you’ll see. Drift is when actual state diverges from desired — someone hand-edits a security group in the console, or an autoscaler changes a count. How a tool detects and corrects drift is the deepest fault line running through this entire page: run-to-completion tools only notice drift the next time you run them; control planes notice within seconds because they never stop looking.

Terraform & OpenTofu — the classic engine

☺ Like you’re 10: Terraform is the most popular “write a file, build the cloud” tool. It speaks its own tidy config language and can build almost anything.

Terraform, created by HashiCorp, is the tool most people mean when they say “infrastructure as code.” You write configuration in HCL (HashiCorp Configuration Language), run terraform plan to preview, and terraform apply to build. Its superpower is breadth: through a plugin system it can manage almost any API on earth — AWS, GCP, Azure, Kubernetes, Cloudflare, Datadog, GitHub — with the same workflow.

HCL, providers, and modules

Providers are the plugins that teach Terraform how to talk to a specific API; each one maps HCL resource types (like aws_db_instance) to real API calls. Resources are the things you declare; data sources read existing things; variables and outputs parameterise and expose values. The unit of reuse is the module: a folder of resources packaged and versioned so a whole VPC-with-subnets pattern becomes one call. A realistic root module wires all of this together, including where its state lives:

terraform {
  required_version = ">= 1.6"
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
  backend "s3" {                        # remote state + locking (below)
    bucket         = "acme-tf-state"
    key            = "prod/network.tfstate"
    region         = "eu-west-1"
    dynamodb_table = "acme-tf-locks"    # advisory lock table
    encrypt        = true
  }
}

provider "aws" {
  region = var.region
}

module "vpc" {                          # reuse: infra packaged as a versioned unit
  source  = "terraform-aws-modules/vpc/aws"
  version = "5.8.1"
  name    = "prod"
  cidr    = "10.0.0.0/16"
  azs             = ["eu-west-1a", "eu-west-1b", "eu-west-1c"]
  private_subnets = ["10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24"]
}

resource "aws_db_instance" "orders" {
  identifier           = "orders-db"
  engine               = "postgres"
  engine_version       = "16"
  instance_class       = "db.t3.medium"
  allocated_storage    = 50
  db_subnet_group_name = module.vpc.database_subnet_group
}

The state file & backends — the crown jewels and the sharp edge

☺ Like you’re 10: The notebook that says “this line in my file = that real database.” Lose it or let two people scribble in it at once and things break.

Terraform records the config-to-reality mapping in terraform.tfstate. By default it’s a local JSON file — fine for one person, a disaster for a team. Two engineers running apply against the same local state would corrupt it. The fix is a remote backend: store state in shared, durable storage (S3, GCS, Azure Blob, HashiCorp’s Terraform Cloud/HCP) with state locking so only one apply runs at a time. The backend "s3" block above uses a DynamoDB table as an advisory lock. State also holds every attribute of every resource — including secrets like the database password Terraform generated — so state is sensitive and must be encrypted and access-controlled.

⚠ State is where Terraform hurts

The state file is Terraform’s greatest strength and its sharpest edge. It can drift from reality (someone deletes a resource by hand and state still “thinks” it exists). It has a blast radius: one giant state means one apply can touch everything, so teams split infra into many small states — but then wiring outputs between them gets fiddly. Adopting existing resources means a careful terraform import. Renaming or moving resources needs moved blocks or terraform state mv or Terraform will propose to destroy and recreate. None of this is a bug — it’s the tax you pay for a run-to-completion tool that must remember what it did between runs.

Plan, apply, and workspaces

The daily loop is plan then apply. A plan is a precise, reviewable diff — the artefact you attach to a pull request:

$ terraform plan

Terraform will perform the following actions:

  # aws_db_instance.orders will be created
  + resource "aws_db_instance" "orders" {
      + engine         = "postgres"
      + engine_version = "16"
      + instance_class = "db.t3.medium"
      + identifier     = "orders-db"
      + id             = (known after apply)
    }

Plan: 1 to add, 0 to change, 0 to destroy.

Workspaces let one configuration hold several independent states — a quick way to get dev and prod copies of the same infra. They’re handy for small cases, but many teams avoid them for real environment separation (a workspace shares the same backend and code path, so it’s easy to apply to the wrong one). At scale, a directory-per-environment layout, or a wrapper like Terragrunt, gives clearer blast-radius boundaries than workspaces alone.

The OpenTofu fork — same HCL, different licence

☺ Like you’re 10: In 2023 Terraform changed its rulebook about who’s allowed to use it and how. A group of people didn’t like that, so they made a free copy that keeps the old rules.

In August 2023 HashiCorp relicensed Terraform from the open-source MPL 2.0 to the Business Source License (BSL) 1.1 — a source-available licence with restrictions on competing commercially. The community responded by forking the last MPL version into OpenTofu (briefly “OpenTF”), now a Linux Foundation project. For most day-to-day work OpenTofu is a drop-in replacement — same HCL, same provider ecosystem, the tofu CLI mirroring terraform. It has since grown features of its own, notably native state encryption, early variable evaluation, and provider-defined functions. For a platform team the practical takeaway is: know that the fork exists, know it’s licence-driven not technology-driven, and pick based on your organisation’s licensing posture and which one your tooling supports.

DimensionTerraformOpenTofuPulumi
Config languageHCL (a DSL)HCL (same DSL)Real languages: TS, Python, Go, C#, Java
LicenceBSL 1.1 (source-available)MPL 2.0 (open source, LF)Apache 2.0 (open source)
State modelState file + backendsState file + backends (adds encryption)State via Pulumi Cloud or self-managed backend
GovernanceHashiCorp (IBM)Linux FoundationPulumi Corp.
Feels likeThe industry defaultThe open-source TerraformSoftware engineering for infra

Pulumi — infrastructure in a real programming language

☺ Like you’re 10: Same idea as Terraform, but you write it in a normal coding language you might already know, so you can use loops, if-statements, and functions.

Pulumi keeps the declarative desired-state model but lets you author it in general-purpose languages — TypeScript, Python, Go, C#, Java, or a YAML dialect — instead of a bespoke DSL. You still describe resources; you just get the full expressiveness of a real language and its ecosystem around them.

When real code genuinely helps (and when it doesn’t)

Using a real language pays off when infrastructure has logic: looping over a dynamic list to create one bucket per environment, sharing an abstraction as an ordinary imported library, writing unit tests for your infra with the same framework your app uses, or generating config from data your program already holds. A short program shows the appeal — a typed loop with a real conditional:

import * as aws from "@pulumi/aws";

// a real loop builds one bucket per environment — typed and testable
const envs = ["dev", "staging", "prod"];

const buckets = envs.map(env =>
  new aws.s3.Bucket(`assets-${env}`, {
    bucket: `acme-assets-${env}`,
    versioning: { enabled: env === "prod" },   // a real conditional
    tags: { env, managedBy: "pulumi" },
  })
);

export const bucketArns = buckets.map(b => b.arn);
⚠ Power cuts both ways

A Turing-complete language means you can write infrastructure that’s hard to review and whose diff is hard to predict — a clever loop that suddenly renames forty resources, or non-deterministic code that plans differently on every run. HCL’s deliberate limits are a feature for exactly this reason: what you see is close to what you get. Pulumi shines for teams who treat infra as software and will apply software discipline (tests, review, small functions); it can hurt teams who reach for cleverness where boring declarations would do.

Pulumi’s state model and engine

☺ Like you’re 10: Your program doesn’t build anything directly — it hands a shopping list to an engine, which compares it to a saved list and buys only the difference.

Under the hood Pulumi separates your program from the work. A language host runs your code, which registers resources with the deployment engine; the engine builds a resource graph, diffs it against saved state, and calls providers to make changes (many Pulumi providers are bridged from the same upstream as Terraform’s, so coverage is broad). Like Terraform, Pulumi keeps state — by default in the managed Pulumi Cloud, but you can self-manage it in S3, GCS, Azure Blob, or a local file. One extra card up its sleeve for platform teams is the Automation API: drive Pulumi as a library from inside your own service, which is a clean way to build a self-service backend that provisions infra on demand rather than shelling out to a CLI.

The control-plane approach — Crossplane

☺ Like you’re 10: Instead of running a tool now and then, you install a forever-robot inside Kubernetes that keeps your cloud infrastructure matching your wishes all day, every day.

Everything so far is run-to-completion: a human or pipeline invokes a CLI, it converges the world, it exits. Crossplane (a CNCF project) flips the model. It turns your Kubernetes cluster into a control plane for infrastructure: you declare cloud resources as Kubernetes objects, and long-running controllers reconcile them continuously. Nothing is ever “done” — the loop that built your database also guards it forever. This is the same operator pattern from Platform APIs, CRDs & Operators, pointed at RDS and VPCs instead of at your own apps.

Continuous reconciliation vs one-shot apply

☺ Like you’re 10: Delete a Crossplane-managed bucket by hand in the cloud console, and a minute later it’s back — the robot noticed and rebuilt it. A Terraform-built bucket stays deleted until someone runs Terraform again.

This is the philosophical core of the page. A Crossplane controller is level-triggered: it repeatedly compares desired state (your Kubernetes objects) to actual state (the cloud) and drives the gap to zero, forever. Drift isn’t something you scan for on a schedule — it’s corrected within a reconcile interval automatically, the same way GitOps self-heals your apps. The cost is that infrastructure now behaves like a living system with a controller you must run and watch, rather than a script you invoke and forget.

RUN-TO-COMPLETION (Terraform · Pulumi) apply apply (fixes it) 😴 between runs: nobody is watching ✎ someone hand-edits the cloud → drift persists ⟶ CONTINUOUS CONTROL PLANE (Crossplane · Cluster API) 🤖 🤖 🤖 🤖 reconcile reconcile reconcile ✎ drift appears → repaired on the very next loop, no human needed the loop never stops — “done” is not a state that exists

Providers and Managed Resources — the raw bricks

☺ Like you’re 10: You install a “cloud pack,” and now Kubernetes learns a word for every cloud thing — a Bucket, a Database, a Network — each guarded by its own robot.

A Crossplane Provider is a package that installs a bundle of CRDs plus their controllers for one cloud (for example provider-aws, provider-gcp, provider-azure; the modern families are often generated from upstream Terraform providers, giving enormous coverage). Each CRD is a Managed Resource (MR): a high-fidelity, one-to-one representation of a single external resource — an RDSInstance, a Bucket, a Subnet. You could hand developers raw MRs, but that’s just cloud APIs re-spelled as YAML — the caterpillar, not the butterfly. The real power is the layer on top.

XRDs, Compositions, and Claims — infrastructure as your own API

☺ Like you’re 10: The platform team invents a friendly word — “PostgreSQLInstance” — and a recipe that turns that one word into all the messy cloud bricks. Developers just say the friendly word.

Crossplane’s composition engine lets a platform team publish its own abstractions, exactly like designing a CRD and operator — but without writing controller code. Three pieces:

# 1) Platform team defines a new API (XRD) — the friendly word + its schema
apiVersion: apiextensions.crossplane.io/v1
kind: CompositeResourceDefinition
metadata:
  name: xpostgresqlinstances.platform.acme.io
spec:
  group: platform.acme.io
  names:      { kind: XPostgreSQLInstance, plural: xpostgresqlinstances }
  claimNames: { kind: PostgreSQLInstance,  plural: postgresqlinstances }
  versions:
    - name: v1alpha1
      served: true
      referenceable: true
      schema:
        openAPIV3Schema:
          type: object
          properties:
            spec:
              type: object
              properties:
                size:   { type: string, enum: [small, medium, large] }
                region: { type: string }
---
# 2) A developer writes a namespaced Claim — the entire interface they see
apiVersion: platform.acme.io/v1alpha1
kind: PostgreSQLInstance
metadata:
  name: orders-db
  namespace: checkout
spec:
  size: medium
  region: eu-west-1
  compositeDeletePolicy: Foreground

Because the whole thing lives in the Kubernetes API, it inherits everything Kubernetes already offers: RBAC decides who may create a PostgreSQLInstance, admission webhooks and policy engines can gate it, the audit log records it, and Argo CD or Flux can reconcile the Claims straight from Git. This is precisely how a self-service platform offers “a database” as a one-line request — and it’s why Crossplane sits at the centre of so many internal developer platforms.

🦆 Dot’s-eye view

“I don’t know what an RDSInstance, a SubnetGroup, or a SecurityGroup is, and I don’t want to. I write six lines — kind: PostgreSQLInstance, size: medium — commit it, and a minute later there’s a connection secret in my namespace. If I delete the Claim, the whole thing tears itself down. Same YAML I use for everything else; no console, no ticket.”

Cluster API — Kubernetes clusters as declarative objects

☺ Like you’re 10: Crossplane makes databases and networks from YAML. Cluster API makes whole Kubernetes clusters from YAML — using one cluster to build and babysit the others.

If Crossplane treats cloud resources as Kubernetes objects, Cluster API (CAPI, a Kubernetes SIG Cluster Lifecycle project) treats entire clusters that way. You describe a cluster — its version, its node pools, its cloud — as a set of Kubernetes resources, and controllers create, scale, upgrade, and repair it. This is how platform teams run a fleet of clusters without hand-crafting each one.

Management clusters vs workload clusters

☺ Like you’re 10: One “boss” cluster holds the blueprints and the robots; the “worker” clusters are the ones the boss builds for your apps to run in.

CAPI splits the world in two. The management cluster runs the CAPI controllers and holds the Cluster, Machine, and provider objects that describe your fleet. Each workload cluster is one the management cluster provisions and then keeps reconciled. The management cluster is small and precious — it’s the control plane for your clusters — while workload clusters come and go as teams and environments need them. (A bootstrap trick called a “pivot” lets a brand-new management cluster move its own CAPI objects onto itself so it becomes self-managing.)

Infrastructure, bootstrap & control-plane providers

CAPI is deliberately modular; three provider roles combine to build a node:

apiVersion: cluster.x-k8s.io/v1beta1
kind: Cluster
metadata:
  name: team-blue
  namespace: fleet
spec:
  controlPlaneRef:                 # a control-plane provider owns the masters
    apiVersion: controlplane.cluster.x-k8s.io/v1beta1
    kind: KubeadmControlPlane
    name: team-blue-cp
  infrastructureRef:              # an infrastructure provider owns the cloud
    apiVersion: infrastructure.cluster.x-k8s.io/v1beta2
    kind: AWSCluster
    name: team-blue
---
apiVersion: cluster.x-k8s.io/v1beta1
kind: MachineDeployment           # like a Deployment, but for worker nodes
metadata:
  name: team-blue-md-0
  namespace: fleet
spec:
  clusterName: team-blue
  replicas: 3
  template:
    spec:
      version: v1.30.2            # bump this → rolling node replacement
      bootstrap:
        configRef:                # a bootstrap provider turns a VM into a node
          apiVersion: bootstrap.cluster.x-k8s.io/v1beta1
          kind: KubeadmConfigTemplate
          name: team-blue-md-0
      infrastructureRef:
        apiVersion: infrastructure.cluster.x-k8s.io/v1beta2
        kind: AWSMachineTemplate
        name: team-blue-md-0

Declarative upgrades and immutable machines

☺ Like you’re 10: To upgrade, you don’t poke the old servers — you change one number and CAPI quietly builds new servers and retires the old ones, one at a time.

Notice the MachineDeployment mirrors a Kubernetes Deployment: it owns MachineSets which own Machines, just as a Deployment owns ReplicaSets which own Pods. Upgrading is therefore beautifully declarative: change version from v1.30.2 to v1.31.0, and CAPI performs a rolling replacement — it provisions fresh nodes at the new version and drains the old ones, never mutating a running machine in place. That’s immutable infrastructure: nodes are cattle, replaced not patched, which makes upgrades repeatable and rollbacks a matter of reverting the number. The same mechanism repairs the fleet — if a node dies, the controller notices the missing Machine and builds a replacement.

Management cluster runs the CAPI controllers 🤖 infrastructure provider 🤖 bootstrap provider 🤖 control-plane provider Cluster · Machine · MachineDeployment the desired fleet, as YAML workload cluster · team-blue control plane + 3 workers workload cluster · team-red control plane + 5 workers workload cluster · staging control plane + 2 workers provision & reconcile change a version number → rolling, immutable node replacement across the fleet
🦋 Mira’s workshop · 20 min

Feel the philosophical split in your hands. On a throwaway kind cluster, install Crossplane and a provider, then create one Managed Resource (a cloud bucket, or a fake one via the built-in nop provider). Now delete that resource out of band — from the cloud console, behind Crossplane’s back — and watch the controller rebuild it within a reconcile interval. Next, do the equivalent with Terraform: apply a bucket, delete it by hand, and notice it stays gone until you run terraform plan again, which now shows drift. Two tools, same desired state, opposite behaviour on the bad day. That single experiment is the whole chapter.

CLI IaC vs control-plane IaC — the deep split

☺ Like you’re 10: One kind of tool does the job once and leaves; the other kind stays and guards the job forever. Neither is “better” — they’re good at different things.

Now we can name the fault line directly. On one side, run-to-completion (CLI) IaC — Terraform, OpenTofu, Pulumi — executes when invoked and exits. On the other, control-plane IaC — Crossplane, Cluster API, cloud operators — installs a controller that reconciles endlessly. They’re not competitors so much as different shapes for different jobs, and mature platforms often run both.

How each handles drift and Day-2

The clearest way to tell them apart is to ask what happens on the days after you build. A CLI tool detects drift only when you next run plan; correcting it is a human-initiated apply. Day-2 operations — upgrades, scaling, rotation — are things you trigger. A control plane detects drift within a reconcile interval and corrects it with no human, and Day-2 knowledge is baked into the controller so it runs continuously. That continuous guarantee is a strength (self-healing, no snowflakes) and a liability (a buggy controller can fight you, and you must operate the control plane itself).

The credential and blast-radius story

☺ Like you’re 10: With the CLI, whoever runs it needs the cloud’s master keys right then. With a control plane, the keys live inside the guard-robot, and people just file friendly requests.

With CLI IaC, powerful cloud credentials must be present wherever apply runs — often a CI runner, which becomes a juicy target. With control-plane IaC, credentials live in the controller inside the cluster; developers never hold them and instead submit Kubernetes objects gated by RBAC and admission — the same push-vs-pull security win that makes GitOps attractive. Blast radius differs too: a single Terraform state can touch everything in one apply, whereas a control plane reconciles each resource independently, so a mistake in one Claim rarely cascades.

DimensionCLI IaC (Terraform · Pulumi)Control-plane IaC (Crossplane · CAPI)
Execution modelRun to completion, then exitAlways-on controller, never “done”
Drift correctionOnly at the next plan/applyAutomatic, within a reconcile interval
StateExplicit state file + backendState lives in the cluster (etcd) + the cloud
CredentialsPresent wherever apply runs (CI risk)Held by the controller; users hold none
Day-2 opsHuman-triggered runsEncoded in the controller, continuous
Provider breadthVast & mature (thousands of providers)Growing fast, often bridged from Terraform
Best atBootstrapping, breadth, one-off & foundational infraSelf-service APIs, fleets, self-healing, GitOps-native infra

When to choose which

A useful rule of thumb: use CLI IaC for the foundation and the bootstrap — the accounts, the networking backbone, the very first (management) cluster — where breadth and a one-shot run fit, and where you may not even have a Kubernetes control plane yet. Use control-plane IaC for the self-service surface and the fleet — the databases and buckets developers request by the hundred, and the workload clusters you spin up and upgrade constantly — where continuous reconciliation and a Kubernetes-native API earn their keep. Many platforms literally use Terraform to build the management cluster, then Crossplane and Cluster API to run everything on top. It’s not either/or; it’s layers.

◆ Key idea

Ask two questions of any piece of infrastructure. How often does it change, and by whom? Rarely, by the platform team → CLI IaC is fine. Constantly, on developer demand → a control plane pays for itself. How badly do you need it to self-heal? If out-of-band drift is dangerous (security groups, cluster nodes), a reconciling control plane is worth the operational cost.

Cloud-provider Kubernetes operators

☺ Like you’re 10: Each big cloud ships its own official robots that let you make its services from Kubernetes YAML — like Crossplane’s raw bricks, but built and blessed by the cloud itself.

Between “a CLI that exits” and “Crossplane with its composition layer” sits a third family: first-party operators that each cloud publishes so you can manage its services as native Kubernetes resources. They give you continuous reconciliation and one-to-one CRDs — the Managed-Resource layer — but generally without Crossplane’s XRD/Composition abstraction. They’re an excellent substrate you can either use directly or compose over.

Config Connector (Google Cloud)

Config Connector (KCC) installs CRDs and controllers that manage Google Cloud resources — a SQLInstance, a StorageBucket, a PubSubTopic — from Kubernetes. Apply the YAML and the controller creates and reconciles the real GCP resource, writing status back. It’s the officially supported way to keep GCP infrastructure declared and reconciled from a cluster, and it underpins parts of Google’s own config tooling.

AWS Controllers for Kubernetes (ACK)

ACK is AWS’s equivalent, split into per-service controllers you install à la carte — an ack-s3-controller, an ack-rds-controller, an ack-iam-controller — each exposing that service’s resources as CRDs. This modularity means you install only the surfaces you use, keeping the control plane lean, at the cost of managing several controllers.

Azure Service Operator (ASO)

ASO does the same for Microsoft Azure: a broad set of CRDs covering resource groups, databases, storage, and networking, reconciled by controllers running in your cluster. Like the others, it turns kubectl apply into “provision and guard this Azure resource.”

OperatorCloudShapeAbstraction layer?
Config ConnectorGoogle CloudOne bundle of CRDs + controllersNo — raw 1:1 resources
ACKAWSPer-service controllers, installed à la carteNo — raw 1:1 resources
Azure Service OperatorAzureOne broad set of CRDs + controllersNo — raw 1:1 resources
CrossplaneMulti-cloudProviders (often Terraform-bridged) plus CompositionsYes — XRDs, Compositions, Claims

The design choice is clear once you see the table: first-party operators give you faithful, cloud-blessed building blocks; Crossplane adds a portable abstraction and composition layer over building blocks like these. Some teams even run a cloud operator as the raw substrate and use Crossplane purely for the composition and self-service API on top.

IaC on the platform

☺ Like you’re 10: Now we wire IaC into the real platform: developers request infra with a button, guards check every request, and Git is the single source of truth for the plumbing too.

IaC isn’t a corner of the platform — it’s the layer that makes self-service real. Three practices turn the tools above into a governed, developer-friendly capability.

Self-service infrastructure APIs

The goal is that a developer never files a ticket for infrastructure. Whether the engine is Crossplane Claims, a curated set of Terraform modules exposed through a portal, or Pulumi’s Automation API behind a service, the interface Dot sees is a small, safe request — “a medium Postgres, in eu-west-1” — and the platform fulfils it. This is the same caterpillar-to-butterfly move as custom operators: hide two hundred lines of correct-but-scary infra behind one friendly noun, and let developers stay in flow.

Policy-as-code on IaC

☺ Like you’re 10: Before any infrastructure is built, an automatic rule-checker reads the plan and blocks anything against the rules — “no public buckets,” “must have an owner tag.”

Guardrails differ by model, and this is one of control-plane IaC’s quiet advantages. For CLI IaC, you gate the plan in CI: render the plan to JSON and run a policy engine over it — OPA/Conftest, HashiCorp Sentinel, Checkov, or Trivy/tfsec — so a bad change is blocked before apply. A tiny Conftest rule:

# policy/deny_public.rego — a Conftest gate over the terraform plan JSON
package main

deny[msg] {
  rc := input.resource_changes[_]
  rc.type == "aws_s3_bucket"
  rc.change.after.acl == "public-read"
  msg := sprintf("bucket %q is public — blocked in CI", [rc.address])
}

For control-plane IaC the guardrail is even more natural: because a Crossplane Claim or a CAPI Cluster is just a Kubernetes object, the same admission-control policy engines — Kyverno, OPA/Gatekeeper — that guard your Pods guard your infrastructure, enforced by the API server on every write. One policy system for apps and infra alike.

GitOps for infrastructure

☺ Like you’re 10: The same “Git is the truth, a robot keeps things matching” trick from your apps, now pointed at your databases, networks, and clusters.

Because Crossplane Claims, CAPI objects, and cloud-operator CRDs are all Kubernetes resources, you can put your infrastructure under the very same GitOps reconciler as your apps. Argo CD or Flux watches a config repo, and a merged pull request provisions a database or a whole cluster — one control plane, one audit trail, one way to roll back. This is the unifying payoff: applications and infrastructure, reconciled from Git, guarded by the same policy and compliance controls, visible in one configuration workflow. For teams still using CLI IaC, the GitOps-flavoured equivalent is a controller that runs Terraform on merge (Atlantis, or a Terraform/Flux controller), keeping the plan-review-apply loop in pull requests.

⚠ Don’t let two brains fight over one resource

The most common self-inflicted wound is overlapping ownership: Terraform manages a security group and a Crossplane controller also thinks it owns it, so they endlessly revert each other. Draw crisp boundaries — each resource has exactly one owner and one tool. Likewise, never let a CI pipeline hold long-lived cloud admin keys “to make apply easy,” and never commit a state file to Git. Clear ownership and least-privilege credentials are what keep a powerful IaC layer from becoming a powerful footgun.

🎬 At the Platform Guild
🦊

Foxy: Wait — if I already have Terraform, isn’t Crossplane just Terraform with extra steps? Why bolt infrastructure onto Kubernetes?

🦫

Benny: Terraform’s brilliant for the foundation — accounts, the backbone network, the first cluster. Run it, it builds the world, it exits. Breadth for days.

🦋

Mira: Right — but “it exits” is the catch. The moment someone hand-edits a security group, Terraform doesn’t know until the next plan. Crossplane never leaves. Developers ask for a Database; a controller builds it and guards it forever.

🤖

Recon: BEEP. That’s me again. Delete a managed bucket behind my back? I rebuild it on the next loop. Desired, actual, diff, apply. There is no “done.”

👺

Gizmo: Ugh, so much machinery. Just give the CI runner permanent cloud-admin keys, commit the state file to the repo so it’s handy, and skip the locking — apply on every push! ⚡🤑

🐢

Timmy: Absolutely not, Gizmo. State holds the database password — the repo is forever. No locking means two applies corrupt it. Long-lived admin keys on a CI box is how the breach starts. Remote encrypted state, locking, short-lived creds.

🦆

Dot: Honestly? I don’t care whether it’s a CLI or a control plane. I write six lines, I get a Postgres with backups, and if I delete it, it cleans up after itself. That’s the platform I want.

Two philosophies, one goal: describe infrastructure once and let software make it true. The classic tools give you breadth and a crisp one-shot run; control planes give you continuous, self-healing, Kubernetes-native APIs. A great platform uses each where it fits — Terraform or OpenTofu to lay the foundation, Crossplane and Cluster API to serve the fleet — and wraps both in GitOps, policy, and self-service so Dot just asks and receives. For the wider map, keep the reference architecture, the tools index, and the glossary close.

🐢 Timmy’s checkpoint

1. In one sentence, what’s the difference between declarative and imperative infrastructure, and why does declarative make idempotency easy? 2. What is a Terraform state file, and name two problems it causes and one way each is mitigated. 3. Why did OpenTofu get created, and how does it differ technically from Terraform? 4. Explain the difference between how a run-to-completion tool and a control plane handle drift. 5. In Crossplane, what do an XRD, a Composition, and a Claim each do? 6. In Cluster API, what are the three provider roles, and what happens when you change the version field on a MachineDeployment? 7. Give one advantage control-plane IaC has for policy enforcement over CLI IaC.

Check your answers
  1. Declarative describes the desired end state and lets the tool compute the steps; imperative lists the steps. Declarative makes idempotency easy because the tool always diffs desired vs actual and does only the delta, so re-running is a safe no-op when things already match.
  2. The state file maps config to real resources (IDs, attributes). Problems & mitigations: (a) it can be corrupted by concurrent runs → a remote backend with state locking; (b) it holds secrets in plaintext → encrypt it and lock down access; (also acceptable: large blast radius → split into smaller states; drift from hand-edits → refresh/import).
  3. HashiCorp relicensed Terraform from open-source MPL 2.0 to the source-available BSL 1.1 in 2023; the community forked the last MPL version into OpenTofu (a Linux Foundation project). It’s a near drop-in — same HCL and providers — with its own additions like native state encryption.
  4. A run-to-completion tool detects drift only the next time you run plan, and correcting it needs a human-triggered apply; a control plane reconciles continuously, so it detects and repairs drift automatically within a reconcile interval, with no human.
  5. An XRD defines a new composite API and its developer-facing claim type (the schema); a Composition is the recipe mapping that composite to a set of Managed Resources; a Claim is the namespaced instance a developer writes — the whole self-service interface.
  6. The three roles are infrastructure (creates cloud VMs/networks), bootstrap (turns a machine into a Kubernetes node, e.g. via kubeadm), and control-plane (manages the control-plane machines). Changing version triggers a rolling, immutable replacement of nodes at the new version — replace, not patch.
  7. Because a Crossplane Claim or CAPI object is just a Kubernetes resource, the same admission-control policy engines (Kyverno, OPA/Gatekeeper) that guard Pods enforce policy on infrastructure at write time — one policy system for apps and infra, enforced by the API server rather than only in CI.