DevOps Advanced

Terraform and CloudFormation Side by Side: State, Drift, Modules vs Nested Stacks, Testing, and Migration

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.

KC Ramo DjangoZen Team Nov 05, 2026 20 min read 4 views

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 same resources in both tools

The example is an encrypted, versioned S3 bucket with public access blocked, plus an SQS queue with a dead-letter queue.

Terraform

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
}

CloudFormation

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.

State files vs stacks

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.

ConcernTerraformCloudFormation
Record of truthState file you storeStack held by the service
Engine runs inYour CI or a hosted runnerAWS control plane
Concurrency controlBackend lockOne operation per stack, enforced by the service
Failed deployStops, state keeps what succeeded, no rollbackRolls back automatically by default
ScopeAny provider: cloud, DNS, SaaSAWS, plus registry and custom types
Manual state surgeryPossible, and sometimes neededNot possible
Credentials used to deployRunner's credentialsCaller's, or a stack service role

Remote state and locking

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.

Plan vs change sets

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.

When a deploy fails

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.

Drift detection

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.

Modules vs nested stacks

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
AspectTerraform moduleNested stack
BoundaryCompile time onlyRuntime stack with own status
DistributionPath, registry, or Git with a versionTemplate in S3
VersioningVersion constraint or pinned refWhatever object the URL points to
Preview depthFull plan by defaultNeeds --include-nested-stacks
Failure debuggingSingle logFollow events into the child stack
Sharing outputsModule 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.

Loops, conditions and macros

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.

Policy as code

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.

Testing

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.

Import and refactoring

Terraform

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.

CloudFormation

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.

Security of state

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:

  • Encrypt the state bucket with KMS, block public access, and restrict reads to deployment roles.
  • Enable versioning and access logging, and treat old versions as equally sensitive.
  • Keep plan artifacts in CI for a short time only, with the same access rules.
  • Prefer write-only arguments and ephemeral resources, which keep values out of state. For example, 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.

The OpenTofu note

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.

How to choose

If this is trueLean towards
You manage several clouds, DNS vendors or SaaS APIs togetherTerraform or OpenTofu
You are AWS-only and want no state to operateCloudFormation
You need automatic rollback on failureCloudFormation
You want one rich plan across many resourcesTerraform or OpenTofu
You need new AWS features firstNeither reliably; check coverage per service
Compliance requires service-side deployment rolesCloudFormation, or locked-down CI runners
You publish products into customer AWS accountsCloudFormation, 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.

A migration path between them

Migration should adopt existing resources, not recreate them. Here is CloudFormation to Terraform:

  1. Pick one low-risk stack and resolve any drift first, so you are not importing surprises.
  2. Set DeletionPolicy: Retain on every resource and deploy the stack. Without a successful update, deletion later destroys real resources. Verify with aws cloudformation get-template.
  3. Write Terraform configuration with import blocks for each resource. Use -generate-config-out to speed this up.
  4. Run terraform plan until it reports only imports and zero changes. Any proposed replacement means your configuration is wrong; do not apply it.
  5. Apply the imports. For a period both tools now track the resources, so freeze stack updates.
  6. Delete the stack. Resources stay because of the retain policy. Check that they all still exist.
  7. Run terraform plan once more, expecting no changes, then archive the template.

Terraform to CloudFormation runs the same way in reverse:

  1. Write the template with a DeletionPolicy on every resource. The IaC generator can draft it from a resource scan via start-resource-scan and create-generated-template.
  2. Create an IMPORT change set for the resources, and review it.
  3. Remove the resources from Terraform with removed blocks using destroy = false, then apply. Do this before anyone can run the old pipeline.
  4. Run drift detection on the new stack, and delete the old configuration.

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.

Verifying it works

# 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
  1. Change a queue's visibility timeout by hand. Confirm that terraform plan -refresh-only and detect-stack-drift both report it.
  2. Run two applies at once and confirm that the second fails on the lock. For a stack, confirm that a second update is rejected while the first is in progress.
  3. Force a failed deployment, for example with a duplicate bucket name, and observe the difference: partial Terraform state versus a CloudFormation rollback.
  4. Rename a resource with a moved block, and confirm that the plan shows a move rather than a replacement.
  5. Try to read the state bucket from a role outside the deployment group; expect access denied.
  6. After any migration, expect a clean 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.