AWS Managed Prefix Lists with Terraform - Stop Hardcoding CIDRs
Every time your data centre team updates an IP range, you’re editing security groups across 15 AWS accounts. Every time AWS adds a new CloudFront edge location, your WAF rules are out of date. Every time a partner VPN changes their egress IPs, someone’s scrambling to update firewall rules.
This is what happens when you hardcode CIDR blocks.
AWS Managed Prefix Lists solve this by letting you define a set of CIDR blocks once and reference them everywhere. Change the prefix list, and every security group, route table, and network ACL using it updates automatically.
This post covers how to use prefix lists in production - both AWS-managed ones (for services like S3 and CloudFront) and customer-managed ones (for your data centres, partners, and custom IP ranges).
TL;DR
- Prefix lists are reusable collections of CIDR blocks
- AWS-managed prefix lists track AWS service IPs automatically
- Customer-managed prefix lists centralise your own IP ranges
- Reference them in security groups, route tables, and NACLs
- One change propagates everywhere - no more CIDR sprawl
Code Repository: All code from this post is available at github.com/moabukar/blog-code/aws-managed-prefix-lists
The Problem with Hardcoded CIDRs
Here’s what most security groups look like:
# The CIDR nightmare
resource "aws_security_group_rule" "allow_office" {
type = "ingress"
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = [
"203.0.113.0/24", # London office
"198.51.100.0/24", # New York office
"192.0.2.0/24", # Singapore office
"10.100.0.0/16", # Data centre 1
"10.200.0.0/16", # Data centre 2
"172.16.50.0/24", # Partner VPN
]
security_group_id = aws_security_group.app.id
}
Problems:
- Duplicated everywhere - Same CIDRs in 50 security groups across 10 accounts
- Change management nightmare - Office moves? Update every security group manually
- No audit trail - Which security groups have the old office IP?
- AWS service IPs change - CloudFront, S3, DynamoDB edge locations update constantly
- Quota limits - Security groups have a limit on rules; each CIDR counts as a rule
What Are Prefix Lists?
A prefix list is a set of CIDR blocks with a name. You reference the prefix list ID instead of individual CIDRs.
# Instead of this:
cidr_blocks = ["10.100.0.0/16", "10.200.0.0/16"]
# You use this:
prefix_list_ids = [aws_ec2_managed_prefix_list.datacentres.id]
There are two types:
1. AWS-Managed Prefix Lists
AWS maintains these automatically. They contain IP ranges for AWS services:
| Prefix List | Contains |
|---|---|
com.amazonaws.region.s3 | S3 gateway endpoint IPs |
com.amazonaws.region.dynamodb | DynamoDB gateway endpoint IPs |
com.amazonaws.global.cloudfront.origin-facing | CloudFront edge IPs |
When AWS adds new edge locations or changes IPs, the prefix list updates automatically. Your security groups stay current without any changes.
2. Customer-Managed Prefix Lists
You create and maintain these. Perfect for:
- Corporate data centre IP ranges
- Office network CIDRs
- Partner/vendor IPs
- On-premises network ranges
- VPN endpoint IPs
Using AWS-Managed Prefix Lists
S3 Gateway Endpoint
When you create a VPC endpoint for S3, AWS automatically creates a prefix list. Use it in route tables:
# Create S3 VPC endpoint
resource "aws_vpc_endpoint" "s3" {
vpc_id = aws_vpc.main.id
service_name = "com.amazonaws.${var.region}.s3"
route_table_ids = [
aws_route_table.private.id
]
}
# The endpoint automatically adds a route using the S3 prefix list
# You can also reference it in security groups:
data "aws_prefix_list" "s3" {
name = "com.amazonaws.${var.region}.s3"
}
resource "aws_security_group_rule" "allow_s3" {
type = "egress"
from_port = 443
to_port = 443
protocol = "tcp"
prefix_list_ids = [data.aws_prefix_list.s3.id]
security_group_id = aws_security_group.app.id
description = "Allow HTTPS to S3"
}
CloudFront Origin-Facing IPs
CloudFront has hundreds of edge locations. The IP ranges change frequently. Use the managed prefix list:
# Get CloudFront prefix list
data "aws_ec2_managed_prefix_list" "cloudfront" {
name = "com.amazonaws.global.cloudfront.origin-facing"
}
# Allow CloudFront to reach your origin
resource "aws_security_group_rule" "allow_cloudfront" {
type = "ingress"
from_port = 443
to_port = 443
protocol = "tcp"
prefix_list_ids = [data.aws_ec2_managed_prefix_list.cloudfront.id]
security_group_id = aws_security_group.alb.id
description = "Allow HTTPS from CloudFront"
}
Why this matters: CloudFront has 400+ edge locations. Without the prefix list, you’d need 400+ security group rules (impossible - the limit is 60 rules per security group). With the prefix list, it’s one rule.
DynamoDB Gateway Endpoint
Same pattern as S3:
data "aws_prefix_list" "dynamodb" {
name = "com.amazonaws.${var.region}.dynamodb"
}
resource "aws_security_group_rule" "allow_dynamodb" {
type = "egress"
from_port = 443
to_port = 443
protocol = "tcp"
prefix_list_ids = [data.aws_prefix_list.dynamodb.id]
security_group_id = aws_security_group.app.id
description = "Allow HTTPS to DynamoDB"
}
Creating Customer-Managed Prefix Lists
For your own IP ranges, create customer-managed prefix lists.
Basic Prefix List
resource "aws_ec2_managed_prefix_list" "corporate_offices" {
name = "corporate-offices"
address_family = "IPv4"
max_entries = 20
entry {
cidr = "203.0.113.0/24"
description = "London HQ"
}
entry {
cidr = "198.51.100.0/24"
description = "New York office"
}
entry {
cidr = "192.0.2.0/24"
description = "Singapore office"
}
tags = {
Name = "corporate-offices"
Environment = "shared"
ManagedBy = "terraform"
}
}
Data Centre Prefix List
resource "aws_ec2_managed_prefix_list" "datacentres" {
name = "on-premises-datacentres"
address_family = "IPv4"
max_entries = 50
entry {
cidr = "10.100.0.0/16"
description = "DC1 - London"
}
entry {
cidr = "10.200.0.0/16"
description = "DC2 - Frankfurt"
}
entry {
cidr = "10.150.0.0/16"
description = "DR Site - Dublin"
}
tags = {
Name = "on-premises-datacentres"
Environment = "shared"
ManagedBy = "terraform"
}
}
Partner/Vendor Prefix List
resource "aws_ec2_managed_prefix_list" "partners" {
name = "trusted-partners"
address_family = "IPv4"
max_entries = 30
entry {
cidr = "172.16.50.0/24"
description = "Partner A - VPN egress"
}
entry {
cidr = "172.16.60.0/24"
description = "Partner B - API servers"
}
entry {
cidr = "203.0.113.128/25"
description = "Vendor C - Monitoring"
}
tags = {
Name = "trusted-partners"
Environment = "shared"
ManagedBy = "terraform"
}
}
Using Prefix Lists in Security Groups
Ingress from Corporate Offices
resource "aws_security_group" "bastion" {
name = "bastion-sg"
description = "Bastion host security group"
vpc_id = aws_vpc.main.id
tags = {
Name = "bastion-sg"
}
}
resource "aws_security_group_rule" "bastion_ssh_offices" {
type = "ingress"
from_port = 22
to_port = 22
protocol = "tcp"
prefix_list_ids = [aws_ec2_managed_prefix_list.corporate_offices.id]
security_group_id = aws_security_group.bastion.id
description = "SSH from corporate offices"
}
resource "aws_security_group_rule" "bastion_ssh_datacentres" {
type = "ingress"
from_port = 22
to_port = 22
protocol = "tcp"
prefix_list_ids = [aws_ec2_managed_prefix_list.datacentres.id]
security_group_id = aws_security_group.bastion.id
description = "SSH from data centres"
}
Application Load Balancer with CloudFront
resource "aws_security_group" "alb" {
name = "alb-sg"
description = "ALB security group - CloudFront origin"
vpc_id = aws_vpc.main.id
}
# Only allow traffic from CloudFront (not direct internet)
resource "aws_security_group_rule" "alb_https_cloudfront" {
type = "ingress"
from_port = 443
to_port = 443
protocol = "tcp"
prefix_list_ids = [data.aws_ec2_managed_prefix_list.cloudfront.id]
security_group_id = aws_security_group.alb.id
description = "HTTPS from CloudFront only"
}
# Egress to anywhere
resource "aws_security_group_rule" "alb_egress" {
type = "egress"
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
security_group_id = aws_security_group.alb.id
description = "Allow all outbound"
}
Database with Restricted Access
resource "aws_security_group" "database" {
name = "database-sg"
description = "Database security group"
vpc_id = aws_vpc.main.id
}
# From application servers (security group reference)
resource "aws_security_group_rule" "db_from_app" {
type = "ingress"
from_port = 5432
to_port = 5432
protocol = "tcp"
source_security_group_id = aws_security_group.app.id
security_group_id = aws_security_group.database.id
description = "PostgreSQL from app servers"
}
# From data centres (for DB admin tools)
resource "aws_security_group_rule" "db_from_datacentres" {
type = "ingress"
from_port = 5432
to_port = 5432
protocol = "tcp"
prefix_list_ids = [aws_ec2_managed_prefix_list.datacentres.id]
security_group_id = aws_security_group.database.id
description = "PostgreSQL from data centres"
}
Using Prefix Lists in Route Tables
Prefix lists work in route tables too - useful for routing traffic through VPN or Transit Gateway:
# Route data centre traffic through Transit Gateway
resource "aws_route" "to_datacentres" {
route_table_id = aws_route_table.private.id
destination_prefix_list_id = aws_ec2_managed_prefix_list.datacentres.id
transit_gateway_id = aws_ec2_transit_gateway.main.id
}
# Route partner traffic through VPN
resource "aws_route" "to_partners" {
route_table_id = aws_route_table.private.id
destination_prefix_list_id = aws_ec2_managed_prefix_list.partners.id
vpn_gateway_id = aws_vpn_gateway.main.id
}
Sharing Prefix Lists Across Accounts
In a multi-account setup, create prefix lists in a shared networking account and share them via AWS RAM:
# In the networking account
resource "aws_ec2_managed_prefix_list" "corporate" {
name = "corporate-networks"
address_family = "IPv4"
max_entries = 100
# ... entries ...
}
# Share via RAM
resource "aws_ram_resource_share" "prefix_lists" {
name = "shared-prefix-lists"
allow_external_principals = false
}
resource "aws_ram_resource_association" "corporate" {
resource_arn = aws_ec2_managed_prefix_list.corporate.arn
resource_share_arn = aws_ram_resource_share.prefix_lists.arn
}
resource "aws_ram_principal_association" "org" {
principal = "arn:aws:organizations::${var.org_master_account_id}:organization/${var.org_id}"
resource_share_arn = aws_ram_resource_share.prefix_lists.arn
}
In other accounts, reference the shared prefix list:
# In a workload account
data "aws_ec2_managed_prefix_list" "corporate" {
filter {
name = "prefix-list-name"
values = ["corporate-networks"]
}
}
resource "aws_security_group_rule" "allow_corporate" {
type = "ingress"
from_port = 443
to_port = 443
protocol = "tcp"
prefix_list_ids = [data.aws_ec2_managed_prefix_list.corporate.id]
security_group_id = aws_security_group.app.id
}
Dynamic Prefix Lists from Variables
For flexibility, build prefix lists from Terraform variables:
variable "office_cidrs" {
description = "Office network CIDR blocks"
type = map(object({
cidr = string
description = string
}))
default = {
london = {
cidr = "203.0.113.0/24"
description = "London HQ"
}
new_york = {
cidr = "198.51.100.0/24"
description = "New York office"
}
}
}
resource "aws_ec2_managed_prefix_list" "offices" {
name = "corporate-offices"
address_family = "IPv4"
max_entries = length(var.office_cidrs) + 10 # Room for growth
dynamic "entry" {
for_each = var.office_cidrs
content {
cidr = entry.value.cidr
description = entry.value.description
}
}
tags = {
Name = "corporate-offices"
ManagedBy = "terraform"
}
}
Prefix List Versioning
AWS tracks prefix list versions. When you update entries, the version increments:
# Get current version
data "aws_ec2_managed_prefix_list" "corporate" {
id = aws_ec2_managed_prefix_list.corporate.id
}
output "prefix_list_version" {
value = data.aws_ec2_managed_prefix_list.corporate.version
}
This is useful for:
- Auditing changes
- Rolling back if needed
- Tracking when IPs were added/removed
Important Considerations
1. Max Entries
Set max_entries higher than your current count to allow growth without recreation:
resource "aws_ec2_managed_prefix_list" "offices" {
name = "offices"
max_entries = 50 # Even if you only have 5 offices today
# ...
}
Note: You can increase max_entries but cannot decrease it below current entry count.
2. Security Group Rule Limits
Each prefix list counts as one rule toward the security group limit, regardless of how many CIDRs it contains. This is a huge benefit.
| Approach | Rules Used |
|---|---|
| 50 individual CIDRs | 50 rules |
| 1 prefix list with 50 CIDRs | 1 rule |
3. Prefix List Size Limits
- Maximum 1000 entries per prefix list
- Maximum 100 prefix lists per region
- Prefix lists count toward VPC quota
4. Cross-Region
Prefix lists are regional. For multi-region deployments, either:
- Create identical prefix lists in each region
- Use Terraform modules to ensure consistency
module "prefix_lists" {
source = "./modules/prefix-lists"
for_each = toset(["eu-west-1", "us-east-1", "ap-southeast-1"])
providers = {
aws = aws.regional[each.key]
}
office_cidrs = var.office_cidrs
}
Complete Example: Production Security Group Module
Here’s a production-ready module that uses prefix lists:
# modules/app-security-group/main.tf
variable "name" {
type = string
}
variable "vpc_id" {
type = string
}
variable "prefix_list_ids" {
description = "Prefix lists for ingress access"
type = object({
offices = string
datacentres = string
partners = optional(string)
})
}
variable "app_port" {
type = number
default = 8080
}
data "aws_ec2_managed_prefix_list" "cloudfront" {
name = "com.amazonaws.global.cloudfront.origin-facing"
}
resource "aws_security_group" "this" {
name = var.name
description = "Security group for ${var.name}"
vpc_id = var.vpc_id
tags = {
Name = var.name
}
}
# HTTPS from offices
resource "aws_security_group_rule" "https_offices" {
type = "ingress"
from_port = 443
to_port = 443
protocol = "tcp"
prefix_list_ids = [var.prefix_list_ids.offices]
security_group_id = aws_security_group.this.id
description = "HTTPS from corporate offices"
}
# HTTPS from data centres
resource "aws_security_group_rule" "https_datacentres" {
type = "ingress"
from_port = 443
to_port = 443
protocol = "tcp"
prefix_list_ids = [var.prefix_list_ids.datacentres]
security_group_id = aws_security_group.this.id
description = "HTTPS from data centres"
}
# HTTPS from CloudFront (if fronted by CDN)
resource "aws_security_group_rule" "https_cloudfront" {
type = "ingress"
from_port = 443
to_port = 443
protocol = "tcp"
prefix_list_ids = [data.aws_ec2_managed_prefix_list.cloudfront.id]
security_group_id = aws_security_group.this.id
description = "HTTPS from CloudFront"
}
# App port from partners (optional)
resource "aws_security_group_rule" "app_partners" {
count = var.prefix_list_ids.partners != null ? 1 : 0
type = "ingress"
from_port = var.app_port
to_port = var.app_port
protocol = "tcp"
prefix_list_ids = [var.prefix_list_ids.partners]
security_group_id = aws_security_group.this.id
description = "App port from partners"
}
# Egress
resource "aws_security_group_rule" "egress" {
type = "egress"
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
security_group_id = aws_security_group.this.id
}
output "security_group_id" {
value = aws_security_group.this.id
}
Usage:
module "app_sg" {
source = "./modules/app-security-group"
name = "my-application"
vpc_id = aws_vpc.main.id
prefix_list_ids = {
offices = aws_ec2_managed_prefix_list.offices.id
datacentres = aws_ec2_managed_prefix_list.datacentres.id
partners = aws_ec2_managed_prefix_list.partners.id
}
}
Key Takeaways
- Stop hardcoding CIDRs - Use prefix lists for any IP range referenced multiple times
- Use AWS-managed prefix lists - For S3, DynamoDB, CloudFront - they update automatically
- Create customer-managed prefix lists - For offices, data centres, partners
- Share across accounts - Use RAM to share prefix lists organisation-wide
- One rule, many CIDRs - Prefix lists help you stay under security group rule limits
- Version tracking - AWS tracks changes for audit purposes
Stop editing security groups when IPs change. Define once, reference everywhere.