AWS CloudFormation Testing: From Linting to Deployment Validation

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

It checks:

  • Property names (typo BucketNmae instead of BucketName)
  • 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-lint

Level 2: Security Scanning

cfn-nag

cfn-nag checks for security anti-patterns:

gem install cfn-nag
cfn_nag_scan --input-path template.yaml

It 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 * action

Failing on FAIL rules, warning on WARN:

cfn_nag_scan --input-path template.yaml --fail-on-warnings

Checkov

Checkov scans CloudFormation for 800+ security checks:

pip install checkov
checkov -d . --framework cloudformation

Checkov 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: Private

Level 3: Policy Validation with cfn-guard

cfn-guard lets you write organization-specific rules as code:

brew install cloudformation-guard

Write 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 all

Write 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 taskcat

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

Run tests:

taskcat test run

TaskCat:

  1. Uploads your templates to S3
  2. Creates CloudFormation stacks in each specified region
  3. Waits for stacks to reach CREATE_COMPLETE
  4. Reports failures with stack events
  5. 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-1

Test 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/ -v

Cost Control

CloudFormation integration tests create real resources. Keep costs down:

  • Use minimal instance types in test parameters (t3.micro, db.t3.micro)
  • Set DeletionPolicy: Delete on 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.

Read more

Start now free