Migrating a SAM CfnApplication to a CDK NestedStack Without Recreating Resources
SAM was the popular way to build serverless applications on AWS before the CDK took off. Plenty of projects started there, then adopted the CDK later, and for historical reasons ended up mixing both. A common shape is a CDK app that still pulls in a SAM application through CfnApplication: you reference a packaged SAM template by S3 URL, you cannot see the resources in your CDK code, and you read their outputs through stringly-typed getAtt calls. At some point you want to fold that SAM app into native CDK as a NestedStack.
This post shows how to do that migration without triggering a replacement, so the resources already running in production stay in place. As an example we take a template with typical SAM resources, a Lambda function and a Step Functions state machine. Those two are worth calling out because they are special resources in SAM: the SAM transform translates them into native CloudFormation in the background, which turns out to be exactly why this migration can be done safely.
The starting point
For this blog post we use a SAM template that defines a Lambda function and a state machine, and exposes two outputs:
Transform: AWS::Serverless-2016-10-31
Resources:
HelloFunction:
Type: AWS::Serverless::Function
Properties:
CodeUri: ./function
Handler: index.handler
Runtime: nodejs20.x
HelloStateMachine:
Type: AWS::Serverless::StateMachine
Properties:
Definition:
StartAt: InvokeHello
States:
InvokeHello:
Type: Task
Resource: !GetAtt HelloFunction.Arn
End: true
Policies:
- LambdaInvokePolicy:
FunctionName: !Ref HelloFunction
Outputs:
StateMachineArn:
Value: !Ref HelloStateMachine
FunctionArn:
Value: !GetAtt HelloFunction.Arn
The template looks similar to a regular CloudFormation template but comes with Transform: AWS::Serverless-2016-10-31 that enable special resource types like AWS::Serverless::StateMachine or AWS::Serverless::Function. It tells CloudFormation to run the SAM macro at deploy time, which expands the shorthand AWS::Serverless::* types into the underlying native resources (Lambda functions, IAM roles, state machines, and so on). This expansion is the foundation for the migration in the next steps.
Such SAM templates have been packaged with sam package, uploaded to S3, and referenced from CDK using CfnApplication:
import { CfnApplication } from 'aws-cdk-lib/aws-sam';
const samApp = new CfnApplication(this, 'SamApp', {
location: this.node.tryGetContext('samTemplateUrl'), // S3 URL of packaged.yaml
});
new cdk.CfnOutput(this, 'SamAppStateMachineArn', {
value: samApp.getAtt('Outputs.StateMachineArn').toString(),
});
Let’s look at what happens in the background: The CfnApplication expands the SAM app, at deploy time, into an AWS::CloudFormation::Stack whose TemplateURL points at the packaged SAM template. In other words, your CfnApplication already is a nested stack.
A CDK NestedStack also synthesizes to an AWS::CloudFormation::Stack with a TemplateURL, so the idea is to replace CfnApplication with a NestedStack that defines the same resources in CDK, without a delete and recreate.
The risk: If the logical IDs of the new nested stack resources do not match the logical IDs of the existing template, CloudFormation deletes the old nested stack resources (destroying the Lambda and state machine) and creates new ones. Preserving logical IDs is key here.
CfnApplication to NestedStack Migration Strategy
To achieve a smooth migration, we need to follow these action items:
- Retrieve resources and logical ids from the translated template
- Build the nested stack in CDK
- Adjust stack output retrieval
- Verify planned changes do not delete or replace resources
- Deploy :)
It processes transforms first, then diffs the processed template against the last deployed processed template. In both the deployed state and the new one, SamApp is an AWS::CloudFormation::Stack. Same logical ID plus same type gives you an in-place update, not a delete and create.
Inspect what is actually deployed
Before writing CDK code, look inside the deployed nested stack. You need the real logical IDs and you need to know whether any resource has an explicit physical name that would force a replacement.
Find the nested stack behind the SamApp logical ID, then fetch its processed template:
aws cloudformation describe-stack-resources \
--stack-name MyStack --logical-resource-id SamApp \
--query "StackResources[0].PhysicalResourceId" --output text
aws cloudformation get-template \
--stack-name <nested-stack-arn> \
--template-stage Processed --query TemplateBody --output json
For this example the SAM transform produced HelloFunction, HelloFunctionRole, HelloStateMachine, and HelloStateMachineRole. None of them carry an explicit FunctionName or StateMachineName, so the physical names are CloudFormation-generated and will survive an in-place update. Note the output names too (StateMachineArn, FunctionArn); you have to reproduce those exactly.
Build the NestedStack and pin the logical IDs
Recreate the resources as a NestedStack, then override each generated logical ID to match what the SAM transform produced. Leave physical names unset so they are preserved:
export class SamAppNestedStack extends cdk.NestedStack {
constructor(scope: Construct, id: string, props?: cdk.NestedStackProps) {
super(scope, id, props);
const fn = new lambda.Function(this, 'HelloFunction', {
runtime: lambda.Runtime.NODEJS_20_X,
handler: 'index.handler',
code: lambda.Code.fromAsset(path.join(__dirname, '..', 'sam-app', 'function')),
});
const stateMachine = new sfn.StateMachine(this, 'HelloStateMachine', {
definitionBody: sfn.DefinitionBody.fromChainable(
new tasks.LambdaInvoke(this, 'InvokeHello', { lambdaFunction: fn }),
),
});
// Pin SAM-generated logical IDs -> in-place update, no replace.
const pin = (c: IConstruct, id: string) =>
(c.node.defaultChild as cdk.CfnResource).overrideLogicalId(id);
pin(fn, 'HelloFunction');
pin(fn.role!, 'HelloFunctionRole');
pin(stateMachine, 'HelloStateMachine');
pin(stateMachine.role, 'HelloStateMachineRole');
// Output logical IDs must match the SAM template's Outputs verbatim.
new cdk.CfnOutput(this, 'StateMachineArn', { value: stateMachine.stateMachineArn });
new cdk.CfnOutput(this, 'FunctionArn', { value: fn.functionArn });
}
}
There are two levels to keep aligned. The child resources inside the nested stack get CDK’s hashed logical IDs (like HelloFunctionABC123), so you override each one back to the SAM name through the L1 escape hatch (node.defaultChild). And the nested-stack resource in the parent, which CDK names something like SamAppNestedStackSamAppNestedStackResource1234ABCD, has to go back to SamApp. That second override is what turns the whole thing into an in-place update:
const sam = new SamAppNestedStack(this, 'SamAppNested');
const samResource = sam.nestedStackResource as cdk.CfnResource;
samResource.overrideLogicalId('SamApp');
Watch out for CDK defaults
CDK’s L2 constructs do not synthesize byte-identical templates to the SAM transform. Two differences showed up here.
The first is the managed Lambda log group. Recent CDK has @aws-cdk/aws-lambda:useCdkManagedLogGroup on by default, which makes lambda.Function create an explicit AWS::Logs::LogGroup. SAM did not create that resource, and the log group already exists, so the deploy fails with “log group already exists”. Turn the flag off in cdk.json to match SAM:
"@aws-cdk/aws-lambda:useCdkManagedLogGroup": false
If that trap sounds familiar, it is the same feature flag behind migrating CDK logRetention to logGroup.
The second is the state machine’s policy. SAM renders LambdaInvokePolicy inline in the role’s Policies. CDK instead attaches a separate AWS::IAM::Policy resource. That is additive, so it creates a new policy on the existing role and drops the old inline statements without renaming or replacing the role. Property changes are fine as long as nothing forces a replacement.
The rule of thumb: diff your synthesized nested template against the processed template from earlier. A matching logical ID with non-replacing property changes is a safe in-place update. New additive resources are fine. A resource whose logical ID disappears would be deleted, so avoid that.
Keep the parent outputs resolving
The parent originally read Fn::GetAtt SamApp.Outputs.StateMachineArn. To keep that reference intact, read the outputs from the logical-ID-overridden nested-stack resource with getAtt:
new cdk.CfnOutput(this, 'SamAppStateMachineArn', {
value: samResource.getAtt('Outputs.StateMachineArn').toString(),
});
Do not wire the parent output to the L2 attribute directly (value: stateMachine.stateMachineArn). That makes CDK auto-generate a nested output with a hashed logical ID, which would not match the deployed Outputs.StateMachineArn and would break the reference. Go through nestedStackResource.getAtt('Outputs.<name>'). The .toString() is required because getAtt returns a resolvable token, not a string.
Verify before you deploy
Do not trust cdk diff here. It compares against the deployed original template, where SamApp is still AWS::Serverless::Application. The type change makes cdk diff treat the nested stack as brand new and paint everything inside it as replaced. That is a false alarm, because CloudFormation diffs the processed template.
The authoritative check is a real change set, which is computed against the deployed processed state and reports replacement per resource without changing anything:
npx cdk deploy MyStack --no-execute
aws cloudformation describe-change-set \
--stack-name MyStack --change-set-name cdk-deploy-change-set \
--query "Changes[].ResourceChange.{Logical:LogicalResourceId,Action:Action,Replacement:Replacement}" \
--output table
The top-level SamApp must show Action: Modify. Then drill into the nested stack’s own change set (its ID is in the SamApp change entry) and confirm HelloFunction and HelloStateMachine show Action: Modify with Replacement: False. The additive IAM policy showing Action: Add is expected. If anything shows Remove or Replacement: True, stop: a logical ID is not lining up or a replace-forcing property changed. Once the change set looks right, deploy for real.
Wrapping up
The trick is realizing that CfnApplication and NestedStack both become the same AWS::CloudFormation::Stack after the SAM transform, so this is a rename problem, not a rebuild. Pin the child logical IDs, pin the nested-stack resource back to SamApp, handle the CDK defaults that add or reshape resources, and verify with a change set rather than cdk diff. Do that and you trade the opaque SAM seam for native CDK without your Lambda or state machine ever noticing.
If you like these CloudFormation edge cases, I also wrote about how updating tags replaces an OpenSearch Serverless collection and cross-account ingestion into OpenSearch Serverless with CDK.
Related Articles

Migrating AWS CDK logRetention to logGroup Without Losing Your Logs
How to safely migrate the deprecated AWS CDK logRetention prop to logGroup by importing the existing log group, so you keep your logs and avoid a failed deploy.

Updating Tags on an OpenSearch Serverless Collection Replaces the Resource
AWS::OpenSearchServerless::Collection requires replacement when you update tags — a surprising CloudFormation behavior that can break cross-account setups and cause downtime.

Run Custom Build Commands During CDK Synthesis with Code.fromCustomCommand
Learn how to use CDK's Code.fromCustomCommand to run custom build scripts, download artifacts, or use non-standard toolchains like Rust or Go during CDK synthesis.

Serve Markdown for LLMs and AI Agents Using Amazon CloudFront
Learn how to serve Markdown to LLM and AI agent clients while keeping HTML for human visitors, using CloudFront Functions, Lambda, and S3 — the AWS equivalent of Cloudflare's 'Markdown for Agents' feature.