CloudFormation

AWS Intermediate/Advanced

Chapter 4 ยท Infrastructure as Code with CloudFormation

The first three chapters described things you would create by hand: accounts and guardrails, roles and policies, keys and secrets. Doing that by clicking does not scale, cannot be reviewed, and cannot be repeated. AWS CloudFormation is AWS's own answer: you describe the resources in a template, CloudFormation creates them together as a stack, and later changes go through the same template. This chapter builds one real template (the encrypted bucket, queues and role from the last two chapters), then goes through parameters, mappings, conditions, change sets, failure and rollback, deletion policies, drift and quotas. Every template here is checked with cfn-lint, and the parts of CloudFormation that need an account are modelled or labelled.

How every example in this chapter was verified
There is no AWS account and no AWS CLI on the machine that wrote this course, so no stack was created, changed or deleted.
  • Run here (level 1): Python 3.14.0 on Windows 11, in a scratch folder outside this site, with cfn-lint 1.57.2 (a template checker that works offline), cfn-flip 1.3.0 (YAML and JSON conversion) and PyYAML. cfn-lint output is pasted from real runs.
  • Needs an AWS account (level 2): aws cloudformation deploy, change sets, rollback, drift detection and every real stack operation. Shown with their documented options, not run here.
  • From the documentation, not run (level 3): every statement about how CloudFormation behaves. I read the template, parameter, condition, change set, failure, deletion-policy, drift and quota pages and the CLI reference on 2026-10-12 and 2026-10-13 through a page-fetch tool that summarises pages. One page (Outputs) came back only as a paraphrase from the tool's general knowledge, and is marked where used.
Three tools in this chapter are models, not AWS: a template resolver (cfn_resolve.py), an approximation of a change set, and a failed-deployment simulator. Each states which documented rules it copies and which it does not.

A Template, Section by Section

The documentation's rule is short: the Resources section "is required in every CloudFormation template"; everything else is optional. The sections: Description, Metadata, Parameters (values supplied when you create or update the stack), Rules (checks on parameter combinations), Mappings ("lookup tables"), Conditions, Transform (macros, including the Serverless Application Model), Resources and Outputs (values returned for viewing or for other stacks). Each resource has a logical ID unique in the template, a type and its properties.

The template below is the running example. It builds, for one environment, the pieces AWS Intermediate/Advanced 3 argued for: a KMS key with the root statement that delegates to IAM policies and a separate administrator statement, an S3 bucket encrypted with that key (and refusing plain-HTTP requests), a work queue with a dead-letter queue, a worker role that gets exactly what it needs, and a backlog alarm that exists only in production. First the inputs:

Parameters: Environment: Type: String Default: dev AllowedValues: [dev, staging, prod] Description: Which environment this stack is for RetentionDays: Type: Number Default: 30 MinValue: 1 MaxValue: 3650 Description: Days before report objects expire KeyAdminRoleArn: Type: String AllowedPattern: "^arn:aws:iam::[0-9]{12}:role/[A-Za-z0-9+=,.@/_-]+$" ConstraintDescription: Must be the ARN of an IAM role Description: Role that may administer (not use) the key Mappings: EnvSettings: dev: { VisibilityTimeout: 30, MaxReceive: 3 } staging: { VisibilityTimeout: 60, MaxReceive: 5 } prod: { VisibilityTimeout: 120, MaxReceive: 5 } Conditions: IsProd: !Equals [!Ref Environment, prod]

Then the key and its alias. The key policy has no statement naming the worker role, on purpose: the role gets kms:Decrypt from its own identity policy because the root statement delegates. Naming the role in the key policy would make the key depend on the role and the role (whose policy refers to the key) depend on the key, which is a circular dependency that cfn-lint rejects (see the experiment below). DeletionPolicy and UpdateReplacePolicy are both Retain for the data stores.

DataKey: Type: AWS::KMS::Key DeletionPolicy: Retain UpdateReplacePolicy: Retain Properties: Description: !Sub "Data key for ${AWS::StackName} (${Environment})" EnableKeyRotation: true KeyPolicy: Version: "2012-10-17" Statement: - Sid: Enable IAM User Permissions Effect: Allow Principal: AWS: !Sub "arn:${AWS::Partition}:iam::${AWS::AccountId}:root" Action: "kms:*" Resource: "*" - Sid: Allow access for Key Administrators Effect: Allow Principal: AWS: !Ref KeyAdminRoleArn Action: - "kms:Create*" - "kms:Describe*" - "kms:Enable*" - "kms:List*" - "kms:Put*" - "kms:Update*" - "kms:Revoke*" - "kms:Disable*" - "kms:Get*" - "kms:Delete*" - "kms:TagResource" - "kms:UntagResource" - "kms:ScheduleKeyDeletion" - "kms:CancelKeyDeletion" Resource: "*" DataKeyAlias: Type: AWS::KMS::Alias Properties: AliasName: !Sub "alias/${AWS::StackName}-data" TargetKeyId: !Ref DataKey

The bucket, its policy and the two queues:

ReportBucket: Type: AWS::S3::Bucket DeletionPolicy: Retain UpdateReplacePolicy: Retain Properties: BucketEncryption: ServerSideEncryptionConfiguration: - BucketKeyEnabled: true ServerSideEncryptionByDefault: SSEAlgorithm: "aws:kms" KMSMasterKeyID: !GetAtt DataKey.Arn VersioningConfiguration: Status: Enabled PublicAccessBlockConfiguration: BlockPublicAcls: true BlockPublicPolicy: true IgnorePublicAcls: true RestrictPublicBuckets: true LifecycleConfiguration: Rules: - Id: expire-reports Status: Enabled ExpirationInDays: !Ref RetentionDays Tags: - Key: environment Value: !Ref Environment ReportBucketPolicy: Type: AWS::S3::BucketPolicy Properties: Bucket: !Ref ReportBucket PolicyDocument: Version: "2012-10-17" Statement: - Sid: DenyInsecureTransport Effect: Deny Principal: "*" Action: "s3:*" Resource: - !GetAtt ReportBucket.Arn - !Sub "${ReportBucket.Arn}/*" Condition: Bool: "aws:SecureTransport": "false" WorkDeadLetterQueue: Type: AWS::SQS::Queue Properties: MessageRetentionPeriod: 1209600 KmsMasterKeyId: !Ref DataKey WorkQueue: Type: AWS::SQS::Queue Properties: VisibilityTimeout: !FindInMap [EnvSettings, !Ref Environment, VisibilityTimeout] KmsMasterKeyId: !Ref DataKey RedrivePolicy: deadLetterTargetArn: !GetAtt WorkDeadLetterQueue.Arn maxReceiveCount: !FindInMap [EnvSettings, !Ref Environment, MaxReceive]

Run here (level 1). The whole template, linted:

cfn-lint app_stack.yaml
cfn-lint exit status: 0

Clean, with exit status 0. Everything below that is "cfn-lint says" means that exit status and those rule IDs; I observed 0 for clean, 2 for errors, 4 for warnings and 6 for both.

Parameters

Parameters are inputs you supply when you create or update a stack. From the documentation: the only required attribute is Type; Number "is validated as a number; however, when you use the parameter elsewhere ... the parameter value becomes a string"; CommaDelimitedList gives "an array of literal strings that are separated by commas" with each member "space trimmed"; List<Number> becomes a list of strings. The constraints are AllowedValues, AllowedPattern (for a String "the pattern must match the entire parameter value"), MinLength, MaxLength, MinValue, MaxValue, ConstraintDescription and Default. A parameter with no default and no value fails the stack with Parameters: [KeyName] must have values. A template may have at most 200 parameters. There are also AWS-specific types (key pair names, VPC IDs) and Systems Manager parameter types that look up the latest value in Parameter Store.

NoEcho hides a value from display, not from the template
NoEcho: true masks the value "as asterisks" in describe calls and stack events, but "does not mask any information stored in" the Metadata section, the Outputs section, or a resource's Metadata attribute. The page says "We strongly recommend you do not use these mechanisms to include sensitive information" and to use dynamic references to Systems Manager Parameter Store or Secrets Manager instead (the page for those, which lists a limit of 60 dynamic references per template, I did not read). It also warns against putting a NoEcho parameter into a property that forms a resource's primary identifier, because the real value "may" end up in the resource ID. For a password, the secret from AWS Intermediate/Advanced 3 is the answer, not a parameter.

Run here (level 1). To check my understanding of these rules I wrote a small resolver: it takes a template and parameter values, applies the documented rules, and shows what the template becomes. Values only AWS knows (a resource's ID or attribute) stay as placeholders. It is a model, tested below. Here it renders the template for two environments and rejects four bad parameter sets:

py -X utf8 render_app.py
== Environment=dev: 7 resources: DataKey, DataKeyAlias, ReportBucket, ReportBucketPolicy, WorkDeadLetterQueue, WorkQueue, WorkerRole WorkQueue : {"VisibilityTimeout": 30, "KmsMasterKeyId": "<Ref DataKey>", "RedrivePolicy": {"deadLetterTargetArn": "<GetAtt WorkDeadLetterQueue.Arn>", "maxReceiveCount": 3}} DataKey desc.: Data key for reports (dev) DataKeyAlias : {'AliasName': 'alias/reports-data', 'TargetKeyId': '<Ref DataKey>'} Outputs : {"BucketName": "<Ref ReportBucket>", "QueueUrl": "<Ref WorkQueue>", "KeyArn": "<GetAtt DataKey.Arn>"} == Environment=prod: 8 resources: BacklogAlarm, DataKey, DataKeyAlias, ReportBucket, ReportBucketPolicy, WorkDeadLetterQueue, WorkQueue, WorkerRole WorkQueue : {"VisibilityTimeout": 120, "KmsMasterKeyId": "<Ref DataKey>", "RedrivePolicy": {"deadLetterTargetArn": "<GetAtt WorkDeadLetterQueue.Arn>", "maxReceiveCount": 5}} DataKey desc.: Data key for reports (prod) DataKeyAlias : {'AliasName': 'alias/reports-data', 'TargetKeyId': '<Ref DataKey>'} Outputs : {"BucketName": "<Ref ReportBucket>", "QueueUrl": "<Ref WorkQueue>", "KeyArn": "<GetAtt DataKey.Arn>", "AlarmName": "<Ref BacklogAlarm>"} RetentionDays 0 (MinValue is 1): CfnError: Parameter RetentionDays: 0 is outside MinValue/MaxValue Environment qa: CfnError: Parameter Environment: 'qa' is not one of AllowedValues ['dev', 'staging', 'prod'] KeyAdminRoleArn that is not a role ARN: CfnError: Malformed input-Parameter KeyAdminRoleArn Must be the ARN of an IAM role no KeyAdminRoleArn (no Default): CfnError: Parameters: [KeyAdminRoleArn] must have values

Production gets a longer visibility timeout, a different redrive count and an alarm and output that development does not have, all from one template. Note how each bad input fails: a value below MinValue, a value outside AllowedValues, a pattern failure (the message uses the page's wording, with the template's ConstraintDescription), and a missing value with the page's exact message.

Mappings, Conditions and Intrinsic Functions

Mappings are two-level lookup tables used with Fn::FindInMap; the template above uses one to choose queue settings by environment. Conditions are named true/false values built with Fn::Equals, Fn::And, Fn::Or, Fn::Not, and you attach them to resources and outputs (Condition: IsProd) or to property values with Fn::If. Rules from the Condition functions page:

  • Fn::And and Fn::Or take "the minimum number of conditions ... is 2, and the maximum is 10".
  • Inside Fn::Equals and the other condition functions you may use only Ref, Fn::FindInMap and other condition functions. (Fn::If itself accepts a longer list, including Fn::Sub and Fn::GetAtt.)
  • Fn::If can return AWS::NoValue to remove a property entirely.
  • "You can use intrinsic functions only in specific parts of a template": resource properties, outputs, metadata attributes and update policy attributes, and to conditionally create resources. That list does not include DeletionPolicy.

The full list of functions on the reference page: Fn::Base64, Fn::Cidr, the condition functions, Fn::FindInMap, Fn::ForEach, Fn::GetAtt, Fn::GetAZs, Fn::GetStackOutput, Fn::ImportValue, Fn::Join, Fn::Length, Fn::Select, Fn::Split, Fn::Sub, Fn::ToJsonString, Fn::Transform, Ref and rule functions. The resolver models the first group; it refuses Fn::GetAZs, Fn::Cidr, Fn::Transform, Fn::ForEach, Fn::Length and Fn::ToJsonString rather than guess.

Run here (level 1). I ran the documentation's own examples through the resolver (the parameter type examples, the EnvType condition sample for prod, dev and test, the subnet Select/Sub example, the NoValue snippet, the condition-function limits) and wrote each expected answer first:

py -X utf8 t_resolve.py
result case match Number 8888, then Ref: 'becomes a string' match List<Number> '80,20': a Ref 'would result in ["80","20"]' match CommaDelimitedList 'test,dev,prod' match CommaDelimitedList members are space-trimmed match Number parameter given 'abc' match AllowedPattern [A-Za-z0-9]+ and the value abc123 match the page's message for a failed AllowedPattern match the pattern must match the ENTIRE value: 'xxabc123yy' against 'abc123' match a parameter with no Default and no value match DbSubnet1 AvailabilityZone: Select 0 of the AZ list match DbSubnet1 CidrBlock: Select 0 of the CIDR list match DbSubnet3 CidrBlock: Select 2 FINDING FINDING (not counted): DbSubnet2 AvailabilityZone, ${AWS::Region}${AZ} with the page's own default AZ 'us-west-2b'; the evident intent is 'us-west-2b' [expected='us-west-2b' model='us-west-2us-west-2b'] match EnvType prod: InstanceType (nested Fn::If) match EnvType prod: MountPoint and NewVolume are created only for prod match EnvType dev: InstanceType (nested Fn::If) match EnvType dev: MountPoint and NewVolume are created only for prod match EnvType test: InstanceType (nested Fn::If) match EnvType test: MountPoint and NewVolume are created only for prod match EnvType 'qa' is not among AllowedValues match NoValue: no snapshot name -> the property is removed match NoValue: snapshot name 'snap-1' -> the property is present match Fn::Not of Fn::Equals (the page's EnvCondition shape) match Fn::And with ONE condition (the minimum is 2) match Fn::Or with 11 conditions (the maximum is 10) match Fn::Sub inside Fn::Equals (only Ref, Fn::FindInMap and condition functions are allowed) match a condition that names another condition match YAML: AllowedValues [Yes, No] without quotes is read by PyYAML as booleans match YAML: with single quotes the strings survive match cfn-flip's loader on AllowedValues [Yes, No] (what a CloudFormation tool sees) match Fn::Join ['-', [a, b, c]] match Fn::Split [',', 'x,y,z'] then Select 1 match Sub with an escaped literal ${!Literal} match Fn::GetAZs is not modelled (it needs the account's Regions) 33 counted cases, mismatches: 0; 1 finding(s) shown but not counted

All 33 counted cases match, and the control (one expectation flipped) reports one mismatch. One result is a finding, deliberately not counted: the Parameters page's subnet example builds an availability zone with ${AWS::Region}${AZ} where AZ is one of the page's own default values, us-west-2b. With the Region us-west-2 that resolves to us-west-2us-west-2b. The example only makes sense if the list held bare letters like b. cfn-lint did not notice (it reported only a warning about the default VPC value). I also checked the YAML trap the page warns about: an unquoted [Yes, No] is read as booleans by PyYAML and by cfn-flip's loader, so AllowedValues with Yes and No needs single quotes. Three small cases (Fn::Join, Fn::Split with Fn::Select, and the ${!Literal} escape in Fn::Sub) come from my own knowledge of those functions, not from pages I read.

Linting: What cfn-lint Catches and What It Does Not

cfn-lint checks a template against the resource schemas that CloudFormation publishes, plus rules of its own, without any AWS call. To learn what it really catches I broke app_stack.yaml in 22 specific ways (each a plain text replacement) and generated six templates that break documented quotas. A mutation that is not flagged is a gap in the tool, not in the experiment:

py -X utf8 mutate_lint.py
python.exe C:\lwaws\c4\.venv\Scripts\cfn-lint 1.57.2 mutation of app_stack.yaml exit rule ids first message (the unchanged template) exit 0 - unknown property name (EnableKeyRotations) exit 2 E3002 Additional properties are not allowed ('EnableKeyRotations' was unexpected. Did you mean 'EnableKeyR wrong type (a word where a number belongs) exit 2 E3012 'two-weeks' is not of type 'integer' Ref to a resource that does not exist exit 2 E1020 'DataKeyy' is not one of ['Environment', 'RetentionDays', 'KeyAdminRoleArn', 'DataKey', 'DataKeyAlia GetAtt of an attribute that does not exist exit 2 E1010 'Arnn' is not one of ['Arn', 'ContentBasedDeduplication', 'DeduplicationScope', 'DelaySeconds', 'Fif Default not among AllowedValues exit 2 E2015 Default should be a value within AllowedValues required property removed (Alias TargetKeyId) exit 2 E3003 'TargetKeyId' is a required property circular dependency (key policy names the role that uses the key) exit 2 E3004 Circular Dependencies for resource DataKey. Circular dependency with [WorkerRole] Sub with a variable nothing defines exit 2 E1019 'StackNam' is not one of ['Environment', 'RetentionDays', 'KeyAdminRoleArn', 'DataKey', 'DataKeyAlia Fn::Sub inside Fn::Equals (the docs allow only Ref and FindInMap there) exit 0 - Condition name that is not defined exit 2 E3015 'IsProduction' is not one of ['IsProd'] invalid DeletionPolicy value (Keep) exit 2 E3035 'Keep' is not one of ['Delete', 'Retain', 'RetainExceptOnCreate'] DeletionPolicy written with !If exit 0 - unused parameter exit 4 W2001 Parameter SpareParameter not used. unused condition exit 4 W8001,W8003 Condition NeverUsed not used output of a conditional resource without the condition exit 4 W1001 Ref to resource 'BacklogAlarm' that may not be available when condition 'IsProd' is False at Outputs bucket name that breaks the naming rules exit 2 E1161,E3031 'Bad_Name' is not a 'AWS::S3::Bucket.Name' with pattern '^(?![.\\-])(?!.*\\.\\.)(?!.*\\-\\.)(?!.*\\. IAM action without the service prefix exit 4 W3037 'ReceiveMessage' is not a valid action. Must be of the form service:action or '*' IAM Effect misspelt (Allowed) exit 2 E3510 'Allowed' is not one of ['Allow', 'Deny'] FindInMap with a second-level key that does not exist exit 0 - Number parameter with a non-number default exit 0 - KMS key policy with Principal missing exit 6 E3512,W2001 Parameter KeyAdminRoleArn not used. SQS retention above the documented service maximum (1209600 -> 99999999) exit 2 E3034 99999999 is greater than the maximum of 1209600 mutations cfn-lint did NOT flag at all (exit status 0): 4 of 22 - Fn::Sub inside Fn::Equals (the docs allow only Ref and FindInMap there) - DeletionPolicy written with !If - FindInMap with a second-level key that does not exist - Number parameter with a non-number default the documented quotas 501 resources (documented maximum is 500) exit 2 E3010 'Resources' has more than 500 properties 500 resources (at the maximum) exit 0 - 201 parameters (documented maximum is 200) exit 4 W2001 Parameter Q0000 not used. 201 outputs (documented maximum is 200) exit 2 E6010 'Outputs' has more than 200 properties description of 1,100 bytes (documented maximum is 1,024) exit 2 E1003 expected maximum length: 1024, found: 1238 template body of about 60,000 bytes (documented direct-request maximum is 51,200) exit 0 -

Read the result in three parts. Caught well: a misspelt property, a wrong type, a reference to something that does not exist, a missing required property, a bad GetAtt, a Default outside AllowedValues, an invalid DeletionPolicy, an undefined condition, a malformed S3 bucket name, an IAM Effect typo, a value above a service maximum, and the circular dependency (E3004 names the loop). Caught only as a warning (exit status 4): an unused parameter or condition, an output that refers to a conditional resource without the condition, an IAM action missing its service prefix. A pipeline that fails only on errors will let these through. Not caught at all (4 of 22): Fn::Sub inside Fn::Equals (the documentation says it is not allowed), DeletionPolicy written with !If, a FindInMap key that exists in no mapping, and a Number parameter whose Default is the word thirty. For the quotas, cfn-lint flagged 501 resources, 201 outputs and a 1,100-byte description, accepted exactly 500 resources, and did not flag 201 parameters (only an unused-parameter warning) or a 62 KB template body. Each of those is a reason to keep a second check, such as the resolver below, and a reason to test on one account before the first real deploy.

Check the checker
My first limits run padded the template to "501 resources" by assuming it started with ten, but it had eight, so it actually tested 499 and 498 and cfn-lint seemed to accept an over-limit template. I only noticed because a second tool (the quota checker further down) counted and disagreed. The generator now counts the template's real resources first, and cfn-lint does flag 501 (rule E3010). When two tools disagree, one of them is not measuring what you think.

Next, the documentation's own example templates, which you would expect to be clean:

cfn-lint (the documentation's examples and Chapter 1's SCP template)
== doc/conditions_sample.yaml exit status: 0 == doc/subnets_example.yaml W1030 {'Ref': 'VPC'} is not a 'AWS::EC2::VPC.Id' with pattern '^vpc-(([0-9A-Fa-f]{8})|([0-9A-Fa-f]{17}))$' when 'Ref' is resolved doc\subnets_example.yaml:18:7 W1030 {'Ref': 'VPC'} is not a 'AWS::EC2::VPC.Id' with pattern '^vpc-(([0-9A-Fa-f]{8})|([0-9A-Fa-f]{17}))$' when 'Ref' is resolved doc\subnets_example.yaml:30:7 W1030 {'Ref': 'VPC'} is not a 'AWS::EC2::VPC.Id' with pattern '^vpc-(([0-9A-Fa-f]{8})|([0-9A-Fa-f]{17}))$' when 'Ref' is resolved doc\subnets_example.yaml:42:7 exit status: 4 == doc/deletionpolicy.yaml W3011 Both 'UpdateReplacePolicy' and 'DeletionPolicy' are needed to protect resource from deletion doc\deletionpolicy.yaml:3:3 exit status: 4 == doc/ch1_scp_stack.yaml exit status: 0

The EnvType sample and Chapter 1's SCP template are clean. The subnet example gets warnings because its default VPC value vpc-123456 is not a valid VPC ID pattern, and the page's DeletionPolicy: Retain example gets W3011: "Both 'UpdateReplacePolicy' and 'DeletionPolicy' are needed to protect resource from deletion". That second warning is why the main template sets both.

Quotas to Design Around

From the CloudFormation quotas page (read 2026-10-12):

QuotaValueThe page's suggestion when you hit it
Resources per template500Split into nested stacks
Parameters per template200Use mappings or lists to carry several values in one parameter
Outputs per template200
Mappings per template, attributes per mapping200 and 200Split into more mappings or nested stacks
Template description1,024 bytes
Template body in a request51,200 bytesUpload it to S3 (limit 1 MB), or split into nested stacks
Parameter value4,096 bytesSeveral parameters joined with Fn::Join
Stacks per account (per Region)2,000Delete unused stacks or request an increase
Stack name128 characters
Resources changed in one nested-stack operation2,500Split the hierarchy
StackSets: sets, instances per set1,000 and 100,000Delete or request an increase
Dynamic references per template60

The deploy command in the CLI needs --s3-bucket for templates "greater than 51,200 bytes". My own quota checker (below) applied those numbers to the template and to the generated ones and agrees with the experiment above, including the two cfn-lint misses (parameters and body size).

Stacks, Change Sets and Deploying

A stack is the set of resources created from one template. Updating means giving CloudFormation a changed template or new parameter values. The safe route is a change set: "Change sets allow you to preview how proposed changes to a stack might impact your running resources". The flow on the page: create the change set (nothing changes yet), view it (which resources will be added, modified or removed, with a before/after for properties), optionally create more, and execute the one you want. After an execution "CloudFormation removes all change sets that are associated with the stack". One caution stands out: "Change sets don't guarantee that CloudFormation will successfully update a stack". Pre-deployment validation catches common causes (syntax errors, name conflicts, quota limits) but "failures that occur because of runtime conditions ... might still occur during execution".

A modification "can cause the resource to be interrupted or replaced (recreated)", which is the field you read most carefully. The page's example output shows "Action": "Modify" and "Replacement": "False" with property-level details. aws cloudformation deploy wraps all of this: it "deploys the specified AWS CloudFormation template by creating and then executing a change set", and with --no-execute-changeset it stops after creating it so you can review it. Its documented options include --parameter-overrides Key=Value, --capabilities (CAPABILITY_IAM or CAPABILITY_NAMED_IAM; without it, an IAM template fails with InsufficientCapabilities), --fail-on-empty-changeset (by default an empty change set exits with status 0) and --s3-bucket. Level 2, not run here:

# level 2: needs an AWS account; NOT RUN here. Options as documented on the CLI reference page. # 1. review the change set without applying it aws cloudformation deploy \ --template-file app_stack.yaml \ --stack-name reports-dev \ --parameter-overrides Environment=dev KeyAdminRoleArn=arn:aws:iam::111122223333:role/KeyAdmin \ --capabilities CAPABILITY_IAM \ --no-execute-changeset # 2. apply it (creates and executes a change set) aws cloudformation deploy \ --template-file app_stack.yaml \ --stack-name reports-dev \ --parameter-overrides Environment=dev KeyAdminRoleArn=arn:aws:iam::111122223333:role/KeyAdmin \ --capabilities CAPABILITY_IAM # 3. a failed create that keeps what worked (documented option) aws cloudformation create-stack --stack-name reports-dev --template-body file://app_stack.yaml --disable-rollback ... # 4. drift (documented three-step flow) aws cloudformation detect-stack-drift --stack-name reports-dev aws cloudformation describe-stack-drift-detection-status --stack-drift-detection-id <id from step 1> aws cloudformation describe-stack-resource-drifts --stack-name reports-dev --stack-resource-drift-status-filters MODIFIED DELETED

Which capability does a template need?

The page says: "If you have IAM resources, you can specify either capability. If you have IAM resources with custom names, you must specify CAPABILITY_NAMED_IAM." I wrote that rule as a checker (my list of name properties, since the page says only "custom names": RoleName, UserName, GroupName, InstanceProfileName, ManagedPolicyName). Its results are in section 3 of the output further down: app_stack.yaml needs CAPABILITY_IAM, adding RoleName makes it CAPABILITY_NAMED_IAM, and Chapter 1's SCP template needs neither.

An approximation of a change set, computed locally

You cannot create a change set without an account, but you can approximate the most important question: will this edit replace something? The resource schemas bundled in cfn-lint list each type's create-only properties, the ones that cannot be changed on an existing resource. My approximation renders the old and new template, lists Add, Remove and Modify with property-level differences, and calls a modification Replacement: True when a changed property is create-only, Conditional when it is conditionally create-only, and False otherwise. It is not a change set: a False can still be an interruption (the schema does not record those), and I did not read the page's definitions of True and Conditional, so those two meanings are my own.

py -X utf8 changeset_model.py
schemas from cfn-lint 1.57.2 -- 1. parameter change: Environment dev -> prod Add BacklogAlarm AWS::CloudWatch::Alarm Replacement: - Modify DataKey AWS::KMS::Key Replacement: False Modify /Properties/Description "Data key for demo-stack (dev)" -> "Data key for demo-stack (prod)" Modify ReportBucket AWS::S3::Bucket Replacement: False Modify /Properties/Tags/0/Value "dev" -> "prod" Modify WorkQueue AWS::SQS::Queue Replacement: False Modify /Properties/RedrivePolicy/maxReceiveCount 3 -> 5 Modify /Properties/VisibilityTimeout 30 -> 120 -- 2. parameter change: RetentionDays 30 -> 90 Modify ReportBucket AWS::S3::Bucket Replacement: False Modify /Properties/LifecycleConfiguration/Rules/0/ExpirationInDays "30" -> "90" -- 3. template edit: give the bucket a fixed BucketName Modify ReportBucket AWS::S3::Bucket Replacement: True Add /Properties/BucketName null -> "acme-reports-prod" -- 4. template edit: give the work queue a fixed QueueName Modify WorkQueue AWS::SQS::Queue Replacement: True Add /Properties/QueueName null -> "work" -- 5. template edit: rename only the bucket's logical ID, ReportBucket to Reports (the bucket policy still points at it) Remove ReportBucket AWS::S3::Bucket Replacement: - Modify ReportBucketPolicy AWS::S3::BucketPolicy Replacement: True Modify /Properties/Bucket <unknown value> -> <unknown value> Modify /Properties/PolicyDocument/Statement/0/Resource/0 <unknown value> -> <unknown value> Modify /Properties/PolicyDocument/Statement/0/Resource/1 <unknown value> -> <unknown value> Add Reports AWS::S3::Bucket Replacement: - Modify WorkerRole AWS::IAM::Role Replacement: False Modify /Properties/Policies/0/PolicyDocument/Statement/1/Resource <unknown value> -> <unknown value> -- 6. parameter change: a different key administrator role Modify DataKey AWS::KMS::Key Replacement: False Modify /Properties/KeyPolicy/Statement/1/Principal/AWS "arn:aws:iam::111122223333:role/KeyAdmin -> "arn:aws:iam::111122223333:role/OtherAdm create-only properties in the bundled schemas (a change to one of these means Replacement: True in this model) AWS::S3::Bucket ['BucketName', 'BucketNamePrefix', 'BucketNamespace'] AWS::SQS::Queue ['FifoQueue', 'QueueName'] AWS::KMS::Key (none listed) AWS::KMS::Alias ['AliasName'] AWS::IAM::Role ['Path', 'RoleName'] AWS::CloudWatch::Alarm ['AlarmName'] AWS::S3::BucketPolicy ['Bucket']

Four lessons. (1) Changing a parameter from dev to prod adds the alarm and modifies three resources, with no replacement. (2) Adding a fixed BucketName or QueueName to an existing resource is a replacement: the properties are create-only, and naming resources is the classic way to make a harmless-looking edit destructive. (3) Renaming a logical ID is not a rename: it is a remove plus an add, and everything that referred to it changes too (here the bucket policy's Bucket is create-only, so it is replaced, and the role's policy changes). That chain is derived from the documented Add/Remove behaviour, not a quoted sentence. (4) The schema lists no create-only properties for AWS::KMS::Key; I report that and make no claim about what real CloudFormation does when a key's other properties change.

When a Deployment Fails

The "Stack failure options" page describes how a failed operation behaves. CloudFormation "identifies dependencies between resources to parallelize independent provisioning actions", and "stops at the first failure in each independent provisioning path. A failure in one path doesn't affect other provisioning paths". If you choose Preserve successfully provisioned resources (--disable-rollback, or on-failure DO_NOTHING), the successful resources stay, the failed one is left in a failed state (CREATE_FAILED), and you can Retry (for a cause such as a missing IAM permission), Update (the template was wrong) or Roll back. Without that option the default is the rollback you are used to. The CLI command rollback-stack returns a failed stack to "its last stable state" and "will delete a stack if it doesn't contain a last known stable state". With express mode, rollback is off unless you turn it back on.

Rollback triggers let CloudWatch alarms stop a deployment: you name up to 5 alarms (or composite alarms) and a monitoring time of 0 to 180 minutes after the deployment; if any goes to ALARM during the operation or that period, "CloudFormation rolls back the entire stack operation". They are not monitored while a rollback is happening, and a missing alarm makes the whole operation fail and roll back.

Run here (level 1): I extracted the dependency graph from the template (references, GetAtt, Sub variables and DependsOn), and applied the documented behaviour to it: which resources get created, which fail, and which are never attempted when one resource fails. This is a model of those sentences, not CloudFormation. Section 3 shows the capability checker and section 4 the quota checker.

py -X utf8 stack_graph.py
1. dependency graph of app_stack.yaml (Environment=prod: BacklogAlarm exists) BacklogAlarm depends on ['WorkQueue'] DataKey depends on - DataKeyAlias depends on ['DataKey'] ReportBucket depends on ['DataKey'] ReportBucketPolicy depends on ['ReportBucket'] WorkDeadLetterQueue depends on ['DataKey'] WorkQueue depends on ['DataKey', 'WorkDeadLetterQueue'] WorkerRole depends on ['DataKey', 'ReportBucket', 'WorkQueue'] waves (each wave can be provisioned in parallel once the earlier waves are done): wave 1: DataKey wave 2: DataKeyAlias, ReportBucket, WorkDeadLetterQueue wave 3: ReportBucketPolicy, WorkQueue wave 4: BacklogAlarm, WorkerRole cycle in the real template: None cycle after letting the key policy name the role: ['DataKey', 'WorkerRole', 'DataKey'] (cfn-lint said E3004: Circular dependency with [WorkerRole]) 2. a failed deployment, preserve-successful-resources model (which resources get created, fail, or are never attempted) DataKey fails -> created: [] not attempted: ['BacklogAlarm', 'DataKeyAlias', 'ReportBucket', 'ReportBucketPolicy', 'WorkDeadLetterQueue', 'WorkQueue', 'WorkerRole'] WorkDeadLetterQueue fails -> created: ['DataKey', 'DataKeyAlias', 'ReportBucket', 'ReportBucketPolicy'] not attempted: ['BacklogAlarm', 'WorkQueue', 'WorkerRole'] ReportBucket fails -> created: ['BacklogAlarm', 'DataKey', 'DataKeyAlias', 'WorkDeadLetterQueue', 'WorkQueue'] not attempted: ['ReportBucketPolicy', 'WorkerRole'] WorkerRole fails -> created: ['BacklogAlarm', 'DataKey', 'DataKeyAlias', 'ReportBucket', 'ReportBucketPolicy', 'WorkDeadLetterQueue', 'WorkQueue'] not attempted: [] BacklogAlarm fails -> created: ['DataKey', 'DataKeyAlias', 'ReportBucket', 'ReportBucketPolicy', 'WorkDeadLetterQueue', 'WorkQueue', 'WorkerRole'] not attempted: [] 3. which capability a deployment needs app_stack.yaml: CAPABILITY_IAM (IAM resources: ['WorkerRole']) same template with RoleName: work-role: CAPABILITY_NAMED_IAM Chapter 1's SCP template (AWS::Organizations::Policy): (none) 4. the documented quotas, applied to the template and to the cfn-lint experiment's generated templates app_stack.yaml within every limit 501 resources (documented maximum is 500) resources 501 > 500 500 resources (at the maximum) within every limit 201 parameters (documented maximum is 200) parameters 201 > 200 201 outputs (documented maximum is 200) outputs 201 > 200 description of 1,100 bytes (documented maximum is 1,024) description bytes 1,238 > 1,024 template body of about 60,000 bytes (documented direct-request maximum is 51,2 body bytes (direct request) 62,191 > 51,200

Read the waves: the key must exist first, then the alias, bucket and dead-letter queue can go in parallel, and so on. When the key fails, nothing else is attempted. When the dead-letter queue fails, the key, alias, bucket and bucket policy are still created (an independent path) but the work queue, role and alarm are never attempted. When the worker role fails, everything else exists. The circular-dependency check finds the same loop as cfn-lint's E3004, which is the cross-check I trust more than either alone. The quota section agrees with the cfn-lint experiment.

Deletion and Replacement Policies

DeletionPolicy controls what happens to a resource when its stack is deleted (and when you remove the resource from the template). The documentation:

  • Delete (the default): the resource is deleted. Exceptions: the default is Snapshot for AWS::RDS::DBCluster and for AWS::RDS::DBInstance without a DBClusterIdentifier. For an S3 bucket "you must delete all objects in the bucket for deletion to succeed".
  • Retain: keep the resource, "continue to incur applicable charges until you delete those resources". Allowed on any resource.
  • RetainExceptOnCreate: like Retain, except that a rollback of the stack operation that created the resource still deletes it (new empty resources go, in-use ones stay).
  • Snapshot: for resources that support it (EBS volumes, RDS instances and clusters, ElastiCache, Redshift, DocumentDB and Neptune clusters are on the page's list), take a snapshot first; the snapshot continues to incur charges.

Two traps. The page says the policy "doesn't apply to resources whose physical instance is replaced during stack update operations", so a harmless-looking edit that forces a replacement (previous section) is not protected by DeletionPolicy alone; UpdateReplacePolicy is the other half, and cfn-lint's W3011 says to set both (I did not read the UpdateReplacePolicy page itself). And DeletionPolicy takes a plain string: the experiment above shows cfn-lint accepting !If there, which the documentation's list of places for intrinsic functions does not allow. The page also advises: if you want to change retained resources outside CloudFormation, use Retain then delete the stack, "otherwise, your resources might get out of sync with your CloudFormation template".

Drift

Drift detection compares what a template says with what exists. The page: "Performing a drift detection operation on a stack determines whether the stack has drifted from its expected template configuration, and returns detailed information about the drift status of each resource in the stack that supports drift detection". A stack "is considered to have drifted if one or more of its resources have drifted". You run detect-stack-drift, watch it with describe-stack-drift-detection-status (it "may take several minutes"; one run at a time per stack), and read the details with describe-stack-resource-drifts. The page also warns to confirm the stack's Last drift check time is earlier than the resource results, "to prevent the use of stale data". Detection reports; it does not fix anything.

Run here (level 1): I took the page's own example (a queue whose expected and actual properties are printed as JSON) and computed the property differences. The result reproduces the page's output exactly: /DelaySeconds actual 120, expected 20, and /RedrivePolicy/maxReceiveCount actual 12, expected 10. This only compares two documents; real drift detection reads the real resources. ADD and REMOVE are my extension of the comparison.

py -X utf8 drift_demo.py
computed from the page's ExpectedProperties and ActualProperties: /DelaySeconds actual 120 expected 20 NOT_EQUAL /RedrivePolicy/maxReceiveCount actual 12 expected 10 NOT_EQUAL matches the PropertyDifferences printed on the page: True what else a comparison like this reports (on top of the two documented differences) a property added by hand to the live resource : [('/Tags', 'ADD')] a property missing from the live resource : [('/VisibilityTimeout', 'REMOVE')] the expected document compared with itself : [] (no drift) ADD and REMOVE are my extension; the page's example prints only NOT_EQUAL. The page says drift detection reports on 'each resource in the stack that supports drift detection'.

Nested Stacks, StackSets, Exports and Terraform

Nested stacks split a large template into smaller ones; the quotas page notes you can have "a nested stack hierarchy with more than 2500 total resources", but not change more than 2,500 in a single operation. StackSets deploy a stack to many accounts and Regions; their limits are in the table above, and "StackSets does not currently support creating or updating stack sets with service-managed permissions from templates that contain macros", which includes transforms. Both are level 3 here. Outputs and exports: the outputs page did not load in the tool, so I did not read it; what follows is the tool's paraphrase from general knowledge and is unverified: export names must be unique within an account and Region, and a stack whose exported output another stack imports cannot be deleted or have that export changed. Check the page before relying on either. The function list above shows Fn::ImportValue and the newer Fn::GetStackOutput.

CloudFormation or Terraform? Terraform / Infrastructure as Code covers Terraform's own plan, apply and state. I will not repeat that here, and I did not verify claims about Terraform in this session. The choice usually turns on questions you can answer yourself: does your estate span clouds or services outside AWS, who will operate the tool and hold its state, and which preview (a change set here, a plan there) your reviewers will read. AWS Intermediate/Advanced 5 adds the AWS CDK, which generates templates like this one from code.

YAML and JSON

A template can be YAML or JSON. YAML allows short-form functions (!Ref, !Sub) and is much shorter; JSON has no comments and no short forms. Run here (level 1): cfn-flip converted the main template to JSON and back.

py -X utf8 flip_roundtrip.py
cfn-flip 1.3.0 parsed data equal: yaml == json: True; yaml -> json -> yaml == original: True size: YAML 6,187 bytes, JSON 14,023 bytes (227% of the YAML) short forms in the YAML: 29 | the same functions in the JSON: 29 cfn-lint app_stack.yaml exit 0 (no findings) cfn-lint app_stack.json exit 0 (no findings) cfn-lint app_stack_roundtrip.yaml exit 0 (no findings) cfn-lint bad.yaml exit 2 E3002 Additional properties are not allowed ('EnableKeyRotations' was unexpected. Did you | location bad.yaml:48:7 cfn-lint bad.json exit 2 E3002 Additional properties are not allowed ('EnableKeyRotations' was unexpected. Did you | location bad.json:87:17

The data is identical in both forms and after the round trip, the JSON is about 2.3 times the size, and cfn-lint gives the same answer on both (the same rule, E3002, but at a different line, so line numbers in a report refer to whichever file you gave it). The converted JSON is also larger against the 51,200-byte request limit.

Cost and Clean-Up

Nothing in this chapter created anything in AWS. If you deploy app_stack.yaml in a sandbox account, it creates a KMS key ($1 a month while it exists, from AWS Intermediate/Advanced 3), a bucket and two queues, a role and, in production, an alarm. Because the key and bucket have Retain policies, deleting the stack leaves them behind and they keep costing money until you remove them. Not run here:

# level 2: NOT RUN here. Deleting the stack leaves the Retain resources behind; remove them yourself. aws cloudformation delete-stack --stack-name reports-dev # then, for each retained resource (steps not spelled out here because I did not read the S3 or KMS CLI pages in this session): # - the bucket: it has versioning on, so empty it of ALL object versions (the docs say a bucket must be empty before deletion), then delete it # - the key: schedule its deletion (waiting period 7 to 30 days, default 30; irreversible), or disable it first if unsure # - the queues, role and alarm are deleted with the stack because they have no Retain policy

A key scheduled for deletion waits 7 to 30 days (default 30) and cannot be recovered after that (AWS Intermediate/Advanced 3). Put a budget alert in place first (AWS Fundamentals 9) and use the sandbox account from AWS Intermediate/Advanced 1.

What was and was not checked
Run here: cfn-lint 1.57.2 on the main template, 22 broken copies, six limit-breaking templates, three documentation examples and Chapter 1's SCP template; the resolver model against 33 documented cases (and one finding); parameter rendering for two environments and four bad inputs; the change set approximation on six edits using cfn-lint's bundled resource schemas; the dependency graph, failure model, capability checker and quota checker; the drift example comparison; the YAML/JSON round trip. From the documentation, not run: every statement about change sets, rollback, rollback triggers, deletion policies, drift detection, stack operations and quotas. Unverified or not read: the Outputs page (export rules), the Fn::Sub and UpdateReplacePolicy pages, the definitions of the Replacement values True and Conditional, the dynamic references page, nested stacks and StackSets beyond their limits. Not run at all: any stack, change set, rollback, drift detection, deploy command, nested stack or StackSet.

Practice

Exercise 1

Write a template for an encrypted bucket, two queues, a worker role and a production-only alarm, with parameters (constrained), a mapping, a condition, outputs and both deletion policies. Lint it, then break it in about twenty specific ways (misspelt property, wrong type, bad Ref, missing required property, circular dependency, bad condition, bad deletion policy, over a documented quota) and record for each whether cfn-lint caught it, only warned, or missed it. Lint the documentation's own example templates as well.

๐Ÿ“„ View solution
Exercise 2

Write a resolver for the documented template rules (parameter types and constraints, Ref, Sub, Join, Select, Split, FindInMap, If, the condition functions with their limits, NoValue, conditional resources and outputs) that refuses what it does not model. Run the documentation's own examples as tests with the expected result written first, report anything in the documentation that seems wrong, and render your template for two environments. Convert it to JSON and back and confirm the data survives.

๐Ÿ“„ View solution
Exercise 3

Approximate a change set locally by diffing two rendered templates and using the create-only properties from cfn-lint's bundled schemas to flag replacements; try six edits including naming a resource and renaming a logical ID. Extract the dependency graph, find cycles, and simulate which resources are created, fail or are never attempted when one fails. Add a capability checker, a quota checker and the drift-example comparison, and label what each cannot know.

๐Ÿ“„ View solution

Chapter 4 Quick Reference

  • Only Resources is required. Parameters (types, constraints, 200 max), mappings, conditions and outputs make one template serve several environments
  • Number parameters become strings under Ref; AllowedPattern must match the whole value; a parameter with no value or default fails with Parameters: [Name] must have values; quote 'Yes' and 'No' in YAML AllowedValues
  • NoEcho does not mask Outputs or Metadata: keep secrets in Secrets Manager or Parameter Store and use dynamic references
  • Condition functions: And/Or take 2 to 10 conditions; inside Equals use only Ref, FindInMap and condition functions; Fn::If with AWS::NoValue removes a property; intrinsic functions are not allowed in DeletionPolicy
  • Quotas: 500 resources, 200 parameters, 200 outputs, 1,024-byte description, 51,200-byte template in a request (1 MB from S3), 2,000 stacks, 128-character stack name
  • cfn-lint: exit 2 errors, 4 warnings, 6 both. It missed Sub inside Equals, !If in DeletionPolicy, a bad FindInMap key and a non-numeric Number default, and does not check the parameter count or body size: add a second check
  • Change set: create, view (Add, Modify, Remove, property-level), execute; it does not guarantee success. deploy creates and executes one (--no-execute-changeset to review); IAM templates need --capabilities, named IAM resources need CAPABILITY_NAMED_IAM
  • Naming a resource (BucketName, QueueName) or renaming a logical ID can replace it; read the Replacement field before executing
  • Failure: independent paths continue, dependents of a failure are never attempted; --disable-rollback keeps successful resources; rollback triggers use up to 5 CloudWatch alarms and a 0 to 180 minute monitoring time
  • DeletionPolicy: Delete (default; Snapshot for RDS clusters and standalone instances), Retain, RetainExceptOnCreate, Snapshot; it does not protect against replacement, so set UpdateReplacePolicy too; retained resources keep costing money
  • Drift detection reports differences (detect-stack-drift, then describe-stack-resource-drifts); it does not fix them, and you check the last drift check time
Coming next
Chapter 5 is the AWS CDK: write the same stack in TypeScript, run cdk synth for real to produce a template like this one, read what it generated, test the stack with assertions, and decide between CDK, CloudFormation and Terraform. Start with AWS Intermediate/Advanced 5.