CloudFormation & Crossplane
Cloud-native and Kubernetes-native IaC.
AWS CloudFormation (Amazon Web Services' own infrastructure-as-code service) works like a builder's merchant that keeps your paperwork for you. You hand over a written order. The shop builds everything on it, files the order under one name, and keeps the only copy. You go home with nothing to file. The written order is a template, written in YAML or JSON (two plain-text formats for describing structured data). The filed order is a stack. AWS itself records what got built and what each piece looks like right now, so there is no state file to park in an S3 bucket (Amazon's object storage service), no lock table to configure, no backend to encrypt and guard. The ledger never leaves AWS.
That closeness is the whole trade. CloudFormation plugs straight into AWS machinery you would otherwise build yourself: rollback that fires automatically when an update fails, stack policies that refuse updates to protected resources, termination protection that blocks stack deletion, Service Catalog, plus SAM (Serverless Application Model, a shorthand syntax that CloudFormation expands at deploy time) and the CDK (Cloud Development Kit, which turns TypeScript or Python into a template). The cost is that it speaks only AWS. Coverage is not instant either, despite being first-party. New services and new resource properties routinely land in CloudFormation weeks or months after the API itself, which is one reason plenty of AWS-only shops still keep a second tool around. You will meet CloudFormation constantly regardless, because AWS documentation, quick-starts and one-click deploy buttons all hand you a template and expect you to read it.
AWSTemplateFormatVersion: '2010-09-09'Parameters:AdminCidr:Type: StringDefault: 10.0.0.0/16 # CIDR: shorthand for a range of IP addressesResources:WebServer:Type: AWS::EC2::Instance # AWS-native resource typesProperties:ImageId: ami-0ff8a91507f77f867InstanceType: t3.microSecurityGroupIds: [!Ref WebSG]Tags:- Key: NameValue: web-serverWebSG:Type: AWS::EC2::SecurityGroupProperties:GroupDescription: web tierSecurityGroupIngress:- IpProtocol: tcpFromPort: 22ToPort: 22CidrIp: !Ref AdminCidr # SSH (remote login) from the office range onlyWebRole:Type: AWS::IAM::Role # creating IAM changes how you must deployProperties:RoleName: web-server-role # a NAMED IAM resource -> CAPABILITY_NAMED_IAMAssumeRolePolicyDocument:Version: '2012-10-17'Statement:- Effect: AllowPrincipal: { Service: ec2.amazonaws.com }Action: sts:AssumeRole# deployed as a "stack": AWS keeps the record, so there is no state file to manage
The stack is the unit of everything. You create, update, delete and roll back at stack level, and CloudFormation stamps every resource that supports tagging with three of its own tags: aws:cloudformation:stack-name, aws:cloudformation:stack-id and aws:cloudformation:logical-id. That is how you answer "what built this thing?" six months later, from the console or a tag report, without asking around.
Secrets need care. A parameter marked NoEcho: true comes back masked as asterisks in describe-stacks output and in stack events, which stops the obvious leak. It does not mask a value you paste into the template's Metadata section or into a resource's Metadata attribute, and it does nothing at all about the template file itself, which is usually sitting in Git for anyone with read access. Keep the secret out of the template and let CloudFormation fetch it at deploy time with a dynamic reference.
Database:Type: AWS::RDS::DBInstance # RDS: Relational Database Service, managed databasesProperties:Engine: postgresDBInstanceClass: db.t3.microAllocatedStorage: '20'MasterUsername: appuser# CloudFormation resolves this at deploy time: no secret in Git, none in the templateMasterUserPassword: '{{resolve:secretsmanager:prod/web/db:SecretString:password}}'# an SSM Parameter Store SecureString works too, pinned to a parameter version:# '{{resolve:ssm-secure:/prod/web/db-password:3}}'
The Two Flags That Decide Who Holds the Power
$ aws cloudformation deploy --template-file template.yaml --stack-name web-prod
Waiting for changeset to be created..An error occurred (InsufficientCapabilitiesException) when calling the CreateChangeSetoperation: Requires capabilities : [CAPABILITY_NAMED_IAM]
That refusal is a safety catch, not red tape. Think of it as the till asking for a manager's key. IAM (Identity and Access Management) is the service that decides who can do what inside an AWS account, so any template that creates or changes IAM has to be deployed with an explicit acknowledgement: CAPABILITY_IAM for roles and policies CloudFormation names itself, CAPABILITY_NAMED_IAM when the template hard-codes the name, and CAPABILITY_AUTO_EXPAND when the template uses macros or transforms that can generate more template at deploy time (every SAM template needs that last one). When you are reviewing a template somebody else wrote, that flag is your cue to stop and read the IAM section line by line. A role with AdministratorAccess and a trust policy pointing at an account number nobody recognizes looks like ordinary YAML until you actually read it.
$ aws cloudformation deploy \--template-file template.yaml \--stack-name web-prod \--capabilities CAPABILITY_NAMED_IAM \--role-arn arn:aws:iam::123456789012:role/cfn-web-prod-exec
Waiting for changeset to be created..Waiting for stack create/update to completeSuccessfully created/updated stack - web-prod
The second flag, --role-arn, is the one most teams skip, and it decides who really holds the power. Without it, CloudFormation makes every API call as you. Anyone who can trigger a stack update borrows the full reach of whatever identity runs the pipeline. With it, the stack acts as a role you scoped on purpose: this stack can build the handful of things it owns and nothing else. That ARN (Amazon Resource Name, the unique identifier for any AWS thing) sticks to the stack for later updates too, so the limit holds after you walk away. Setting it requires iam:PassRole on that role, which is a useful gate in itself. It tidies the audit trail as well. In CloudTrail (the AWS record of every API call), calls made by the stack carry the execution role as the actor and invokedBy set to cloudformation.amazonaws.com, so you can tell automation apart from a human at a keyboard.
# jq is a command-line filter for JSON; CloudTrailEvent arrives as a JSON string$ aws cloudtrail lookup-events \--lookup-attributes AttributeKey=EventName,AttributeValue=CreateRole \--max-results 1 --query 'Events[0].CloudTrailEvent' --output text \| jq '.userIdentity.invokedBy, .userIdentity.sessionContext.sessionIssuer.arn'
"cloudformation.amazonaws.com""arn:aws:iam::123456789012:role/cfn-web-prod-exec"
Change Sets Are the Plan You Get to Read
CloudFormation's version of terraform plan is the change set. It is the written quote a builder hands you before anyone picks up a hammer: here is what I will add, what I will alter, and what I will knock down. The deploy command builds one every time and runs it straight away, unless you tell it to stop and let you read.
$ aws cloudformation deploy --template-file template.yaml --stack-name web-prod \--capabilities CAPABILITY_NAMED_IAM --no-execute-changeset
Waiting for changeset to be created..Changeset created successfully. Run the following command to review changes:aws cloudformation describe-change-set --change-set-name arn:aws:cloudformation:us-east-1:123456789012:changeSet/awscli-cloudformation-package-deploy-1784030112/9c1f5b6a-0d3e-4c77-9a21-5f0a2b8e77c4
$ aws cloudformation describe-change-set \--change-set-name arn:aws:cloudformation:us-east-1:123456789012:changeSet/awscli-cloudformation-package-deploy-1784030112/9c1f5b6a-0d3e-4c77-9a21-5f0a2b8e77c4 \--query 'Changes[].ResourceChange.[Action,LogicalResourceId,ResourceType,Replacement]' \--output text
Modify WebServer AWS::EC2::Instance TrueAdd WebRole AWS::IAM::Role NoneModify WebSG AWS::EC2::SecurityGroup False
Replacement is the column that matters. True means CloudFormation cannot change that property in place, so it builds a replacement resource and deletes the old one. For an EC2 instance (Elastic Compute Cloud, an AWS virtual machine) that means a new machine, a new root disk, a new private IP address, and anything written to the old disk is gone. False means an in-place update. Conditional means it depends on values only known once the update actually runs, and you should read it as True until somebody proves otherwise. On a database, or any volume holding data people care about, that one word is the difference between a rolling change and an outage. Make it part of review: no stack update touching a stateful resource gets approved until a human has read its change set.
Drift Detection Finds the Console Edits
Here is the scenario that pays for this whole section. Someone with console access widens the security group to debug a Friday evening incident, sets the source to 0.0.0.0/0, and forgets. Your template still says 10.0.0.0/16. Your Git history looks spotless. Port 22 is open to the internet. CloudFormation will not tell you, because nothing is watching. You have to ask.
$ aws cloudformation detect-stack-drift --stack-name web-prod
{"StackDriftDetectionId": "7f13a0c2-6ba5-11f0-9c1b-0a7e2b45c9d3"}
$ aws cloudformation describe-stack-drift-detection-status \--stack-drift-detection-id 7f13a0c2-6ba5-11f0-9c1b-0a7e2b45c9d3
{"StackId": "arn:aws:cloudformation:us-east-1:123456789012:stack/web-prod/2c8b41e0-6b9f-11f0-a1d4-0e5c2f9a11bb","StackDriftDetectionId": "7f13a0c2-6ba5-11f0-9c1b-0a7e2b45c9d3","StackDriftStatus": "DRIFTED","DetectionStatus": "DETECTION_COMPLETE","DriftedStackResourceCount": 1,"Timestamp": "2026-07-21T09:14:22.104000+00:00"}
$ aws cloudformation describe-stack-resource-drifts --stack-name web-prod \--stack-resource-drift-status-filters MODIFIED \--query 'StackResourceDrifts[].PropertyDifferences[]'
[{"PropertyPath": "/SecurityGroupIngress/0/CidrIp","ExpectedValue": "10.0.0.0/16","ActualValue": "0.0.0.0/0","DifferenceType": "NOT_EQUAL"}]
CloudFormation or Terraform on AWS
On AWS the choice is a real one. CloudFormation keeps the record for you, rolls back a failed update on its own, and needs nothing installed beyond the AWS CLI (command line interface). Terraform is cloud-agnostic, so one tool and one workflow covers AWS, Azure, Google Cloud, Kubernetes, Datadog and hundreds of other providers; its module ecosystem is far bigger; support for brand-new AWS features often arrives sooner than CloudFormation ships it; and most engineers find HCL (HashiCorp Configuration Language) and plan output easier to read than a change set. The price is a state file you have to store, lock, encrypt and guard, because that file contains every value your infrastructure knows. Teams living entirely inside AWS often stay with CloudFormation, frequently through the CDK, which generates the template from real code. Teams that want one workflow everywhere pick Terraform or OpenTofu (the open-source fork). Both are declarative, both compute a diff before acting, and the habits carry over either way.
Crossplane Turns the Cluster Into the Control Plane
Terraform and CloudFormation are contractors you phone. They turn up, do the work written on the order, and drive off. Crossplane is the building superintendent who lives on site with a copy of the blueprint and walks the floors all day. You install Crossplane into a Kubernetes cluster, add a provider package for the platform you want, and the cluster's API grows new resource types for cloud things. A bucket, a database, a VPC (Virtual Private Cloud, your own private network inside AWS) become Kubernetes objects you create with kubectl apply, and a controller keeps checking them against the real cloud.
There is no state file for you to store or secure. The desired state is the object sitting in the Kubernetes API (etcd, the database behind that API, is the ledger now) and the actual state is whatever the cloud reports when the controller looks. It looks on a timer. That poll interval is a provider flag, commonly somewhere between one and ten minutes, so a bucket someone deletes by hand comes back on its own in minutes rather than waiting to be noticed on the next apply. That is the real break from run-on-demand tools: enforcement never stops. It also means everything you already built around Kubernetes now applies to cloud infrastructure with no new tooling, including GitOps (keeping desired state in Git and letting a controller apply it), admission control, audit logging, and RBAC (role-based access control, the rules for who may do what to which objects). Platform teams push it further with Compositions and Composite Resource Definitions, so a developer asks for a "PostgresInstance" and your house rules decide what that actually builds.
apiVersion: s3.aws.upbound.io/v1beta1kind: Bucketmetadata:name: acme-logs-prodspec:forProvider:region: us-east-1providerConfigRef:name: default # which cloud credentials this object provisions withdeletionPolicy: Orphan # kubectl delete removes the K8s object, NOT the bucket
$ kubectl apply -f bucket.yaml$ kubectl get managed
bucket.s3.aws.upbound.io/acme-logs-prod createdNAME READY SYNCED EXTERNAL-NAME AGEbucket.s3.aws.upbound.io/acme-logs-prod True True acme-logs-prod 47s
# delete it out from under Crossplane, the way an attacker or a tired human would# (this provider is configured to poll every minute)$ aws s3 rb s3://acme-logs-prod --force$ kubectl get bucket acme-logs-prod -w
remove_bucket: acme-logs-prodNAME READY SYNCED EXTERNAL-NAME AGEacme-logs-prod True True acme-logs-prod 3m12sacme-logs-prod False True acme-logs-prod 3m41sacme-logs-prod True True acme-logs-prod 4m02s
In Crossplane, Kubernetes RBAC Is Cloud IAM
This is the part teams miss. The provider holds credentials that can create and destroy real infrastructure, and it uses them on behalf of whoever managed to create the object. So "who can make an S3 bucket in this account" stops being an AWS IAM question and becomes a Kubernetes RBAC question. The cluster's permission model is now a front door into your cloud account. Check it the way you would check any other lock.
$ kubectl auth can-i create buckets.s3.aws.upbound.io \--as=system:serviceaccount:team-web:deployer$ kubectl get providerconfig default -o jsonpath='{.spec.credentials.source}{"\n"}'$ kubectl get providerconfig default \-o jsonpath='{.spec.credentials.secretRef.namespace}/{.spec.credentials.secretRef.name}{"\n"}'
yesSecretcrossplane-system/aws-creds
Two findings in three commands. A service account in a team namespace can create cloud storage, which is either the self-service you designed on purpose or an accident of a ClusterRole written too broadly. And the provider authenticates from a static Secret, which means long-lived AWS access keys are sitting in etcd. On EKS (Elastic Kubernetes Service), switch that to IRSA (IAM Roles for Service Accounts, which mints short-lived credentials for the pod) or to EKS Pod Identity, so there are no permanent keys left to steal. From then on, treat read access to the crossplane-system namespace as read access to your cloud account. Those provider Secrets are what an attacker who lands a shell in the cluster goes looking for, long before they care about your pods.
Pick the One Your Team Can Actually Operate
These tools are less better-or-worse than fit-for-context. CloudFormation suits deep AWS shops that want native integration, automatic rollback and nothing of their own to run. Terraform and OpenTofu suit teams who want one workflow across every provider. Crossplane suits platform teams already running Kubernetes who want to hand developers a self-service API with the house rules baked in. The failure mode is picking by fashion. Adopt Crossplane because it is the interesting choice, and one day your production database depends on a stuck controller in a cluster nobody on the rota can debug. Fight CloudFormation in a pure-AWS shop, and you burn weeks hardening a state backend you never needed. The principles are identical in all three, so pick the one whose failure modes your team can handle at 3am.
Two checks worth running this week. If you deploy with CloudFormation, list the stacks with no execution role, because those are the ones borrowing whatever identity happens to run them. If you run Crossplane, ask what a developer's service account can already do to your cloud account through the cluster.
$ aws cloudformation describe-stacks \--query 'Stacks[?RoleARN==`null`].StackName' --output text$ kubectl auth can-i --list --as=system:serviceaccount:team-web:deployer \| grep upbound.io
logging-prod ci-artifacts vpc-sharedbuckets.s3.aws.upbound.io [] [] [create delete get list patch update watch]
Try this
Run aws cloudformation deploy --template-file template.yaml --stack-name web-prod on a scratch host or disposable cluster and read the output against what this lesson described. Then change one input so it fails, and re-run: the error you get is the one you will meet in production.
Takeaway
The trap worth remembering here: drift detection reports; it never repairs. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.