2026-01-19
Our recent fieldnote about deal sizing from estimates jogged our memory about an old feature which is worth revisiting: generating estimates from CloudFormation workload definitions.
This was the hook:
"Partners can optionally import AWS Pricing Calculator URLs to automatically populate AWS service selections
and corresponding spend estimates into their opportunities, reducing the need for manual re-entry.
This is actually a fairly old CLI/API, but its a goodie.
Essentially, if you have a CloudFormation definition of your workload, you can use that definition to bootstrap the workload cost estimator. The details are tucked away in a place many alliance people will not know exists :-(
A worked example: if we have a Cloudformation yaml file like this (save it as devinfrasingle.yaml; if you don’t speak CloudFormation, scroll down to see the results!):
AWSTemplateFormatVersion: '2010-09-09'
Description: >
Dev infrastructure stack - t3.micro EC2 + 20GB EBS (gp3) + RDS PostgreSQL (dev grade).
Deploy this template once per target region:
us-west-2 | ap-southeast-2 | eu-west-1
Use StackSets or the CLI deploy commands at the bottom of this file.
# ─────────────────────────────────────────────
# PARAMETERS
# ─────────────────────────────────────────────
Parameters:
EnvironmentName:
Type: String
Default: dev
Description: Short prefix applied to all resource names and tags.
KeyPairName:
Type: AWS::EC2::KeyPair::KeyName
Description: >
Name of an existing EC2 Key Pair in this region for SSH access.
Key Pairs are region-specific — you need one per region.
DBPassword:
Type: String
NoEcho: true
MinLength: 12
MaxLength: 41
AllowedPattern: '[a-zA-Z0-9!#$%^&*()_+=-]*'
Description: >
RDS master password (12-41 chars, alphanumeric + special chars).
NoEcho prevents it appearing in console or CLI output.
DBUsername:
Type: String
Default: dbadmin
MinLength: 1
MaxLength: 16
AllowedPattern: '[a-zA-Z][a-zA-Z0-9]*'
Description: RDS master username.
AllowedSSHCidr:
Type: String
Default: 0.0.0.0/0
Description: >
CIDR block permitted SSH access to the EC2 instance.
Restrict to your IP in practice (e.g. 203.0.113.0/32).
# ─────────────────────────────────────────────
# MAPPINGS
# Uses SSM dynamic resolution for AMI IDs so
# this template never needs updating for AMI
# changes — AWS resolves the latest AL2023 AMI
# in whatever region you deploy into.
# ─────────────────────────────────────────────
# No static AMI mappings needed — see ImageId below.
# ─────────────────────────────────────────────
# RESOURCES
# ─────────────────────────────────────────────
Resources:
# ── NETWORKING ──────────────────────────────
VPC:
Type: AWS::EC2::VPC
Properties:
CidrBlock: 10.0.0.0/16
EnableDnsSupport: true
EnableDnsHostnames: true
Tags:
- Key: Name
Value: !Sub ${EnvironmentName}-vpc
InternetGateway:
Type: AWS::EC2::InternetGateway
Properties:
Tags:
- Key: Name
Value: !Sub ${EnvironmentName}-igw
IGWAttachment:
Type: AWS::EC2::VPCGatewayAttachment
Properties:
VpcId: !Ref VPC
InternetGatewayId: !Ref InternetGateway
# Public subnet — EC2 lives here
PublicSubnet:
Type: AWS::EC2::Subnet
Properties:
VpcId: !Ref VPC
CidrBlock: 10.0.1.0/24
AvailabilityZone: !Select [ 0, !GetAZs '' ]
MapPublicIpOnLaunch: true
Tags:
- Key: Name
Value: !Sub ${EnvironmentName}-public-subnet-az1
PublicRouteTable:
Type: AWS::EC2::RouteTable
Properties:
VpcId: !Ref VPC
Tags:
- Key: Name
Value: !Sub ${EnvironmentName}-public-rt
PublicRoute:
Type: AWS::EC2::Route
DependsOn: IGWAttachment
Properties:
RouteTableId: !Ref PublicRouteTable
DestinationCidrBlock: 0.0.0.0/0
GatewayId: !Ref InternetGateway
PublicSubnetRTAssociation:
Type: AWS::EC2::SubnetRouteTableAssociation
Properties:
SubnetId: !Ref PublicSubnet
RouteTableId: !Ref PublicRouteTable
# Private subnets — RDS requires at least 2 AZs for a subnet group
PrivateSubnetAZ1:
Type: AWS::EC2::Subnet
Properties:
VpcId: !Ref VPC
CidrBlock: 10.0.2.0/24
AvailabilityZone: !Select [ 0, !GetAZs '' ]
Tags:
- Key: Name
Value: !Sub ${EnvironmentName}-private-subnet-az1
PrivateSubnetAZ2:
Type: AWS::EC2::Subnet
Properties:
VpcId: !Ref VPC
CidrBlock: 10.0.3.0/24
AvailabilityZone: !Select [ 1, !GetAZs '' ]
Tags:
- Key: Name
Value: !Sub ${EnvironmentName}-private-subnet-az2
# ── SECURITY GROUPS ─────────────────────────
EC2SecurityGroup:
Type: AWS::EC2::SecurityGroup
Properties:
GroupDescription: EC2 dev instance — SSH inbound only
VpcId: !Ref VPC
SecurityGroupIngress:
- Description: SSH
IpProtocol: tcp
FromPort: 22
ToPort: 22
CidrIp: !Ref AllowedSSHCidr
SecurityGroupEgress:
- Description: Allow all outbound
IpProtocol: -1
CidrIp: 0.0.0.0/0
Tags:
- Key: Name
Value: !Sub ${EnvironmentName}-ec2-sg
RDSSecurityGroup:
Type: AWS::EC2::SecurityGroup
Properties:
GroupDescription: RDS dev instance — Postgres inbound from EC2 SG only
VpcId: !Ref VPC
SecurityGroupIngress:
- Description: Postgres from EC2
IpProtocol: tcp
FromPort: 5432
ToPort: 5432
SourceSecurityGroupId: !Ref EC2SecurityGroup
Tags:
- Key: Name
Value: !Sub ${EnvironmentName}-rds-sg
# ── EC2 ─────────────────────────────────────
EC2Instance:
Type: AWS::EC2::Instance
Properties:
InstanceType: t3.micro
# Resolves to the latest Amazon Linux 2023 AMI in whatever
# region this stack is deployed into — no hardcoded AMI IDs.
ImageId: !Sub ''
KeyName: !Ref KeyPairName
SubnetId: !Ref PublicSubnet
SecurityGroupIds:
- !Ref EC2SecurityGroup
# Root volume — default 8GB, left as-is.
# The 20GB data volume is attached separately below.
BlockDeviceMappings:
- DeviceName: /dev/xvda
Ebs:
VolumeType: gp3
VolumeSize: 8
DeleteOnTermination: true
Tags:
- Key: Name
Value: !Sub ${EnvironmentName}-ec2
- Key: Environment
Value: !Ref EnvironmentName
# ── EBS DATA VOLUME (20GB) ──────────────────
# Attached as a secondary block device at /dev/xvdf.
# After first boot you'll need to: mkfs, mount, and
# optionally add to /etc/fstab for persistence.
EBSDataVolume:
Type: AWS::EC2::Volume
DeletionPolicy: Snapshot # Snapshot before delete — change to Delete if preferred
Properties:
AvailabilityZone: !GetAtt EC2Instance.AvailabilityZone
Size: 20
VolumeType: gp3
Encrypted: true
Tags:
- Key: Name
Value: !Sub ${EnvironmentName}-data-volume
EBSVolumeAttachment:
Type: AWS::EC2::VolumeAttachment
Properties:
InstanceId: !Ref EC2Instance
VolumeId: !Ref EBSDataVolume
Device: /dev/xvdf
# ── RDS POSTGRES (DEV GRADE) ─────────────────
# Dev configuration:
# - db.t3.micro
# - Single-AZ (no standby)
# - No automated backups (retention = 0)
# - Deletion protection OFF
# - DeletionPolicy: Delete (stack teardown removes the DB)
# Do NOT use this config in production.
RDSSubnetGroup:
Type: AWS::RDS::DBSubnetGroup
Properties:
DBSubnetGroupDescription: !Sub ${EnvironmentName} dev RDS subnet group
SubnetIds:
- !Ref PrivateSubnetAZ1
- !Ref PrivateSubnetAZ2
Tags:
- Key: Name
Value: !Sub ${EnvironmentName}-rds-subnet-group
RDSInstance:
Type: AWS::RDS::DBInstance
DeletionPolicy: Delete # Stack delete drops the DB — intentional for dev
UpdateReplacePolicy: Delete
Properties:
DBInstanceIdentifier: !Sub ${EnvironmentName}-postgres
DBInstanceClass: db.t3.micro
Engine: postgres
EngineVersion: '16.3' # Update to latest available if this errors on deploy
MasterUsername: !Ref DBUsername
MasterUserPassword: !Ref DBPassword
AllocatedStorage: '20'
StorageType: gp2
MultiAZ: false # Single-AZ — dev only
PubliclyAccessible: false # Accessible from EC2 via SG rule above
DBSubnetGroupName: !Ref RDSSubnetGroup
VPCSecurityGroups:
- !Ref RDSSecurityGroup
BackupRetentionPeriod: 0 # Disable automated backups — dev only
DeletionProtection: false
StorageEncrypted: true
Tags:
- Key: Name
Value: !Sub ${EnvironmentName}-postgres
- Key: Environment
Value: !Ref EnvironmentName
# ─────────────────────────────────────────────
# OUTPUTS
# ─────────────────────────────────────────────
Outputs:
VPCId:
Description: VPC ID
Value: !Ref VPC
EC2InstanceId:
Description: EC2 Instance ID
Value: !Ref EC2Instance
EC2PublicIP:
Description: EC2 Public IP address
Value: !GetAtt EC2Instance.PublicIp
EBSVolumeId:
Description: EBS data volume ID (mount at /dev/xvdf after first boot)
Value: !Ref EBSDataVolume
RDSEndpoint:
Description: RDS Postgres endpoint hostname
Value: !GetAtt RDSInstance.Endpoint.Address
RDSPort:
Description: RDS Postgres port
Value: !GetAtt RDSInstance.Endpoint.Port
We can run it like this:
aws login
aws cloudformation estimate-template-cost \
--region us-west-2 \
--template-body file://devinfrasingle.yaml \
--parameters \
ParameterKey=KeyPairName,ParameterValue=your-keypair-name \
ParameterKey=DBUsername,ParameterValue=dbadmin \
ParameterKey=DBPassword,ParameterValue=YourSecurePassword123! \
ParameterKey=AllowedSSHCidr,ParameterValue=0.0.0.0/0 \
ParameterKey=EnvironmentName,ParameterValue=dev
And we get back a result like this

Unfortunately the estimate-template-cost function works per region, so if you have a complex solution
with multi-region footprint you need to run the same process a few times with a different region each time. You can wrap it easily enough:
for REGION in us-west-2 ap-southeast-2 eu-west-1; do
echo "=== $REGION ==="
aws cloudformation estimate-template-cost \
--region $REGION \
--template-body file://devinframulti.yaml \
--parameters \
ParameterKey=KeyPairName,ParameterValue=your-keypair-name \
ParameterKey=DBUsername,ParameterValue=dbadmin \
ParameterKey=DBPassword,ParameterValue=YourSecurePassword123! \
ParameterKey=AllowedSSHCidr,ParameterValue=0.0.0.0/0 \
ParameterKey=EnvironmentName,ParameterValue=dev \
--query 'Url' \
--output text
done
In revisiting this we discovered there’s a bug in the new PartnerCentral Estimator which means you can’t import CloudFormation-based Workload Cost Estimates from calculator.aws. We’ve reported it in detail and presume it will be fixed eventually (it seems like this was a use case missed in testing).
If you’re working with a solution architect as you develop opportunities for customers, or if you’re wondering what the cost will be for an internal project, there’s an advantage to taking the CloudFormation route that pays off twice - most people know that IaC = ability to deploy infrastructure from a script, repeatedly, reliably. A smaller number know you can use the same script to work out the likely cost.