TL;DR: cfn-lint catches a broken CloudFormation
Refbefore deployment: it reports rule E1020 and exits with code 2. You can reproduce it from two public commits without an AWS account, fix the reference, and run cfn-lint on every pull request. A clean lint run proves the references resolve, not that the stack is safe or will deploy.
By Prateek Singh, founder of Ganak AI Labs. Updated 6 October 2026.
A one-line change to an Auto Scaling group swaps !Ref PrivateSubnetB for !Ref MissingPrivateSubnetB. The YAML is valid, the diff looks harmless, and in a busy review it gets approved.
The mistake only shows up later, when CloudFormation tries to resolve a resource that doesn't exist.
This kind of error is cheap to catch before anything touches AWS. Here's how to reproduce it with cfn-lint from two public commits, fix it, gate pull requests on it, and see what lint can't tell you.
Disclosure: I'm the founder of Cloudeval.
Key terms used below
- CloudFormation: AWS's infrastructure-as-code service, using JSON or YAML templates.
-
Ref: the intrinsic function that returns the value of a parameter or resource declared in the same template. - cfn-lint: the AWS CloudFormation Linter. It validates templates locally against CloudFormation's resource specifications.
-
E1020: the cfn-lint rule for
Refvalidation.
What is a CloudFormation reference error?
A reference error is a Ref (or a similar intrinsic) that names something the template doesn't declare.
In the example below, the Auto Scaling group's VPCZoneIdentifier points to MissingPrivateSubnetB. That isn't a resource or a parameter in the file.
This is different from an architecture concern. Whether two private subnets give you enough availability, or a security group exposes the workload, are review questions. A broken Ref is simply wrong, and a linter can prove it.
How do I catch a broken Ref before deployment?
- Install a pinned cfn-lint version.
- Lint the known-good baseline and the regression.
- Fix the reference and lint again.
- Run cfn-lint on every pull request so it fails the check.
- Add a review step for what lint doesn't judge.
Step 1: Install a pinned cfn-lint
Pin the version so teammates and CI get the same result you do:
python3 -m venv .venv
source .venv/bin/activate
python -m pip install cfn-lint==1.52.1
cfn-lint --version # cfn-lint 1.52.1
On Windows, activate with .venv\Scripts\Activate.ps1. The commands below use a POSIX shell.
Step 2: Lint the baseline and the regression
The public example repository keeps both versions at fixed commits, so this works even if branches move:
curl -fsSLo baseline.yaml https://raw.githubusercontent.com/ganakailabs/cloudeval-aws-cloudformation-review-example/c3c867bb6d587482a47be1dc6bd8eae798a37844/templates/webapp.yaml
curl -fsSLo regression.yaml https://raw.githubusercontent.com/ganakailabs/cloudeval-aws-cloudformation-review-example/e18247c84c34944428141841f54dd3075e657b30/templates/webapp.yaml
cfn-lint baseline.yaml; echo "exit=$?"
cfn-lint regression.yaml; echo "exit=$?"
Here's the output, with the list of valid names shortened:
exit=0
E1020 'MissingPrivateSubnetB' is not one of ['EnvironmentName', 'LatestAmiId', ..., 'PrivateSubnetA', 'PrivateSubnetB', ..., 'AWS::StackName']
regression.yaml:338:7
exit=2
The baseline passes. The regression fails with E1020 at line 338.
cfn-lint lists every name the Ref could have used, which makes the typo obvious. The source diff is a single line:
VPCZoneIdentifier:
- !Ref PrivateSubnetA
- - !Ref PrivateSubnetB
+ - !Ref MissingPrivateSubnetB
Step 3: Fix the reference and lint again
sed 's/!Ref MissingPrivateSubnetB/!Ref PrivateSubnetB/' regression.yaml > corrected.yaml
cfn-lint corrected.yaml; echo "exit=$?" # exit=0
diff baseline.yaml corrected.yaml && echo "identical to baseline"
The corrected file passes and matches the baseline byte for byte. That closes this error. It doesn't close the review.
| Input | cfn-lint result | What it shows |
|---|---|---|
Baseline commit c3c867bb6d58
|
Exit 0, no findings | The baseline passes this pinned checker |
Regression commit e18247c84c34
|
Exit 2, E1020 | The changed Ref doesn't name a declared resource or parameter |
| Corrected regression | Exit 0, no findings | Restoring PrivateSubnetB removes the error |
Step 4: Run cfn-lint on every pull request
cfn-lint's exit codes are built for CI. 0 means no issues, 2 an error, 4 a warning and 8 an informational finding, and combinations add up bitwise. Any non-zero exit fails the job.
A minimal GitHub Actions job:
name: cfn-lint
on: pull_request
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install cfn-lint==1.52.1
- run: cfn-lint "templates/**/*.yaml"
Quoting the glob lets cfn-lint expand it. If only errors should fail the build, --non-zero-exit-code error changes the threshold.
Want to catch it before the push? cfn-lint ships a pre-commit hook, and its README links a GitHub Action and plugins for VS Code and IntelliJ.
Make the job a required status check in branch protection, and a broken Ref can't be merged.
Step 5: Add a review step for what lint doesn't judge
A passing template can still have broad ingress, public access or availability assumptions that don't fit the workload. That's the job of a review step.
The example repository is wired for Cloudeval, which reviews CloudFormation JSON and YAML statically, as a beta. It doesn't connect to a live AWS account.
To try it:
- Fork the example and install the Cloudeval GitHub App on your fork.
- Create a Cloudeval project from the repository.
- Add a project-scoped CI key as the
CLOUDEVAL_ACCESS_KEYsecret, plusCLOUDEVAL_PROJECT_ID.
You don't need AWS credentials. The CloudFormation review walkthrough covers the full setup.
The two screenshots below come from an Azure demo project. A CloudFormation beta review covers fewer areas.

The Action Plan tab ranks findings by effort and evidence.
Three habits keep the beta review honest:
- Check that it actually ran. The example workflow skips the review when its secrets are missing, so a green workflow isn't proof. Open the review step and confirm there's a real result.
- Keep sources separate. cfn-lint findings, extra Checkov findings and Cloudeval's own AWS mappings should stay distinguishable.
- Read "Not assessed" literally. It means no result was established, not that the area passed.

Breakdown traces a summary score back through each pillar.
Does a clean cfn-lint run mean the template is safe to deploy?
No. It means the template passes cfn-lint's checks, including that every Ref resolves.
Account permissions, service quotas, live dependencies and runtime behaviour can still break a deployment. None of them is visible in the file.
Each tool answers a different question:
| Check | Answers | Needs AWS credentials? |
|---|---|---|
| cfn-lint | Are resources, properties and references valid against the CloudFormation specs? | No |
| cfn-guard or Checkov | Does the template follow the policies your team maintains? | No |
aws cloudformation validate-template |
Is the template format valid, and what parameters does it take? | Yes |
| Change sets | What will change in this stack before execution? | Yes |
| Pull request review (for example, Cloudeval static beta) | Does the change add exposure, networking or deployment-quality findings, where checks apply? | No, for static review |
Run cfn-lint first because it's fast and local. Add a policy tool, review the change in the PR and create a change set before you execute.
Frequently asked questions
What is cfn-lint?
cfn-lint is the AWS CloudFormation Linter. It validates JSON and YAML templates locally against CloudFormation's resource specifications and reports errors, warnings and informational findings, such as E1020 for an invalid Ref.
How do I use cfn-lint?
Install it with pip install cfn-lint, then run cfn-lint template.yaml or a quoted glob such as cfn-lint "templates/**/*.yaml". Any non-zero exit code means it found something. Pin the version in CI so results are repeatable.
What is the difference between validate-template and cfn-lint?
validate-template checks the template format and parameters through the AWS API, so it needs credentials. cfn-lint checks resources, properties and references locally against its schemas, with no AWS account.
Do I need an AWS account for static validation?
No. A local cfn-lint check needs no AWS credentials, and neither does the example in this post. Cloudeval's static review needs a Cloudeval account and project access, but not AWS credentials.
Does Cloudeval connect to AWS or estimate AWS cost?
No. Cloudeval's CloudFormation support is a static beta. It reviews the supplied template and doesn't read live AWS state or estimate AWS cost.
Takeaway
A broken Ref is the cheapest CloudFormation bug you'll ever fix, as long as you catch it before deploy. Pin cfn-lint, make it a required check, and read a pass as "the references resolve", not "the change is safe".
For the wider comparison of linting, policy checks, PR review and change sets, read How do I review CloudFormation before deployment?
Related on cloudeval.ai
Try it
Run the two
curlcommands andcfn-lintabove. It takes about a minute. Then fork the example repository and follow the CloudFormation review walkthrough to add the static beta review in the Cloudeval app.About the author
Prateek Singh is the founder of Ganak AI Labs and builds Cloudeval AI.
cloudeval.ai | Docs | YouTube | GitHub | LinkedIn
Top comments (0)