CoursesAWS CloudFormationNested & cross-stack references

Nested & cross-stack references

Compose big infrastructure.

Intermediate12 min · lesson 7 of 12

One template that stands up your whole platform starts out helpful and ends as a 3,000-line file nobody wants to open. Programmers hit this same wall decades ago, and they solved it the same way every time: break the giant program into functions you call and libraries you share. CloudFormation (the infrastructure-as-code service from Amazon Web Services, or AWS, where a text file you write turns into real, running cloud resources) hands you both of those moves.

Nested stacks are the function call. A parent template owns its children, passes arguments down to them, and reads results back, and the whole set deploys and deletes as a single unit. Cross-stack references are the shared library. One stack publishes a value under a public name, and other stacks, run on their own schedule by other teams, borrow it. Both let you build big infrastructure out of small pieces. The real difference is how tightly the pieces are welded together, and that choice decides your blast radius (how much breaks when one piece fails) the day something goes wrong. This lesson is about the wiring between templates, not the Ref, GetAtt, and Sub functions themselves (those are cf-functions), and not fanning one template across many accounts (that is cf-stacksets).

Nested Stacks: Templates You Call Like Functions

A nested stack is a single resource, type AWS::CloudFormation::Stack, whose TemplateURL points at another template. That child template has to be sitting in S3 (Simple Storage Service, Amazon's file storage) by the time CloudFormation reads it, though in practice you write a local filename here and let the package command handle the upload (shown just below). Deploy the parent, which people call the root stack, and CloudFormation creates each child, feeds it its Parameters, and works out the deploy order for you from the dependencies between them. The wire that makes all of this pay off is GetAtt reading a child's Output. Writing !GetAtt NetworkStack.Outputs.PublicSubnetId pulls a named result out of one child and hands it straight to a sibling. Your network layer produces a subnet ID, your app layer consumes it, and you never copy-paste an ID between files again.

parent.yaml
Resources:
NetworkStack:
Type: AWS::CloudFormation::Stack
Properties:
TemplateURL: network.yaml # local path; 'package' uploads it and rewrites this to an S3 URL
Parameters:
VpcCidr: 10.0.0.0/16
TimeoutInMinutes: 20 # give up on the child if it hangs
AppStack:
Type: AWS::CloudFormation::Stack
Properties:
TemplateURL: app.yaml
Parameters:
# read an Output from the sibling child and pass it straight in
SubnetId: !GetAtt NetworkStack.Outputs.PublicSubnetId

The children share the root's life. Update the parent and CloudFormation compares every child against what is already running, then changes only what actually moved. Delete the parent and every child goes with it, in dependency order. That is the whole point: the platform becomes one versioned unit you deploy in a single call. It is also the whole risk. If AppStack fails to create, the default behavior rolls the entire root back, tearing down a perfectly healthy NetworkStack along with it. One all-or-nothing deploy means one all-or-nothing failure.

So keep pieces that live on very different clocks in different roots. Long-lived infrastructure that many things lean on, your VPC (Virtual Private Cloud, your own private network inside AWS) and your shared database, does not belong in the same root as app stacks you redeploy ten times a day. Because the whole tree rolls back as one, a typo in a fast-moving app template can delete a healthy network stack in the middle of a deploy. Blast radius follows the lifecycle you chose, so choose that boundary on purpose.

The children are real, standalone stacks, but the console tucks them under the parent, and the API stamps each one with a ParentId and a RootId. When you are auditing an account you inherited, that is how you tell an independent stack from one that is owned by a root you should not go poking at directly.

terminal
aws cloudformation describe-stacks \
--query 'Stacks[].[StackName,ParentId]' --output text
output
prod-platform None
prod-platform-NetworkStack-1A2B3C4D5E6F arn:aws:cloudformation:us-east-1:123456789012:stack/prod-platform/8f0e6a10-4c2b-11ef-9a1e-0e5c1b2a3d4f
prod-platform-AppStack-9Z8Y7X6W5V4U arn:aws:cloudformation:us-east-1:123456789012:stack/prod-platform/8f0e6a10-4c2b-11ef-9a1e-0e5c1b2a3d4f

Packaging And Shipping The Assembled Stack

Those child templates have to physically live in S3 before CloudFormation can read them, and uploading each one by hand and pasting its URL back into the parent is exactly how mistakes creep in. The aws cloudformation package command (part of the AWS CLI, the command-line interface) walks your parent template, uploads every local TemplateURL it finds to a bucket, and rewrites each reference to point at the S3 copy, writing out a finished template you can hand straight to deploy.

terminal
aws cloudformation package \
--template-file parent.yaml \
--s3-bucket my-templates \
--output-template-file parent.packaged.yaml
output
Uploading to 3f9a1c7e5b2d8a6f4c0e1b9d7a3f5c2e.template 1774 / 1774.0 (100.00%)
Uploading to a1b2c3d4e5f60718293a4b5c6d7e8f90.template 2210 / 2210.0 (100.00%)
Successfully packaged artifacts and wrote output template to file parent.packaged.yaml.
Execute the following command to deploy the packaged template
aws cloudformation deploy --template-file /home/you/parent.packaged.yaml --stack-name <YOUR STACK NAME>

Now deploy the packaged root in a single call. Two acknowledgments almost always apply. Because deploy runs through a change set (CloudFormation's preview of exactly what a run will add, change, or delete before it touches anything) and your template embeds AWS::CloudFormation::Stack children, CloudFormation makes you pass CAPABILITY_AUTO_EXPAND. And if any child creates IAM (Identity and Access Management, the service that controls who can do what in AWS) roles, you also acknowledge CAPABILITY_IAM, or CAPABILITY_NAMED_IAM when those roles carry custom names you picked. These flags are not red tape. They are a security gate. You are signing off that this one deploy can mint new permissions and expand templates you may not have read line by line. Miss a flag and CloudFormation rejects the whole thing up front, before it touches a single resource.

terminal
aws cloudformation deploy \
--template-file parent.packaged.yaml \
--stack-name prod-platform
output
An error occurred (InsufficientCapabilitiesException) when calling the CreateChangeSet operation: Requires capabilities : [CAPABILITY_AUTO_EXPAND, CAPABILITY_NAMED_IAM]

Add both capabilities and it goes through. Those success lines are your confirmation that the change set got built and applied.

terminal
aws cloudformation deploy \
--template-file parent.packaged.yaml \
--stack-name prod-platform \
--capabilities CAPABILITY_NAMED_IAM CAPABILITY_AUTO_EXPAND
output
Waiting for changeset to be created..
Waiting for stack create/update to complete
Successfully created/updated stack - prod-platform

Cross-Stack References: Publish Once, Import Forever

Sometimes the pieces genuinely live on different clocks. A network stack that stands for years. A dozen app stacks that come and go every week. Welding those into one root would be a mistake. Cross-stack references handle this like a public bulletin board on the office wall. The producer stack pins a value up under a name that has to be unique across your account and region. Any other stack in that same account and region reads it off the board by that exact name. No parent, no child, no shared deploy. The only thing linking the two stacks is a string.

That board is local to one place. Export names are scoped to a single account and a single region, so a stack in one region (say us-west-2) cannot import an export published in another (us-east-1), and a stack in a different account cannot reach it at all. Sharing across those lines needs a different tool, like Parameter Store (a shared key-value store we will get to shortly) set up for cross-account reads, or StackSets for pushing one template into many accounts at once (cf-stacksets).

The producer publishes with an Export block on one of its Outputs. The consumer reads it back with Fn::ImportValue.

network.yaml
# producer: network.yaml
Outputs:
VpcId:
Value: !Ref Vpc
Export:
Name: prod-network-VpcId # must be unique per account + region
app-sg.yaml
# consumer: a completely separate stack, deployed on its own schedule
Resources:
AppSg:
Type: AWS::EC2::SecurityGroup
Properties:
GroupDescription: app tier
VpcId: !ImportValue prod-network-VpcId

You can read the whole board at once. This is the first command to run when you land in an account you have never seen and want to know what shared plumbing already exists, and who might be leaning on it.

terminal
aws cloudformation list-exports
output
{
"Exports": [
{
"ExportingStackId": "arn:aws:cloudformation:us-east-1:123456789012:stack/prod-network/2c8f4a90-3b1d-11ef-b8e7-0e5c1b2a3d4f",
"Name": "prod-network-VpcId",
"Value": "vpc-0a1b2c3d4e5f6a7b8"
},
{
"ExportingStackId": "arn:aws:cloudformation:us-east-1:123456789012:stack/prod-network/2c8f4a90-3b1d-11ef-b8e7-0e5c1b2a3d4f",
"Name": "prod-network-PublicSubnetId",
"Value": "subnet-0c1d2e3f4a5b6c7d8"
}
]
}

The Lock Nobody Warns You About

Here is the trap. The moment one stack imports a value, the export behind it becomes a hard lock on the producer. You cannot change that export, and you cannot delete the resource it points at, as long as a single other stack still imports it. The value you treated as a handy shortcut is really a one-way weld between two stacks. So before you touch any producer stack, find out who is leaning on it. list-imports names the exact stacks reading a given export.

terminal
aws cloudformation list-imports --export-name prod-network-VpcId
output
{
"Imports": [
"prod-app-1",
"prod-app-2"
]
}

Skip that check, try to rename or drop the export, and CloudFormation halts the update and rolls the producer straight back. The call itself looks like it worked, because updates run in the background. The real reason surfaces in the stack events, and it names the offenders for you.

terminal
# start the update that renames the in-use export
aws cloudformation update-stack \
--stack-name prod-network \
--template-body file://network-v2.yaml
output
{
"StackId": "arn:aws:cloudformation:us-east-1:123456789012:stack/prod-network/2c8f4a90-3b1d-11ef-b8e7-0e5c1b2a3d4f"
}
terminal
# the CLI call returned fine (updates run async); the failure lands in the events
aws cloudformation describe-stack-events \
--stack-name prod-network --max-items 3 \
--query 'StackEvents[].[ResourceStatus,ResourceStatusReason]' \
--output text
output
UPDATE_ROLLBACK_COMPLETE None
UPDATE_ROLLBACK_IN_PROGRESS None
UPDATE_FAILED Export prod-network-VpcId cannot be updated as it is in use by prod-app-1, prod-app-2
Renaming an in-use export is a multi-team, multi-deploy dance
You cannot change or delete an exported value while any stack still imports it. Unwinding one export is a sequence: remove Fn::ImportValue from every consumer and deploy those first, then change the producer, then wire the consumers back, all coordinated across teams who may not deploy on your schedule. Run list-imports before every producer change, so a forgotten dependency turns up in review instead of in production at 2 a.m.

When The Value Changes, Use Parameter Store Instead

The export lock is fine for things that never move, like a VPC ID that lives as long as the account does. It is a trap for anything that rotates: an AMI (Amazon Machine Image, the disk image a server boots from) ID that gets rebuilt every month, a service endpoint, an ARN (Amazon Resource Name, the unique ID string AWS gives every resource) that changes the moment you replace a resource. For those, write the value into SSM (Systems Manager) Parameter Store, a plain key-value store, and have each consumer read the key instead of importing an export.

network.yaml
# producer writes the value to a well-known key instead of exporting it
Resources:
VpcIdParam:
Type: AWS::SSM::Parameter
Properties:
Name: /prod/network/vpc-id
Type: String
Value: !Ref Vpc
app.yaml
# consumer reads the key at deploy time, with no export lock
Parameters:
VpcId:
Type: AWS::SSM::Parameter::Value<String>
Default: /prod/network/vpc-id

CloudFormation does not treat that key as a stack dependency, so the producer stays free to update it or replace it, and each consumer picks up whatever the key holds the next time it deploys. You get the sharing without welding the producer shut. Be honest about the timing, though. A consumer sees the new value on its next deploy, not the instant the key changes, so anything that has to flip everywhere at the same moment belongs in application config read at runtime, not in a template parameter.

Which composition mechanism should you reach for
How should two stacks share a value?
they deploy and die together
Nested stacks
pass values with GetAtt Outputs; children share the parent's lifecycle and blast radius
separate lifecycles, stable value
Cross-stack Export / ImportValue
loose link by name; the export locks while any stack imports it
the value rotates over time
SSM Parameter Store
read the key at deploy; no dependency lock, picks up changes on the next deploy
Match the wiring to how the pieces actually live.
Quick check
01prod-network exports VpcId, and both prod-app-1 and prod-app-2 import it. Which change to prod-network will CloudFormation reject?
Incorrect — Allowed. The export is untouched, so no import is affected and the update goes straight through.
Correct — Renaming removes the in-use export name, and CloudFormation blocks the update while prod-app-1 and prod-app-2 still import it.
Incorrect — Allowed. A brand-new export name conflicts with nothing that is currently imported.
Incorrect — Allowed. No imported export is changed or removed, so there is no lock to trip.
02You run aws cloudformation deploy on a packaged parent template whose nested children (type AWS::CloudFormation::Stack) create IAM (Identity and Access Management) roles with names you picked. CloudFormation rejects it up front with InsufficientCapabilitiesException. Which acknowledgement does this deploy need?
Incorrect — plain CAPABILITY_IAM covers auto-named roles but not custom-named ones, and it still omits the auto-expand acknowledgement the nested stacks require.
Incorrect — the rejection is a hard permission gate, not a transient timing error, so re-running without flags fails identically every time.
Incorrect — named IAM resources anywhere in the deploy still need the IAM acknowledgement, so auto-expand alone is not enough.
Correct — the embedded AWS::CloudFormation::Stack children require AUTO_EXPAND and the custom-named roles require NAMED_IAM, so both must be passed.
03To simplify deploys you put your long-lived shared VPC (Virtual Private Cloud, your own private network in AWS) and an app stack you redeploy several times a day into a single root stack with nested children. One afternoon a typo makes the app child fail during an update. With default behavior, what is the risk to the VPC?
Correct — nested children share the root's all-or-nothing lifecycle, so one child's failure rolls the entire tree back, which is why blast radius should follow lifecycle.
Incorrect — children are not isolated for rollback; the default behavior rolls the whole root back together.
Incorrect — nested children deploy and delete as one unit with the parent; independent schedules describe cross-stack references, not nested stacks.
Incorrect — a failed update neither selectively deletes one child nor auto-exports the VPC; the entire root rolls back.

The one habit that saves you is boring: before you touch any producer stack, run list-imports on its exports, and list-exports when you first land in an unfamiliar account, so you can see who is downstream. The dependency you forgot is always the one that blocks the deploy at the worst possible moment. Thirty seconds of checking turns a production surprise into a quiet line in your change review.

Try this

Run aws cloudformation list-exports on a scratch host or disposable cluster and read the output against what this lesson described. Then change one input so it fails, and re-run: the error you get is the one you will meet in production.

Takeaway

The trap worth remembering here: renaming an in-use export is a multi-team, multi-deploy dance. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.

Related