Skip to content
Stribog

Delivery

All writing

Terraform vs CloudFormation: Who Holds the Record

Terraform vs CloudFormation: drift-aware change sets narrowed the old gap. What remains is where the record lives, and what each engine refuses to move.

Stribog17 min read

Most comparisons of these engines argue about syntax, the part that does not matter. One writes its record to a file you can copy onto a laptop; the other keeps it as an object inside the account it manages. What an upgrade breaks, what an auditor is shown and what leaving costs all follow from that.

It is also out of date almost everywhere it is published. CloudFormation acquired a three-way comparison against live infrastructure in late 2025, and the pages ranking for this question still describe its drift story as one command — which makes it better than its reputation, and moves the honest case for Terraform somewhere other than drift.

The 2026 scoreboard, not the 2019 one

Four things moved since the comparison most teams read. Drift-aware change sets, announced on 18 November 2025 as "a three-way diff between a new template, last-deployed template, and actual infrastructure state". Stack refactoring, which relocates resources between stacks without destroying them. The IaC generator, which scans an account and models what it finds. And Terraform's licensor: its Business Source License names a Licensed Work of "Terraform Version 1.6.0 or later" that "is (c) 2024 IBM Corp."

Two things did not move, and neither decides it. Terraform speaks to many providers; CloudFormation only to AWS, as the native service with no additional charge for resource providers "in the following namespaces: AWS::* and Alexa::*". That gets flattened into "CloudFormation is free", which the pricing page does not say: third-party registry extensions carry two meters — $0.0009 per handler operation beyond a free tier of 1,000 per month per account, one bucket that "includes hook and third party resource provider operations" rather than an allowance each, plus $0.00008 per second of duration above the first 30 seconds. AWS's worked example reaches $12.60 for 15,000 operations: the meter that grows when a team wraps its own services as resource types.

Two records: a file you hold, a service object you query

Terraform's record is a file at a bucket and key you chose; protecting it is your job. The S3 backend is blunt: "State locking is an opt-in feature." Native locking comes from use_lockfile, which "Defaults to false" — the .tflock object beside your state exists only because someone wrote that line. Nor has the older mechanism vanished: "DynamoDB-based locking is deprecated" and "will be removed in a future minor version".

Reading it is terraform plan, whose documentation names three operations: read "the current state of any already-existing remote objects", compare "the current configuration to the prior state", and propose "change actions that should, if applied, make the remote objects match the configuration" — all inspectable, because the middle input is a file on disk.

CloudFormation's record is a stack object inside the account, reached through the service API. No path, no bucket, no lock, no file to leak — a real advantage: a whole class of failure — the state file copied to a laptop, two engineers applying at once — does not exist there. The cost is symmetric: a record in the account is one you query, not one you carry.

hcl
terraform {
  required_version = ">= 1.10.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.67"
    }
  }

  # State locking is an opt-in feature of the S3 backend. use_lockfile
  # defaults to false, so the <key>.tflock object exists only because this
  # line is here. S3 native state locking arrived in Terraform 1.10.0, so
  # the required_version floor above is what makes this line valid at all:
  # on 1.8.x or 1.9.x it is an Unsupported argument error, not a lock.
  # DynamoDB-based locking is deprecated and will be removed in a future
  # minor version -- deprecated, not gone, so a migration off it is
  # planned work rather than an upgrade that happens to you.
  backend "s3" {
    bucket       = "example-tfstate-eu-west-1"
    key          = "platform/network/terraform.tfstate"
    region       = "eu-west-1"
    encrypt      = true
    use_lockfile = true
  }
}

provider "aws" {
  region = "eu-west-1"
}

resource "aws_vpc" "platform" {
  cidr_block           = "10.42.0.0/16"
  enable_dns_hostnames = true

  tags = {
    Name = "platform"
  }
}

# Terraform renames existing objects to the string in the `to` field and
# then creates a plan, so the resource is not destroyed during the run.
# This edits the address an object is recorded under -- nothing else.
moved {
  from = aws_vpc.main
  to   = aws_vpc.platform
}
The Terraform side of the record. The lock object exists because of one line; without it there is no lock at all.
The two mechanisms on the CloudFormation side are drawn separately because they are separate: a drift scan's blind spots are not the same list as a change set's carve-outs.

Drift: what each engine actually compares

Start with the older mechanism; most monitoring is built on it. CloudFormation "only determines drift for property values that are explicitly set", which "doesn't include default values for resource properties" — so "explicitly set the property value, even if you are setting it to the default value". Three exclusions follow. It "does not detect drift on any nested stacks", so initiate the operation on each. It "does not perform drift detection on the KMSKeyId property of any resources". And resource types that do not support drift detection.

It also reports things that are not drift: "objects contained in property arrays will be reported as drift, when in actuality they're default values supplied... from the underlying service". Nor is the explicitly-set rule total: it "also detects drift on stack-level tags". And the status itself misleads: IN_SYNC means "the current configuration of each supported resource" matches its template, and a stack "with no resources that support drift detection will also have a status of IN_SYNC" — a stack nothing could be checked in is green.

Coverage has to be established before the scan, and one page answers it: the user guide's Resource type support table, which "lists AWS resource types that currently support import, drift detection, and infrastructure as code (IaC) generator operations" in a column of its own. The stack-drift page names it for this question — not the Cloud Control API supported-resources page, which records something else. Enumerate your stack's types against that column, but do not read its row count as coverage: the table "is not an exhaustive list of AWS resources".

Terraform's side is one sentence, published already in Terraform vs Ansible: a refresh-only plan reports drift, and applying it writes the drifted values into state rather than reverting infrastructure.

yaml
AWSTemplateFormatVersion: "2010-09-09"
Description: Minimal stack whose properties are set explicitly so drift detection evaluates them.

Parameters:
  BucketName:
    Type: String
    Description: Name of the bucket this stack owns.

Resources:
  AuditBucket:
    Type: AWS::S3::Bucket
    # Keep the object store if the stack is deleted or the resource is
    # replaced. Neither policy affects drift; both affect what an exit
    # costs, which is the point of writing them down at creation time.
    DeletionPolicy: Retain
    UpdateReplacePolicy: Retain
    Properties:
      BucketName:
        Ref: BucketName
      # CloudFormation only determines drift for property values that are
      # explicitly set, and that does not include default values for
      # resource properties. AWS's guidance is to set the property value
      # even when you are setting it to the default value -- which is the
      # only reason every block below is spelled out.
      VersioningConfiguration:
        Status: Enabled
      PublicAccessBlockConfiguration:
        BlockPublicAcls: true
        BlockPublicPolicy: true
        IgnorePublicAcls: true
        RestrictPublicBuckets: true
      Tags:
        - Key: owner
          Value: platform

Outputs:
  AuditBucketArn:
    Description: ARN of the bucket this stack owns.
    Value:
      Fn::GetAtt: [AuditBucket, Arn]
A stack written so a drift scan can actually see it. Every property is set explicitly, because defaults are invisible to the scan.
bash
# -- CloudFormation ---------------------------------------------------
# detect-stack-drift only INITIATES the operation. It returns an id, and
# the per-resource records come from two further calls.
aws cloudformation detect-stack-drift \
  --stack-name "{{stack-name}}"
# -> { "StackDriftDetectionId": "{{detection-id}}" }

aws cloudformation describe-stack-drift-detection-status \
  --stack-drift-detection-id "{{detection-id}}"

# One record per resource that HAS been checked. Resources that don't
# currently support drift detection aren't checked, and so not included --
# so there is no row here to alert on, and an absent row is not proof that
# a resource was examined. Nested stacks need their own detect-stack-drift.
aws cloudformation describe-stack-resource-drifts \
  --stack-name "{{stack-name}}"

# A REVERT_DRIFT change set previews reconciliation. Executing it updates
# the actual state of ALL stack resources to match the desired state, even
# where a resource was not explicitly changed in the template -- so an
# incident fix made outside CloudFormation is overwritten by execution.
aws cloudformation create-change-set \
  --stack-name "{{stack-name}}" \
  --change-set-name "{{change-set-name}}" \
  --template-body "file://template.yaml" \
  --parameters ParameterKey=BucketName,ParameterValue="{{bucket}}" \
  --deployment-mode REVERT_DRIFT

# -- Terraform --------------------------------------------------------
# A refresh-only plan reports drift, and applying it writes the drifted
# values INTO state rather than reverting infrastructure. A plain
# terraform plan is the command that proposes bringing resources back to
# the configuration.
terraform plan -refresh-only
Reading drift on each side. Template, not a runnable script — the double-brace values are placeholders.

The plan you get, and what it will not promise

A drift-aware change set comes from create-change-set with "the --deployment-mode REVERT_DRIFT parameter". Executing one will "update the actual state of all stack resources to match the desired state, even if a resource was not explicitly changed in the template", matching "resource properties with template values" and recreating "resources deleted outside of IaC". Against a 03:00 console fix, the next deploy of an unrelated commit reverses it.

The same page documents the other path, and omitting it would make that warning dishonest: "when the template definition of a resource is updated to match its actual state, drift-aware change sets reset the drift status of the resource without modifying the resource." Overwrite and adopt are both supported; execution carries out whichever the template encodes, so the review is the control.

Reviewing means reading fields that decline to commit. Replacement can be Conditional — the API reference gives RequiresRecreation of Always with Evaluation of Dynamic as an example, not a definition. Action includes Dynamic, where the "exact action for the resource can't be determined", and SyncWithActual, where the "resource will not be changed, only CloudFormation metadata will change". ResourceDriftStatus is "only present for drift-aware change sets", and the API reference defines its UNSUPPORTED as "resource type does not support actual state comparison".

The field that earns its place is the fallback. Where a type does not support the three-way comparison, these change sets "fall back to a comparison of previous deployment state and desired state" — quietly the old two-way diff. The exclusion list is published rather than left to guesswork — AWS::MSK::Cluster and AWS::RDS::DBProxy are on it, so read it before the review. BeforeValueFrom then reports the fallback per property, "whether the before value comes from ACTUAL_STATE or PREVIOUS_DEPLOYMENT_STATE". Separately and more narrowly, write-only "properties containing sensitive data (passwords, secrets)" are compared against previous deployment values — about those properties, not unsupported types.

json
{
  "ChangeSetName": "platform-revert-drift",
  "StackName": "platform-network",
  "Status": "CREATE_COMPLETE",
  "ExecutionStatus": "AVAILABLE",
  "StackDriftStatus": "DRIFTED",
  "Changes": [
    {
      "Type": "Resource",
      "ResourceChange": {
        "Action": "Modify",
        "LogicalResourceId": "AuditBucket",
        "ResourceType": "AWS::S3::Bucket",
        "Replacement": "Conditional",
        "ResourceDriftStatus": "MODIFIED",
        "Details": [
          {
            "Target": {
              "Attribute": "Properties",
              "Name": "BucketName",
              "RequiresRecreation": "Always"
            },
            "Evaluation": "Dynamic",
            "ChangeSource": "ParameterReference",
            "BeforeValueFrom": "ACTUAL_STATE"
          },
          {
            "Target": {
              "Attribute": "Properties",
              "Name": "VersioningConfiguration",
              "RequiresRecreation": "Never"
            },
            "Evaluation": "Static",
            "ChangeSource": "DirectModification",
            "BeforeValueFrom": "ACTUAL_STATE"
          }
        ]
      }
    },
    {
      "Type": "Resource",
      "ResourceChange": {
        "Action": "Dynamic",
        "LogicalResourceId": "LegacyProvisioner",
        "ResourceType": "Custom::LegacyProvisioner",
        "ResourceDriftStatus": "UNSUPPORTED",
        "Details": [
          {
            "Target": {
              "Attribute": "Properties",
              "Name": "Endpoint"
            },
            "Evaluation": "Static",
            "ChangeSource": "DirectModification",
            "BeforeValueFrom": "PREVIOUS_DEPLOYMENT_STATE"
          }
        ]
      }
    },
    {
      "Type": "Resource",
      "ResourceChange": {
        "Action": "SyncWithActual",
        "LogicalResourceId": "AlarmTopic",
        "ResourceType": "AWS::SNS::Topic",
        "ResourceDriftStatus": "MODIFIED"
      }
    }
  ]
}
Shape of a drift-aware describe-change-set response, trimmed. Resource types here are illustrative, but the per-property RequiresRecreation values are not: an S3 bucket is replaced to change BucketName and updated in place to change VersioningConfiguration. The fallback is identified by BeforeValueFrom and by the published exclusion list — never by guessing which types support the comparison. AlarmTopic is drifted, which is why SyncWithActual has a drift status to reset.

Failure: a rollback that strands you, a state file that already moved

A comparison ranking for this question gives "Rollback on failure" as "Automatic, built in" for CloudFormation against "Manual; failed apply leaves partial state" for Terraform. The Terraform half is accurate; the CloudFormation half describes what is supposed to happen, and three documented outcomes say otherwise.

First, a failed drift-aware execution: CloudFormation "will roll back resources to their actual pre-deployment state and preserve out-of-band changes made since the last deployment" — the drifted world, not the last-deployed template.

Second, the classic path, which can strand you at either step. A stack in UPDATE_ROLLBACK_FAILED "can't be updated, but it can be rolled back to a working state (UPDATE_ROLLBACK_COMPLETE)" — and getting there is conditional: "in most cases, you must fix the error that causes the update rollback to fail" first, a timed-out operation being the exception. Where the only way on is skipping resources, CloudFormation sets them to UPDATE_COMPLETE, leaving them "inconsistent with the state of the resources in the stack template" — and warns that otherwise "subsequent stack updates might fail, and the stack will become unrecoverable."

Third, Terraform. When an apply step errors it logs the error, "updates the state file with any changes to your resources", unlocks it and exits: "your infrastructure may be in an invalid state", and "Terraform does not automatically roll back a partially-completed apply". Recovery is to resolve the error and apply again: worse on paper, simpler at 03:00, because the record is a file you can read.

Moving a resource: a state edit versus a service operation

On Terraform a move edits a record you hold; on CloudFormation it is a request a service can refuse. Terraform's moved block is gated too, by schema. Within one state file it is cheap: Terraform "renames existing objects to the string specified in the to field" and "does not destroy the resource during the Terraform run". Across types it is conditional — "each resource type has a separate schema so objects of different types are not typically compatible", and only some providers "let you change an object from one resource type to another". One case is closed: you cannot use it "to change a managed resource... into a data resource". A move between two state files is not a moved block at all: HashiCorp recommends remove and import, "because the removed and import blocks help keep a record of the configuration history", on Terraform 1.7 or newer — over terraform state mv, "a legacy command".

CloudFormation stack refactoring refuses at four points. The type must be eligible: it "only supports resource types with a provisioningType of FULLY_MUTABLE", checked with describe-type. The type must not be excluded — the stack refactoring page lists AWS::AutoScaling::AutoScalingGroup, AWS::EC2::EIP, AWS::EC2::LaunchTemplate, AWS::Route53::RecordSet, AWS::CertificateManager::Certificate and AWS::CloudFormation::CustomResource among many others: six ordinary pieces of infrastructure; AWS edits that list in place, so read the page. The stack must carry no stack policy — refactoring "doesn't support stacks that have stack policies attached, regardless of what the policies allow or deny". And it stays small: "a maximum of 5 stacks (using the AWS CLI)".

Those four are a subset. The same page refuses "pseudo parameters whose values differ between the source and destination stacks", refuses "the same resource into multiple stacks", and refuses a refactor that would empty one. Nothing rides along either: it "is limited to reorganizing existing resources" — no creates, deletes, configuration changes or new parameters and conditions.

Unequal numbers of gates, because the engines refuse at different points: Terraform's are schema questions you answer in the repository, CloudFormation's are service questions you answer by being told no.

The resources with no equivalent, in both directions

No honest score exists, so take each side's bound and escape hatch instead. The IaC generator's documented bound is scope, not a number: it "only supports AWS resources that are supported by Cloud Control API in your Region". A different page — Cloud Control API's supported resources table — lists "the public resource types published by AWS that currently support AWS Cloud Control API"; counted from it on 4 October 2026, roughly 1,300 distinct AWS::*::* type names, on a page whose header says the table was last updated 23 December 2025. One count, one page, one day — not AWS's resource-type coverage, which AWS does not publish, and not the drift table.

On the Terraform side, the registry API records hashicorp/aws 6.67.0 as published 30 September 2026, and counting that version's documentation index entries with a category of "resources" gives 1,722 managed resource types — again a count from a response, not a vendor total. These are not comparable universes: Terraform resources are not one-to-one with AWS type names, so subtracting one from the other invents a gap that does not exist.

Know each escape hatch as cost, not parity. CloudFormation's is the custom resource: for "resources that aren't available as CloudFormation resource types" you declare AWS::CloudFormation::CustomResource or Custom::MyCustomResourceTypeName and supply "the service token, which specifies where CloudFormation sends requests to, such as an Amazon SNS topic or a Lambda function" — an endpoint you write, run and keep alive for the stack's life. Terraform's counterpart is the hashicorp/awscc provider, "fully generated based on CloudFormation schema" and "refreshed weekly" from us-east-1, with its own caveat: "Some resources generation may be suppressed, and therefore unavailable in the provider despite being supported by CloudFormation."

What the auditor is shown, and who can read the record

Compliance work is where the comparison does not go Terraform's way. A stack event ties itself to the operation that produced it: its contents carry a Timestamp — "time the status was updated" — and a ClientRequestToken, since "all events triggered by a given stack operation are assigned the same client request token". Checked against the API reference's contents list on 4 October 2026, that list names no IAM principal. Caller identity is a separate record: a CloudTrail event "is the record of an activity in an AWS account", which "can be an action taken by an IAM identity, or service", covering "both API and non-API account activity".

Terraform's record has a sharper problem. "If you are developing with Terraform locally, Terraform stores your state in a plaintext file, which includes any secret values you defined in your configuration." The record an operator holds is a secret-bearing artefact needing encryption, access control, retention and a leak response — a control surface CloudFormation does not hand you, and a comparison that omits it is selling something. For the same evidence questions when the platform is yours, see NIST SP 800-53 on Kubernetes You Own: AC, AU, CM, SC, SI.

The sovereign reading is not that one record is better but that they fail differently. A file you hold can be read by anyone who obtains it — and by you, forever, with no account and no API. A record inside the account cannot leak, and cannot leave.

What a migration actually costs, each direction

No duration, headcount or cost figure appears here: none is sourceable. What is documented is where each direction stops — the input to an estimate you build yourself, the same discipline as pricing a cloud exit as a number.

Going in, the IaC generator is the on-ramp, and quota-shaped throughout. The scan "is region-wide and expires after 30 days", with frequency keyed to size: 10 scans per day below 10,000 resources, 1 per day above. One generated template models at most 500 resources — also the hard ceiling of any CloudFormation template, alongside 200 parameters and 200 outputs (and a template body capped at 51,200 bytes in a request, 1 MB as an S3 object).

Coming out, Terraform's generator carries a warning its documentation has never withdrawn: "Configuration generation is available in Terraform v1.5 as an experimental feature. Later minor versions may contain changes to the formatting of generated configuration and behavior of the terraform plan command using the -generate-config-out flag." It will not always succeed either — "for certain resources with complex schemas, Terraform may not be able to construct a valid configuration". Expect a baseline a human rewrites, not a translation.

Both directions are an import exercise, not a conversion. Nothing in a template becomes HCL, nothing in HCL a stack. The infrastructure transfers and the record is rebuilt from the live account — the strongest argument here for writing DeletionPolicy: Retain and its Terraform equivalents early.

bash
# -- Into CloudFormation: scan, then model at most 500 resources -------
# The scan is region-wide and expires after 30 days.
aws cloudformation start-resource-scan
# -> { "ResourceScanId": "{{scan-arn}}" }

aws cloudformation create-generated-template \
  --generated-template-name "{{template-name}}" \
  --resources '[{"ResourceType":"AWS::S3::Bucket","ResourceIdentifier":{"BucketName":"{{bucket}}"}}]'

aws cloudformation get-generated-template \
  --generated-template-name "{{template-name}}" \
  --format YAML

# -- Out of CloudFormation: import blocks, then generate configuration -
cat > imports.tf <<'HCL'
import {
  to = aws_s3_bucket.audit
  id = "{{bucket}}"
}
HCL

# Configuration generation is experimental. The output path must not
# already exist, and complex schemas may not yield a valid configuration.
terraform plan -generate-config-out="generated.tf"
First concrete step in each direction. Template, not a runnable script — the double-brace values are placeholders.

The long game: the record you can still read in ten years

CloudFormation has a native answer to keeping the source of truth in git. Git sync monitors a repository for "a CloudFormation template file that defines a stack" and "a stack deployment file that contains parameters": "when you commit changes to the template or the deployment file, CloudFormation automatically updates the stack". It is not uniformly available: the Git sync documentation names a list of Regions that is not every commercial Region — an architectural constraint, the same shape as when a GitOps control plane must reach every cluster.

Terraform's long-game risk is not technical. Its licence permits production use "provided Your use does not include offering the Licensed Work to third parties on a hosted or embedded basis in order to compete with IBM Corp.'s paid version(s)", and defines a competitive offering broadly: "a Product that is offered to third parties on a paid basis, including through paid support arrangements, that significantly overlaps" with those. Each version converts to MPL 2.0 on its own Change Date, four years from publication. That is a question about your business model, not your architecture, and the exit is a migration whose hard part is the registry.

So, a decision rule rather than a winner. If everything you will ever run is in AWS, and what you most want gone is a state file with secrets in it, CloudFormation is defensible and genuinely cheaper — provided you set every property explicitly, establish drift coverage against the resource-type table rather than a status code, and know what refactoring refuses to move. If you expect to operate anywhere else, or want a record still readable when the account, the vendor relationship and the licence have all changed, the file is the point. Pick the record you can still answer questions from in ten years.

§FAQ/Common questions

Frequently asked

Is Terraform or CloudFormation better in 2026?

Neither, and the honest answer depends on one question: do you want the record of your infrastructure to be a file you hold or an object inside the AWS account? CloudFormation's record is a stack object reached through the service API — there is no state file to encrypt, lock, version or leak, which removes a whole class of operational failure. Terraform's record is a file at a path you choose, which you must protect yourself (the S3 backend states that "state locking is an opt-in feature" and that use_lockfile "Defaults to false"), but which you can copy, diff and read without an account. The feature gap that used to decide this narrowed on 18 November 2025, when drift-aware change sets gave CloudFormation "a three-way diff between a new template, last-deployed template, and actual infrastructure state". What remains is portability and what each engine refuses to move.

Does CloudFormation drift detection catch everything?

No, and the exclusions are documented. CloudFormation "only determines drift for property values that are explicitly set, either through the stack template or by specifying template parameters", and this "doesn't include default values for resource properties" — so AWS advises setting a property "even if you are setting it to the default value". Drift detection on a stack "does not detect drift on any nested stacks"; it is never run on "the KMSKeyId property of any resources"; and resource types that do not support it are not evaluated. It also reports some things that are not drift: "objects contained in property arrays will be reported as drift, when in actuality they're default values supplied to the property from the underlying service". Critically, a green stack proves little — "a stack, stack set, or stack instance with no resources that support drift detection will also have a status of IN_SYNC".

Can I alert on a NOT_CHECKED drift status to find unmonitored resources?

No — that alert will never fire, and three AWS pages word this four different ways. The stack-drift user guide says in prose that "resources that don't support drift detection are assigned a drift status of NOT_CHECKED", but both status tables on that same page define NOT_CHECKED as CloudFormation having "not checked" the resource, with no mention of unsupported types. DescribeStackResourceDrifts says resources "that don't currently support drift detection aren't checked, and so not included" — there is no record returned at all. And StackResourceDrift documents NOT_CHECKED as a value "CloudFormation does not currently return". The only reliable method is to enumerate your stack's resource types against the Drift detection column of the user guide's "Resource type support" table, which the stack-drift page itself names as the list of resources that support drift detection. An absent row is not proof a resource was checked.

What does executing a drift-aware change set do to an out-of-band incident fix?

It depends on which of the two documented outcomes you encoded. If the template still carries the old value, execution overwrites the fix: drift-aware change sets "will update the actual state of all stack resources to match the desired state, even if a resource was not explicitly changed in the template", and AWS's launch post adds that during execution CloudFormation "will match resource properties with template values and recreate resources deleted outside of IaC". If instead you adopt the change into the template, the same page documents the non-mutating path: "when the template definition of a resource is updated to match its actual state, drift-aware change sets reset the drift status of the resource without modifying the resource". The control is the review step. Read BeforeValueFrom per property first — it "indicates whether the before value for a property comes from ACTUAL_STATE or PREVIOUS_DEPLOYMENT_STATE" — because unsupported types silently "fall back to a comparison of previous deployment state and desired state".

Can I move a resource between CloudFormation stacks the way Terraform's moved block works?

Sometimes, and the refusals are stricter. Stack refactoring "only supports resource types with a provisioningType of FULLY_MUTABLE", excludes a published list that includes AWS::AutoScaling::AutoScalingGroup, AWS::EC2::EIP, AWS::EC2::LaunchTemplate, AWS::Route53::RecordSet, AWS::CertificateManager::Certificate and AWS::CloudFormation::CustomResource, and "doesn't support stacks that have stack policies attached, regardless of what the policies allow or deny". It spans "a maximum of 5 stacks (using the AWS CLI)", refuses pseudo parameters "whose values differ between the source and destination stacks", refuses the same resource into multiple stacks, and refuses to leave a stack empty. Terraform's moved block is gated differently rather than not at all: it renames within a state file without destroying the resource, but each resource type "has a separate schema", cross-type moves work only where the provider allows the pair, and a managed resource cannot become a data resource. A move between two state files is not a moved block at all — HashiCorp recommends removed plus import on Terraform 1.7 or newer and calls terraform state mv "a legacy command".

terraform vs cloudformationcloudformation vs terraformcloudformation drift detection limitationsdrift-aware change setscloudformation stack refactoring unsupported resourcesterraform moved block stack refactor

Executive Briefing

Thirty minutes to clarify your infrastructure risk

Walk us through your vendor footprint and regulatory constraints. We will tell you honestly where sovereignty creates leverage — and where it does not. No pitch deck. No obligation.