Exposing Private Load Balancers with CloudFront VPC Origins

Let's explore CloudFront VPC Origins, an AWS feature that allows you to connect CloudFront directly to private resources in your VPC without exposing them to the Internet. In this article, we'll see why this matters and how you can implement it using Terraform.
Introduction: Why Keep Your Load Balancers Private?
When building web applications on AWS, we've traditionally needed public load balancers so CloudFront could reach our origin servers. This presented several challenges:
- Your load balancer is exposed to the world - Even with tight security groups, a public load balancer increases your application's attack surface
- Maintaining IP allow lists is a pain - The traditional approach requires whitelisting CloudFront's IP ranges in your security groups, which change periodically and require updates
- Secret headers aren't secure enough - Some try using secret headers as an alternative to IP whitelisting, but this "security by obscurity" approach can be discovered and exploited
- Direct access bypassing - Attackers might discover your load balancer's public endpoint and bypass CloudFront entirely, circumventing any protections you've set up there
CloudFront VPC Origins provides a better solution. It creates a direct, secure connection between CloudFront and resources in your private subnets, keeping your load balancers completely hidden from the public internet while still being accessible through CloudFront.
The Architecture
Before diving into implementation details, let's visualize how CloudFront VPC Origins creates a secure architecture. The diagram below illustrates the end-to-end flow from internet users to your private resources, showing how CloudFront VPC Origins bridges the gap between the public internet and your private VPC without exposing your infrastructure:
Practical Implementation with Terraform
Let's implement this using Terraform, based on our Medusa.js AWS module. We'll go through this in a logical order based on dependencies.
1. Setting Up the Private Load Balancer
First, we create a load balancer in private subnets:
resource "aws_lb" "main" { load_balancer_type = "application" # Default value subnets = var.vpc.private_subnet_ids # The key part - using private subnets! security_groups = [aws_security_group.lb.id] name = "${local.prefix}-lb" tags = local.tags } resource "aws_lb_target_group" "main" { port = 9000 # Default container port for Medusa backend protocol = "HTTP" vpc_id = var.vpc.id target_type = "ip" name = "${local.prefix}-tg" health_check { protocol = "HTTP" port = 9000 interval = 30 matcher = "200" timeout = 3 path = "/health" healthy_threshold = 3 unhealthy_threshold = 3 } tags = local.tags } resource "aws_lb_listener" "main" { load_balancer_arn = aws_lb.main.arn port = 80 protocol = "HTTP" default_action { type = "forward" forward { target_group { arn = aws_lb_target_group.main.arn } } } lifecycle { replace_triggered_by = [aws_lb_target_group.main] } tags = local.tags }
2. Creating the Security Groups
Next, we set up security groups that only allow traffic from CloudFront VPC Origins:
# Using AWS-managed prefix list for CloudFront VPC Origins data "aws_ec2_managed_prefix_list" "vpc_origin" { name = "com.amazonaws.global.cloudfront.origin-facing" } resource "aws_security_group" "lb" { name_prefix = "${local.prefix}-lb-" description = "Allow inbound traffic from CloudFront VPC Origins" vpc_id = var.vpc.id tags = local.tags lifecycle { create_before_destroy = true } } resource "aws_vpc_security_group_ingress_rule" "vpc_origin" { security_group_id = aws_security_group.lb.id prefix_list_id = data.aws_ec2_managed_prefix_list.vpc_origin.id from_port = 80 to_port = 80 ip_protocol = "tcp" tags = local.tags } resource "aws_vpc_security_group_egress_rule" "lb" { security_group_id = aws_security_group.lb.id cidr_ipv4 = "0.0.0.0/0" ip_protocol = "-1" tags = local.tags }
This is where the magic happens - instead of maintaining our own list of CloudFront IPs, we use AWS's managed prefix list com.amazonaws.global.cloudfront.origin-facing
. AWS keeps this updated automatically, so you don't have to worry about it.
3. Setting Up the VPC Origin Connection
Now, we create the CloudFront VPC Origin that connects to our private load balancer:
locals { origin_id = "${local.prefix}-lb" } resource "aws_cloudfront_vpc_origin" "main" { vpc_origin_endpoint_config { name = local.origin_id arn = aws_lb.main.arn http_port = 80 https_port = 443 origin_protocol_policy = "http-only" origin_ssl_protocols { quantity = 1 items = ["TLSv1.2"] } } timeouts { create = "30m" # Important: VPC Origins take time to provision } depends_on = [aws_lb_target_group.main, aws_security_group.lb] tags = local.tags }
Note: CloudFront VPC Origins can take a while to provision, and without extended timeout, Terraform might give up too early.
4. Configuring the CloudFront Distribution
Finally, we set up the CloudFront distribution to use our VPC Origin:
resource "aws_cloudfront_distribution" "main" { enabled = true comment = "My-Project-Dev-Backend" # Example of what title(local.prefix) might render origin { domain_name = aws_lb.main.dns_name origin_id = local.origin_id vpc_origin_config { vpc_origin_id = aws_cloudfront_vpc_origin.main.id } } # Default cache behavior default_cache_behavior { target_origin_id = local.origin_id viewer_protocol_policy = "redirect-to-https" # Cache settings for APIs - disabled to allow dynamic content min_ttl = 0 default_ttl = 0 max_ttl = 0 forwarded_values { query_string = true headers = ["*"] cookies { forward = "all" } } allowed_methods = ["GET", "HEAD", "POST", "PUT", "PATCH", "OPTIONS", "DELETE"] cached_methods = ["GET", "HEAD", "OPTIONS"] } # Certificate configuration viewer_certificate { cloudfront_default_certificate = true # Use CloudFront's default certificate } # Other settings price_class = "PriceClass_100" # Use only North America and Europe edge locations restrictions { geo_restriction { restriction_type = "none" } } tags = { Project = "my-project" Environment = "dev" Owner = "DevOps Team" ManagedBy = "terraform" Component = "Backend" } }
The vpc_origin_config block
that references our VPC Origin is the key difference from a traditional CloudFront setup.
Conclusion: Solving Real Security Challenges
CloudFront VPC Origins represents a significant advancement for securing web applications on AWS, directly addressing the security challenges we outlined at the beginning:
Remember the problem of exposed load balancers? With VPC Origins, your load balancers now remain completely isolated in private subnets, dramatically reducing your attack surface. The headache of maintaining IP allowlists is eliminated through AWS's managed prefix list com.amazonaws.global.cloudfront.origin-facing
, which is automatically maintained for you.
Insecure secret header approaches are no longer needed, as the direct connection between CloudFront and your VPC resources provides a much stronger security model. And attackers trying to bypass CloudFront? With your load balancer in a private subnet, there's simply no way for external traffic to reach it except through CloudFront.
There are a few practical considerations: CloudFront VPC Origins take time to provision, each region needs its own VPC Origin, there's a cost for the connectivity, and setup is slightly more complex. However, for organizations handling sensitive data or with compliance requirements, these minor considerations are easily outweighed by the significant security benefits.