Nested & cross-stack references
Compose big infrastructure.
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.
Resources:NetworkStack:Type: AWS::CloudFormation::StackProperties:TemplateURL: network.yaml # local path; 'package' uploads it and rewrites this to an S3 URLParameters:VpcCidr: 10.0.0.0/16TimeoutInMinutes: 20 # give up on the child if it hangsAppStack:Type: AWS::CloudFormation::StackProperties:TemplateURL: app.yamlParameters:# read an Output from the sibling child and pass it straight inSubnetId: !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.
aws cloudformation describe-stacks \--query 'Stacks[].[StackName,ParentId]' --output text
prod-platform Noneprod-platform-NetworkStack-1A2B3C4D5E6F arn:aws:cloudformation:us-east-1:123456789012:stack/prod-platform/8f0e6a10-4c2b-11ef-9a1e-0e5c1b2a3d4fprod-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.
aws cloudformation package \--template-file parent.yaml \--s3-bucket my-templates \--output-template-file parent.packaged.yaml
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 templateaws 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.
aws cloudformation deploy \--template-file parent.packaged.yaml \--stack-name prod-platform
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.
aws cloudformation deploy \--template-file parent.packaged.yaml \--stack-name prod-platform \--capabilities CAPABILITY_NAMED_IAM CAPABILITY_AUTO_EXPAND
Waiting for changeset to be created..Waiting for stack create/update to completeSuccessfully 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.
# producer: network.yamlOutputs:VpcId:Value: !Ref VpcExport:Name: prod-network-VpcId # must be unique per account + region
# consumer: a completely separate stack, deployed on its own scheduleResources:AppSg:Type: AWS::EC2::SecurityGroupProperties:GroupDescription: app tierVpcId: !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.
aws cloudformation list-exports
{"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.
aws cloudformation list-imports --export-name prod-network-VpcId
{"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.
# start the update that renames the in-use exportaws cloudformation update-stack \--stack-name prod-network \--template-body file://network-v2.yaml
{"StackId": "arn:aws:cloudformation:us-east-1:123456789012:stack/prod-network/2c8f4a90-3b1d-11ef-b8e7-0e5c1b2a3d4f"}
# the CLI call returned fine (updates run async); the failure lands in the eventsaws cloudformation describe-stack-events \--stack-name prod-network --max-items 3 \--query 'StackEvents[].[ResourceStatus,ResourceStatusReason]' \--output text
UPDATE_ROLLBACK_COMPLETE NoneUPDATE_ROLLBACK_IN_PROGRESS NoneUPDATE_FAILED Export prod-network-VpcId cannot be updated as it is in use by prod-app-1, prod-app-2
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.
# producer writes the value to a well-known key instead of exporting itResources:VpcIdParam:Type: AWS::SSM::ParameterProperties:Name: /prod/network/vpc-idType: StringValue: !Ref Vpc
# consumer reads the key at deploy time, with no export lockParameters: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.
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.