AWS CloudFormation Testing: From Linting to Deployment Validation
CloudFormation templates grow from 50 lines to 5000 lines. They define VPCs, IAM policies, RDS instances, and load balancers. A misconfigured security group or a missing IAM permission isn't a code bug — it's an infrastructure outage or a security incident. Testing CloudFormation templates rigorously is not optional.
This guide covers the full testing stack: linting, security scanning, policy validation, and live stack deployment tests.
The CloudFormation Testing Pyramid
Like application code, CloudFormation testing has layers:
Level 4: Deployment integration tests (real stack, test account)
Level 3: Compliance and policy tests (cfn-guard)
Level 2: Security scanning (cfn-nag, checkov)
Level 1: Syntax and best practices (cfn-lint)Level 1 runs in under a second. Level 4 takes 10-30 minutes and costs money. Run them all — they catch different classes of problems.
Level 1: Linting with cfn-lint
CloudFormation Linter (cfn-lint) validates template syntax, resource properties, and catches common mistakes before deployment:
pip install cfn-lint
cfn-lint template.yamlIt checks:
- Property names (typo
BucketNmaeinstead ofBucketName) - Property types (string where integer expected)
- Deprecated resource types
- Missing required properties
- Invalid resource configurations (e.g., VPC settings that conflict)
Ignore rules that don't apply to your context:
# .cfnlintrc.yaml
rules:
ignore_checks:
- W3002 # Allow non-dynamic references in certain contexts
templates:
- "**/*.yaml"
- "**/*.json"
regions:
- "us-east-1"
- "eu-west-1"Run cfn-lint in CI:
- name: Lint CloudFormation templates
run: |
find . -name "*.yaml" -path "*/cloudformation/*" | xargs cfn-lintLevel 2: Security Scanning
cfn-nag
cfn-nag checks for security anti-patterns:
gem install cfn-nag
cfn_nag_scan --input-path template.yamlIt catches:
- Security groups open to
0.0.0.0/0 - Missing encryption on S3 buckets, EBS volumes, RDS instances
- IAM wildcard permissions
- Unrestricted network ACLs
- Missing MFA delete on S3 buckets
Example output:
WARN W2
Resources: ["SecurityGroup"]
Line Numbers: [45]
Security Groups found with cidr open to world on ingress - This is always a concern.
FAIL F2
Resources: ["IAMPolicy"]
Line Numbers: [89]
IAM policy should not allow * actionFailing on FAIL rules, warning on WARN:
cfn_nag_scan --input-path template.yaml --fail-on-warningsCheckov
Checkov scans CloudFormation for 800+ security checks:
pip install checkov
checkov -d . --framework cloudformationCheckov integrates with Terraform, ARM templates, and Kubernetes manifests too — useful if you have mixed IaC.
Suppress false positives inline:
Properties:
BucketName: my-bucket
# checkov:skip=CKV_AWS_18:Access logging not required for this internal bucket
AccessControl: PrivateLevel 3: Policy Validation with cfn-guard
cfn-guard lets you write organization-specific rules as code:
brew install cloudformation-guardWrite rules in cfn-guard DSL:
# rules/encryption.guard
# All S3 buckets must have server-side encryption
rule s3_bucket_encryption {
AWS::S3::Bucket {
Properties {
BucketEncryption {
ServerSideEncryptionConfiguration[*] {
ServerSideEncryptionByDefault {
SSEAlgorithm == "AES256" | SSEAlgorithm == "aws:kms"
}
}
}
}
}
}
# RDS instances must be encrypted
rule rds_encryption {
AWS::RDS::DBInstance {
Properties {
StorageEncrypted == true
}
}
}
# EC2 instances must not have public IPs
rule no_public_ec2 {
AWS::EC2::Instance {
Properties.NetworkInterfaces[*] {
AssociatePublicIpAddress == false
}
}
}Run against your templates:
cfn-guard validate \
--data template.yaml \
--rules rules/encryption.guard \
--show-summary allWrite rules for your specific compliance requirements — PCI-DSS, HIPAA, SOC 2, or internal standards. Check them into your repo so the rules evolve with your infrastructure.
Level 4: Deployment Integration Tests with TaskCat
TaskCat deploys your CloudFormation templates to real AWS accounts and validates they succeed:
pip install taskcatCreate .taskcat.yml:
project:
name: my-infrastructure
regions:
- us-east-1
- us-west-2
s3_bucket: my-taskcat-bucket
tests:
vpc-test:
template: cloudformation/vpc.yaml
parameters:
VpcCidr: "10.0.0.0/16"
Environment: test
app-stack-test:
template: cloudformation/app.yaml
parameters:
EnvironmentName: test
InstanceType: t3.micro
DBInstanceClass: db.t3.micro
regions:
- us-east-1 # override global regionsRun tests:
taskcat test runTaskCat:
- Uploads your templates to S3
- Creates CloudFormation stacks in each specified region
- Waits for stacks to reach
CREATE_COMPLETE - Reports failures with stack events
- Cleans up all created stacks (success or failure)
Custom Test Assertions After Stack Creation
TaskCat validates the stack creates — but not what it created. Add post-deploy assertions:
# tests/integration/test_vpc_stack.py
import boto3
import pytest
STACK_NAME = "tcat-my-infrastructure-vpc-test"
@pytest.fixture(scope="session")
def stack_outputs():
cf = boto3.client("cloudformation", region_name="us-east-1")
response = cf.describe_stacks(StackName=STACK_NAME)
stack = response["Stacks"][0]
assert stack["StackStatus"] == "CREATE_COMPLETE"
return {o["OutputKey"]: o["OutputValue"] for o in stack.get("Outputs", [])}
def test_vpc_created_with_correct_cidr(stack_outputs):
ec2 = boto3.client("ec2", region_name="us-east-1")
vpc_id = stack_outputs["VpcId"]
vpcs = ec2.describe_vpcs(VpcIds=[vpc_id])["Vpcs"]
assert len(vpcs) == 1
assert vpcs[0]["CidrBlock"] == "10.0.0.0/16"
def test_private_subnets_created(stack_outputs):
ec2 = boto3.client("ec2", region_name="us-east-1")
vpc_id = stack_outputs["VpcId"]
subnets = ec2.describe_subnets(
Filters=[
{"Name": "vpc-id", "Values": [vpc_id]},
{"Name": "tag:Type", "Values": ["private"]}
]
)["Subnets"]
assert len(subnets) >= 2, "Expected at least 2 private subnets"
for subnet in subnets:
assert not subnet["MapPublicIpOnLaunch"], \
f"Private subnet {subnet['SubnetId']} should not auto-assign public IPs"
def test_nat_gateway_exists(stack_outputs):
ec2 = boto3.client("ec2", region_name="us-east-1")
vpc_id = stack_outputs["VpcId"]
nat_gateways = ec2.describe_nat_gateways(
Filters=[
{"Name": "vpc-id", "Values": [vpc_id]},
{"Name": "state", "Values": ["available"]}
]
)["NatGateways"]
assert len(nat_gateways) >= 1, "Expected at least one NAT Gateway"Testing Stack Updates
Stack creates are easier to test than stack updates. Update failures leave stacks in UPDATE_ROLLBACK_COMPLETE or worse, UPDATE_ROLLBACK_FAILED. Test your updates:
def test_stack_update_succeeds(stack_name: str, new_template: str, parameters: dict):
cf = boto3.client("cloudformation")
# Capture current state
before = cf.describe_stacks(StackName=stack_name)["Stacks"][0]
before_outputs = {o["OutputKey"]: o["OutputValue"] for o in before.get("Outputs", [])}
# Apply update
cf.update_stack(
StackName=stack_name,
TemplateBody=new_template,
Parameters=[{"ParameterKey": k, "ParameterValue": v} for k, v in parameters.items()],
Capabilities=["CAPABILITY_NAMED_IAM"]
)
# Wait for update
waiter = cf.get_waiter("stack_update_complete")
waiter.wait(StackName=stack_name)
# Verify stack is healthy
after = cf.describe_stacks(StackName=stack_name)["Stacks"][0]
assert after["StackStatus"] == "UPDATE_COMPLETE"
# Verify critical outputs are preserved
after_outputs = {o["OutputKey"]: o["OutputValue"] for o in after.get("Outputs", [])}
assert after_outputs.get("DatabaseEndpoint") == before_outputs.get("DatabaseEndpoint"), \
"Database endpoint changed during update — this may cause downtime"Change Set Preview Testing
Before deploying a stack update to production, generate and review a change set:
def preview_changes(stack_name: str, template_body: str, parameters: dict) -> list[dict]:
cf = boto3.client("cloudformation")
change_set_name = f"preview-{int(time.time())}"
cf.create_change_set(
StackName=stack_name,
ChangeSetName=change_set_name,
TemplateBody=template_body,
Parameters=[{"ParameterKey": k, "ParameterValue": v} for k, v in parameters.items()],
Capabilities=["CAPABILITY_NAMED_IAM"]
)
# Wait for change set to be created
waiter = cf.get_waiter("change_set_create_complete")
waiter.wait(StackName=stack_name, ChangeSetName=change_set_name)
response = cf.describe_change_set(
StackName=stack_name,
ChangeSetName=change_set_name
)
# Clean up
cf.delete_change_set(StackName=stack_name, ChangeSetName=change_set_name)
return response["Changes"]
def assert_no_replacements(changes: list[dict], allowed_resources: list[str] = None):
"""Fail if any change causes resource replacement (potential data loss)."""
replacements = [
c for c in changes
if c["ResourceChange"]["Replacement"] in ("True", "Conditional")
and (allowed_resources is None or c["ResourceChange"]["LogicalResourceId"] not in allowed_resources)
]
if replacements:
resources = [c["ResourceChange"]["LogicalResourceId"] for c in replacements]
pytest.fail(f"Update would replace these resources (possible data loss): {resources}")Use this in your deployment pipeline before applying any production update.
Testing Nested Stacks
Nested stacks (CloudFormation stacks that reference other stacks) require testing at each level:
# Validate all nested templates
find . -name "*.yaml" -path "*/stacks/*" | xargs cfn-lint
# Run TaskCat on the root stack (it handles nested stacks automatically)
taskcat test run --include-regions us-east-1Test that nested stack outputs are correctly passed between stacks:
def test_nested_stack_outputs_connected():
cf = boto3.client("cloudformation")
root_stack = cf.describe_stacks(StackName="my-root-stack")["Stacks"][0]
vpc_stack_ref = next(
r for r in cf.describe_stack_resources(StackName="my-root-stack")["StackResourceSummaries"]
if r["ResourceType"] == "AWS::CloudFormation::Stack" and "VPC" in r["LogicalResourceId"]
)
vpc_stack = cf.describe_stacks(StackName=vpc_stack_ref["PhysicalResourceId"])["Stacks"][0]
vpc_outputs = {o["OutputKey"]: o["OutputValue"] for o in vpc_stack.get("Outputs", [])}
# Verify the VPC ID was exported correctly
assert "VpcId" in vpc_outputs
assert vpc_outputs["VpcId"].startswith("vpc-")CI/CD Pipeline
# .github/workflows/cloudformation.yml
name: CloudFormation Tests
on:
pull_request:
paths:
- "cloudformation/**"
jobs:
lint-and-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Lint templates
run: |
pip install cfn-lint
cfn-lint cloudformation/**/*.yaml
- name: Security scan
run: |
pip install checkov
checkov -d cloudformation/ --framework cloudformation --compact
- name: Policy validation
run: |
brew install cloudformation-guard
cfn-guard validate \
--data cloudformation/ \
--rules rules/ \
--show-summary fail
integration-test:
runs-on: ubuntu-latest
needs: lint-and-scan
if: github.base_ref == 'main'
steps:
- uses: actions/checkout@v4
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789:role/cloudformation-test-role
aws-region: us-east-1
- name: Run TaskCat
run: |
pip install taskcat
taskcat test run
- name: Run post-deploy assertions
run: |
pytest tests/integration/ -vCost Control
CloudFormation integration tests create real resources. Keep costs down:
- Use minimal instance types in test parameters (
t3.micro,db.t3.micro) - Set
DeletionPolicy: Deleteon all resources in test stacks - Use TaskCat's automatic cleanup — never leave stacks running
- Run integration tests only on PRs targeting
main, not on every branch push - Set AWS budget alerts for your test account
CloudFormation testing is infrastructure testing — the consequences of skipping it are measured in downtime and security incidents, not just failing unit tests. Linting takes seconds and catches typos. cfn-guard enforces your compliance requirements. TaskCat proves your stack actually deploys. All three layers are necessary; none is sufficient alone.