The same S3 and SQS resources built in both tools, compared on state and locking, plans vs change sets, drift, modules vs nested stacks, loops, policy, testing, import, refactoring and state security, with a step-by-step migration path in each direction.
Terraform and CloudFormation both turn declarative files into AWS resources, but they disagree on where truth is stored, who runs the engine, and what happens when a deployment fails. Those differences decide your day-to-day operations far more than HCL versus YAML. This guide builds the same small resource set in both tools, then compares state, change previews, drift, reuse, policy, testing, refactoring and migration.
The example is an encrypted, versioned S3 bucket with public access blocked, plus an SQS queue with a dead-letter queue.
terraform {
required_version = ">= 1.14, < 2.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 6.0, < 7.0"
}
}
backend "s3" {
bucket = "myproject-tfstate"
key = "app/prod/terraform.tfstate"
region = "eu-west-1"
encrypt = true
use_lockfile = true
}
}
provider "aws" {
region = "eu-west-1"
}
variable "environment" {
type = string
validation {
condition = contains(["dev", "staging", "prod"], var.environment)
error_message = "environment must be dev, staging or prod."
}
}
resource "aws_s3_bucket" "assets" {
bucket = "myproject-assets-${var.environment}"
}
resource "aws_s3_bucket_versioning" "assets" {
bucket = aws_s3_bucket.assets.id
versioning_configuration {
status = "Enabled"
}
}
resource "aws_s3_bucket_server_side_encryption_configuration" "assets" {
bucket = aws_s3_bucket.assets.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "aws:kms"
}
}
}
resource "aws_s3_bucket_public_access_block" "assets" {
bucket = aws_s3_bucket.assets.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
resource "aws_sqs_queue" "dlq" {
name = "myproject-orders-dlq-${var.environment}"
message_retention_seconds = 1209600
}
resource "aws_sqs_queue" "orders" {
name = "myproject-orders-${var.environment}"
visibility_timeout_seconds = 60
redrive_policy = jsonencode({
deadLetterTargetArn = aws_sqs_queue.dlq.arn
maxReceiveCount = 5
})
}
output "queue_url" {
value = aws_sqs_queue.orders.url
}
AWSTemplateFormatVersion: "2010-09-09"
Description: Assets bucket and orders queue
Parameters:
Environment:
Type: String
AllowedValues: [dev, staging, prod]
Resources:
AssetsBucket:
Type: AWS::S3::Bucket
DeletionPolicy: Retain
UpdateReplacePolicy: Retain
Properties:
BucketName: !Sub "myproject-assets-${Environment}"
VersioningConfiguration:
Status: Enabled
BucketEncryption:
ServerSideEncryptionConfiguration:
- ServerSideEncryptionByDefault:
SSEAlgorithm: aws:kms
PublicAccessBlockConfiguration:
BlockPublicAcls: true
BlockPublicPolicy: true
IgnorePublicAcls: true
RestrictPublicBuckets: true
OrdersDlq:
Type: AWS::SQS::Queue
Properties:
QueueName: !Sub "myproject-orders-dlq-${Environment}"
MessageRetentionPeriod: 1209600
OrdersQueue:
Type: AWS::SQS::Queue
Properties:
QueueName: !Sub "myproject-orders-${Environment}"
VisibilityTimeout: 60
RedrivePolicy:
deadLetterTargetArn: !GetAtt OrdersDlq.Arn
maxReceiveCount: 5
Outputs:
QueueUrl:
Value: !Ref OrdersQueue
Two shape differences show up immediately. The AWS provider models bucket features as separate resources, while CloudFormation nests them in one resource type. And in CloudFormation, deletion behaviour is a resource attribute: DeletionPolicy: Retain leaves the bucket in place when the stack is deleted. Terraform's nearest guard is lifecycle { prevent_destroy = true }, which makes a plan fail rather than leaving the resource behind.
Terraform keeps a JSON state file that maps each resource address to a real object ID and records its last known attributes. You own that file: where it lives, who reads it, and how it is locked. CloudFormation keeps the equivalent inside the service as a stack. You cannot read the raw record or edit it, and you do not have to protect or back it up.
| Concern | Terraform | CloudFormation |
|---|---|---|
| Record of truth | State file you store | Stack held by the service |
| Engine runs in | Your CI or a hosted runner | AWS control plane |
| Concurrency control | Backend lock | One operation per stack, enforced by the service |
| Failed deploy | Stops, state keeps what succeeded, no rollback | Rolls back automatically by default |
| Scope | Any provider: cloud, DNS, SaaS | AWS, plus registry and custom types |
| Manual state surgery | Possible, and sometimes needed | Not possible |
| Credentials used to deploy | Runner's credentials | Caller's, or a stack service role |
A local state file does not work for a team. The S3 backend above stores state remotely, and use_lockfile = true takes a lock using an S3 lock object and conditional writes. DynamoDB locking is deprecated in the S3 backend, so new setups should not add it. Create the state bucket with versioning enabled, so you can recover from a corrupted write, and keep the bucket outside the stack that uses it.
Failure mode: a CI job killed mid-apply leaves a stale lock. Confirm that nothing is running, then clear it with terraform force-unlock LOCK_ID. Never force-unlock while another apply is live, because two writers can corrupt state.
Split state by environment and by blast radius. One huge state file means slow plans, wide lock contention, and a wide radius for any mistake. Stacks face the same design question, with a per-stack resource quota pushing you to split.
Sharing values across boundaries differs too. Terraform uses terraform_remote_state, which requires read access to the whole source state, or a parameter store lookup, which needs less access and is usually better. CloudFormation uses Outputs with Export and Fn::ImportValue. An export cannot be changed or deleted while another stack imports it, which is strong protection and also a frequent cause of stuck refactors. For loosely coupled stacks, publishing values in SSM Parameter Store avoids that lock.
Both tools can preview changes, but the workflow differs.
# Terraform: save the plan, review it, apply exactly that plan
terraform plan -var environment=prod -out=tfplan
terraform show -json tfplan > plan.json
terraform apply tfplan
# CloudFormation: create, wait, review, execute
aws cloudformation create-change-set \
--stack-name myproject-prod \
--change-set-name release-142 \
--template-body file://template.yaml \
--parameters ParameterKey=Environment,ParameterValue=prod
aws cloudformation wait change-set-create-complete \
--stack-name myproject-prod --change-set-name release-142
aws cloudformation describe-change-set \
--stack-name myproject-prod --change-set-name release-142 \
--query 'Changes[].ResourceChange.[Action,LogicalResourceId,Replacement]' \
--output table
aws cloudformation execute-change-set \
--stack-name myproject-prod --change-set-name release-142
For a first deployment, add --change-set-type CREATE. Use --include-nested-stacks to see inside nested stacks, otherwise the change set shows only that the child stack will change.
A Terraform plan refreshes against live resources and reports attribute-level differences, including drift. A standard change set compares the new template with the last deployed template, so it can report nothing while a resource has drifted. In both tools, the preview is not a guarantee: a replacement forced by the service, a quota, or a name collision can still fail at apply time. Read each preview for destructive actions. In Terraform, look for -/+ and must be replaced. In a change set, check Replacement, which can be True, False or Conditional. Treat Conditional as a replacement until proved otherwise.
This is the sharpest operational difference. When Terraform fails halfway, the completed changes stay, state records them, and you fix forward and re-apply. When CloudFormation fails, it rolls back to the previous state, which is safer for small stacks but can take a long time. If rollback itself fails, the stack enters UPDATE_ROLLBACK_FAILED and blocks all updates until you fix the cause and run aws cloudformation continue-update-rollback, possibly with --resources-to-skip. Skipping resources leaves the stack out of sync with reality, so reconcile them afterwards.
Terraform detects drift on every plan. To check without proposing config changes, use a refresh-only plan, and let CI act on the exit code:
terraform plan -refresh-only -detailed-exitcode -var environment=prod
# Exit 0: no drift. Exit 1: error. Exit 2: changes detected.
CloudFormation drift detection is an explicit, asynchronous operation:
ID=$(aws cloudformation detect-stack-drift \
--stack-name myproject-prod --query StackDriftDetectionId --output text)
aws cloudformation describe-stack-drift-detection-status \
--stack-drift-detection-id "$ID"
aws cloudformation describe-stack-resource-drifts \
--stack-name myproject-prod \
--stack-resource-drift-status-filters MODIFIED DELETED
Drift detection covers supported resource types and generally only properties you set explicitly in the template, so a property you left at its default can change without a report. Once detected, the standard fix is to update the template or undo the manual change. Drift-aware change sets go further: passing --deployment-mode REVERT_DRIFT to create-change-set compares live state, the previous deployment and the template, then proposes bringing resources back in line. They do not reconcile drift on properties that require replacement, so review describe-change-set output closely.
Neither tool sees resources created outside its management. A bucket someone made by hand appears in no plan and no drift report. Terraform's ignore_changes intentionally hides attributes that other systems manage, such as autoscaling desired capacity, and every entry is a place where drift is accepted. Keep those lists short and commented.
A Terraform module is a source-level include. It is expanded into the root configuration, shares one state and one plan, and has no runtime boundary:
# modules/queue/main.tf
variable "name" { type = string }
resource "aws_sqs_queue" "dlq" {
name = "${var.name}-dlq"
}
resource "aws_sqs_queue" "this" {
name = var.name
redrive_policy = jsonencode({
deadLetterTargetArn = aws_sqs_queue.dlq.arn
maxReceiveCount = 5
})
}
output "arn" { value = aws_sqs_queue.this.arn }
# Root configuration
module "orders_queue" {
source = "./modules/queue"
name = "myproject-orders-prod"
}
A nested stack is a real stack, created by the AWS::CloudFormation::Stack resource. It has its own events, status and resource quota, and it fails and rolls back with its parent:
# queue.yaml
Parameters:
QueueName:
Type: String
Resources:
Dlq:
Type: AWS::SQS::Queue
Properties:
QueueName: !Sub "${QueueName}-dlq"
Queue:
Type: AWS::SQS::Queue
Properties:
QueueName: !Ref QueueName
RedrivePolicy:
deadLetterTargetArn: !GetAtt Dlq.Arn
maxReceiveCount: 5
Outputs:
QueueArn:
Value: !GetAtt Queue.Arn
# parent.yaml
Resources:
OrdersQueue:
Type: AWS::CloudFormation::Stack
Properties:
TemplateURL: ./queue.yaml
Parameters:
QueueName: myproject-orders-prod
A local TemplateURL works only after packaging, which uploads children to S3 and rewrites the URL:
aws cloudformation package \
--template-file parent.yaml \
--s3-bucket myproject-cfn-artifacts \
--output-template-file packaged.yaml
| Aspect | Terraform module | Nested stack |
|---|---|---|
| Boundary | Compile time only | Runtime stack with own status |
| Distribution | Path, registry, or Git with a version | Template in S3 |
| Versioning | Version constraint or pinned ref | Whatever object the URL points to |
| Preview depth | Full plan by default | Needs --include-nested-stacks |
| Failure debugging | Single log | Follow events into the child stack |
| Sharing outputs | Module outputs | !GetAtt Child.Outputs.Name |
CloudFormation also has registry modules, which are closer to Terraform's model, because they expand into the same stack. Its other multi-target tool is StackSets, for deploying one template across accounts and regions, where Terraform needs a provider alias or a separate root per target. Pin every external module, because an unpinned reference means a plan can change without any commit in your repository.
Terraform builds repetition into the language:
variable "queues" {
type = set(string)
default = ["orders", "billing"]
}
resource "aws_sqs_queue" "worker" {
for_each = var.queues
name = "myproject-${each.key}-prod"
}
resource "aws_sqs_queue" "audit" {
count = var.environment == "prod" ? 1 : 0
name = "myproject-audit-prod"
}
Use for_each for collections. With count, resources are addressed by position, so removing an item from the middle of a list shifts every later index and proposes destroying and recreating queues that did not really change. Keep count for an on/off toggle.
CloudFormation's native equivalent for on/off is Conditions. Loops arrived later, through the language extensions transform:
AWSTemplateFormatVersion: "2010-09-09"
Transform: AWS::LanguageExtensions
Parameters:
Environment:
Type: String
Conditions:
IsProd: !Equals [!Ref Environment, prod]
Resources:
Fn::ForEach::WorkerQueues:
- Name
- [orders, billing]
- "Queue${Name}":
Type: AWS::SQS::Queue
Properties:
QueueName: !Sub "myproject-${Name}-${Environment}"
AuditQueue:
Type: AWS::SQS::Queue
Condition: IsProd
Properties:
QueueName: myproject-audit-prod
Beyond that, macros run template transformations in a Lambda function you own. They are powerful, but deployment now depends on your code, and the template you review differs from what gets deployed. Stacks that use macros or this transform require CAPABILITY_AUTO_EXPAND. Failure mode: a broken macro blocks deployment of every stack that uses it, so version and test it like any production service.
Many teams avoid raw YAML logic altogether and generate templates with the AWS CDK. That is a separate choice, but worth noting when conditions start to multiply.
Terraform policy typically runs against the JSON plan, so it evaluates computed values and actual actions. OPA with Conftest works in any CI:
# policy/s3.rego
package main
import rego.v1
deny contains msg if {
rc := input.resource_changes[_]
rc.type == "aws_s3_bucket_public_access_block"
"create" in rc.change.actions
not rc.change.after.block_public_acls
msg := sprintf("%s must block public ACLs", [rc.address])
}
terraform show -json tfplan > plan.json
conftest test plan.json --policy policy/
Sentinel is the equivalent inside HCP Terraform and Terraform Enterprise, and it enforces between plan and apply so that an engineer cannot skip it from CI:
import "tfplan/v2" as tfplan
queues = filter tfplan.resource_changes as _, rc {
rc.type is "aws_sqs_queue" and rc.change.actions contains "create"
}
main = rule {
all queues as _, rc {
rc.change.after.name matches "^myproject-"
}
}
CloudFormation Guard checks the template itself:
# rules/s3.guard
let buckets = Resources.*[ Type == 'AWS::S3::Bucket' ]
rule bucket_encrypted when %buckets !empty {
%buckets.Properties.BucketEncryption.ServerSideEncryptionConfiguration[*].ServerSideEncryptionByDefault.SSEAlgorithm IN ['aws:kms', 'AES256']
<<
Every bucket needs default encryption
>>
}
rule bucket_private when %buckets !empty {
%buckets.Properties.PublicAccessBlockConfiguration.BlockPublicAcls == true
}
cfn-guard validate --data template.yaml --rules rules/
Static template checks cannot resolve every intrinsic function, so a value produced by !Ref or a macro may escape a rule, whereas plan-based checks see more resolved values but still show unknown attributes as computed. To enforce rules server-side, CloudFormation Hooks run during provisioning and can block non-compliant resources regardless of who deploys, including with Guard rules. CI-only policy is advisory if people can deploy from a laptop, so pair it with IAM restrictions.
Terraform has a native test framework. Tests with command = plan are fast and need no real resources when a provider is mocked:
# tests/queue.tftest.hcl
mock_provider "aws" {}
variables {
environment = "dev"
}
run "names_include_environment" {
command = plan
assert {
condition = aws_sqs_queue.orders.name == "myproject-orders-dev"
error_message = "queue name must include the environment"
}
}
run "rejects_unknown_environment" {
command = plan
variables {
environment = "qa"
}
expect_failures = [var.environment]
}
Mocks verify your logic, not AWS behaviour. Add a smaller set of command = apply tests in a sandbox account for what only the real API can show, such as IAM conditions and name constraints.
On the CloudFormation side, use cfn-lint for schema and best-practice checks, Guard for policy, and TaskCat for real deployments, across multiple regions if needed:
# .taskcat.yml
project:
name: myproject
regions:
- eu-west-1
- eu-central-1
tests:
default:
template: template.yaml
parameters:
Environment: dev
cfn-lint template.yaml
taskcat lint
taskcat test run
TaskCat creates real stacks and deletes them afterwards, so it costs money and time, and needs a sandbox account. The example template retains its bucket and uses a fixed name, which will collide between regions and leave debris; test templates should parameterize names and use a suitable deletion policy.
Renaming a resource or moving it into a module changes its address, and Terraform would otherwise destroy and recreate it. A moved block records the rename in code, so it applies safely for every environment and appears in review:
moved {
from = aws_sqs_queue.orders
to = module.orders_queue.aws_sqs_queue.this
}
moved {
from = aws_sqs_queue.worker[0]
to = aws_sqs_queue.worker["orders"]
}
import {
to = aws_s3_bucket.legacy
id = "myproject-legacy-assets"
}
removed {
from = aws_sqs_queue.audit
lifecycle {
destroy = false
}
}
The import block adopts an existing resource during a normal plan and apply. To draft the configuration, run terraform plan -generate-config-out=generated.tf, and clean the output before committing. The removed block stops managing a resource without destroying it. Prefer all three over terraform state mv and terraform state rm, which change shared state without review.
To bring existing resources into a stack, add them to the template with a DeletionPolicy, then create an import change set:
cat > import.json <<'EOF'
[{
"ResourceType": "AWS::S3::Bucket",
"LogicalResourceId": "LegacyBucket",
"ResourceIdentifier": { "BucketName": "myproject-legacy-assets" }
}]
EOF
aws cloudformation create-change-set \
--stack-name myproject-prod \
--change-set-name import-legacy \
--change-set-type IMPORT \
--resources-to-import file://import.json \
--template-body file://template.yaml \
--parameters ParameterKey=Environment,UsePreviousValue=true
To move resources between stacks or rename logical IDs, use stack refactoring. Provide the updated templates, plus a mapping if identifiers change. The --enable-stack-creation flag is needed only when the destination stack does not exist yet:
cat > mapping.json <<'EOF'
[{
"Source": { "StackName": "myproject-app", "LogicalResourceId": "OrdersQueue" },
"Destination": { "StackName": "myproject-messaging", "LogicalResourceId": "OrdersQueue" }
}]
EOF
REFACTOR_ID=$(aws cloudformation create-stack-refactor \
--query StackRefactorId --output text \
--stack-definitions \
StackName=myproject-app,TemplateBody@=file://app.yaml \
StackName=myproject-messaging,TemplateBody@=file://messaging.yaml \
--resource-mappings file://mapping.json \
--enable-stack-creation)
aws cloudformation describe-stack-refactor --stack-refactor-id "$REFACTOR_ID"
aws cloudformation list-stack-refactor-actions --stack-refactor-id "$REFACTOR_ID"
aws cloudformation execute-stack-refactor --stack-refactor-id "$REFACTOR_ID"
A refactor cannot change resource configuration at the same time, and a stack must not end up empty. Make property updates in a separate deployment first.
Terraform state is sensitive. It stores attribute values in plain text, including generated passwords, and sensitive = true hides a value only from CLI output. Saved plan files carry the same data. Treat them accordingly:
aws_db_instance supports password_wo. Better still, let the database service manage the master password.CloudFormation has no state file to protect, but templates and parameters still leak. NoEcho masks a parameter in console and API responses, yet a value hardcoded in a template stays readable. Use dynamic references such as {{resolve:secretsmanager:myproject/db:SecretString:password}}, and be aware that a resolved value can still reach logs or resource metadata depending on the property. Also, deploy with a scoped service role through --role-arn, so that engineers need permission to deploy a stack, not to change every underlying resource.
OpenTofu is an open-source fork created after Terraform moved to the Business Source License in 2023. It keeps the same language and provider model and works with the tofu command. Migration from comparable versions is usually a binary swap plus tofu init, but the two codebases have diverged since the fork. OpenTofu has client-side state encryption, which Terraform lacks, while newer Terraform features may arrive later, differently, or not at all. Check the compatibility notes for your exact versions before switching. Everything said here about Terraform state and plans applies to OpenTofu unless a feature is version-specific.
| If this is true | Lean towards |
|---|---|
| You manage several clouds, DNS vendors or SaaS APIs together | Terraform or OpenTofu |
| You are AWS-only and want no state to operate | CloudFormation |
| You need automatic rollback on failure | CloudFormation |
| You want one rich plan across many resources | Terraform or OpenTofu |
| You need new AWS features first | Neither reliably; check coverage per service |
| Compliance requires service-side deployment roles | CloudFormation, or locked-down CI runners |
| You publish products into customer AWS accounts | CloudFormation, since customers can launch it directly |
Running both is common and fine with a clear boundary: for example, Terraform for the shared platform and CloudFormation for vendor-supplied stacks. Never let both manage the same resource, because each will see the other's change as drift. Team skills matter too. A familiar tool with good review habits beats a theoretically better one that nobody understands.
Migration should adopt existing resources, not recreate them. Here is CloudFormation to Terraform:
DeletionPolicy: Retain on every resource and deploy the stack. Without a successful update, deletion later destroys real resources. Verify with aws cloudformation get-template.import blocks for each resource. Use -generate-config-out to speed this up.terraform plan until it reports only imports and zero changes. Any proposed replacement means your configuration is wrong; do not apply it.terraform plan once more, expecting no changes, then archive the template.Terraform to CloudFormation runs the same way in reverse:
DeletionPolicy on every resource. The IaC generator can draft it from a resource scan via start-resource-scan and create-generated-template.IMPORT change set for the resources, and review it.removed blocks using destroy = false, then apply. Do this before anyone can run the old pipeline.Failure modes to watch: import does not cover every resource type in either tool, so check support before you begin. Bucket subresources in Terraform each need their own import, while a single CloudFormation import covers them. Dependent resources referenced by exports must move first or last, not independently. Work in small batches, keep a pre-migration export of the template and state, and test the entire procedure in a sandbox.
# Terraform
terraform fmt -check -recursive
terraform init -backend=false
terraform validate
terraform test
terraform plan -detailed-exitcode -var environment=dev
conftest test plan.json --policy policy/
# CloudFormation
cfn-lint template.yaml
cfn-guard validate --data template.yaml --rules rules/
aws cloudformation validate-template --template-body file://template.yaml
aws cloudformation describe-stack-events --stack-name myproject-dev --max-items 20
# Both produce the same result
aws s3api get-bucket-versioning --bucket myproject-assets-dev
aws s3api get-bucket-encryption --bucket myproject-assets-dev
aws s3api get-public-access-block --bucket myproject-assets-dev
aws sqs get-queue-attributes \
--queue-url "$QUEUE_URL" --attribute-names RedrivePolicy
terraform plan -refresh-only and detect-stack-drift both report it.moved block, and confirm that the plan shows a move rather than a replacement.terraform plan or a stack status of IN_SYNC, and confirm each resource still exists with its data.Found a mistake in this tutorial? Let us know and we will fix it.