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.
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.