← Back to list

WebSockets Through AWS CloudFront VPC Origins: Keeping Your Load Balancer Private

For a long time, exposing a real-time backend on Amazon EC2 or Amazon Elastic Container Services (ECS) behind Amazon CloudFront meant…

Cedric De Schepper in Towards AWS · 2026-07-06 04:35 · 2 claps · 6.1 min read
#aws #cloud-computing #networking #cloud
Open on Medium ↗
Wiki topics: 🌐 · Web Development ☁️ · DevOps & Cloud 🔒 · Cybersecurity

WebSockets Through AWS CloudFront VPC Origins: Keeping Your Load Balancer Private

For a long time, exposing a real-time backend on Amazon EC2 or Amazon Elastic Container Services (ECS) behind Amazon CloudFront meant exposing your AWS Application Load Balancer (ALB) publicly and locking it down with a Web Application Firewall (WAF), security groups, and/or a shared secret header.

When AWS released CloudFront VPC origins to support origins with private resources, I wrote about migrating a central ingress hub to leverage VPC Origins. However, this initially didn’t support the WebSocket protocol. With its update in March 2026, you can now leverage VPC Origins to expose your application through a private load balancer.

In this post, I’ll quickly revisit what CloudFront VPC Origins are and share a demo I built to demonstrate this new capability.

What is a CloudFront VPC Origin?

A VPC Origin lets a CloudFront distribution send traffic directly to a resource inside your VPC, an internal ALB, AWS Network Load Balancer (NLB), or an Amazon EC2 instance , without that resource being reachable from the public internet.

Practically, AWS provisions Elastic Network Interfaces (ENIs) inside your VPC that CloudFront uses as its egress points. On top of that, it automatically creates a service-managed security group with the naming pattern CloudFront-VPCOrigins-Service-SGthat your origin can use to accept traffic from those ENIs.

The benefits are:

  • Private load balancer. Your ALB is internal. This results in no public IPs incurring costs and no internet gateway routing being required.
  • Smaller attack surface. The only resource exposed on the internet is your CloudFront distribution.
  • CloudFront enforcement: Public traffic can only enter through CloudFront without requiring additional enforcement configurations such as custom headers.

What’s new: WebSockets

CloudFront has supported WebSockets for years on regular (public) origins. The new feature is that WebSockets now work through VPC Origins, so you can put a real-time service behind a fully private ALB.

There’s one small thing you have to do to make it work, and it’s the same thing you’d do for a public origin:

WebSocket functionality is automatically enabled to work with any distribution. To use WebSockets, configure one of the following in the cache behavior that’s attached to your distribution:

  • Specifically forward the Sec-WebSocket-Key and Sec-WebSocket-Version request headers in your origin request policy.

Testing the feature: a chat app

To prove this end-to-end I built a deliberately small demo: a Node.js WebSocket server hosted on AWS ECS fronted by CloudFront and an Application Load Balancer.

The Architecture

                ┌──────────────┐
   Browser ──>  │  CloudFront  │  HTTPS (TLS terminated here)
                │ Distribution │
                └──────┬───────┘
                       │  VPC Origin (HTTP:80)
                       │  via managed ENIs in your VPC
                       ▼
                ┌──────────────┐
                │ Internal ALB │  (no public IP)
                └──────┬───────┘
                       │  HTTP:8080
                       ▼
                ┌──────────────┐
                │ ECS Fargate  │  Node.js + ws
                │   tasks      │
                └──────────────┘

Key properties:

  • The ALB is internal. It has no public listener and is unreachable from outside the VPC.
  • The ECS service runs a tiny Node.js app using the [ws](https://github.com/websockets/ws) library. It broadcasts every incoming message to all connected clients.
  • CloudFront sits in front and is the only public resource in the deployment stack.

What you need to configure

There are four key pieces that have to be configured correctly to make this work.

1. A private Application Load Balancer

resource "aws_lb" "this" {
  name               = "${var.name}-alb"
  internal           = true
  load_balancer_type = "application"
  security_groups    = [aws_security_group.alb.id]
  subnets            = aws_subnet.private[*].id
  idle_timeout       = 3600
  tags               = { Name = var.name }
}

Note the idle_timeout = 3600 . The ALB’s default idle timeout is 60 seconds, which is fine for short-lived HTTP requests but will silently close any WebSocket connection that goes a minute without traffic. For a long-lived connection you want this raised (3600s / 1 hour is the maximum) and you typically pair it with an application-level ping/pong to keep the connection healthy and detect dead peers. CloudFront’s own idle timeout for WebSockets is 10 minutes and is not configurable, so application-level keepalives are required regardless of the ALB setting.

2. The VPC Origin

resource "aws_cloudfront_vpc_origin" "alb" {
  vpc_origin_endpoint_config {
    name                   = "ws-vpco-demo-alb"
    arn                    = aws_lb.this.arn
    http_port              = 80
    https_port             = 443
    origin_protocol_policy = "http-only"
    origin_ssl_protocols {
      items    = ["TLSv1.2"]
      quantity = 1
    }
  }
}

3. The cache behavior for the WebSocket

You need an origin request policy that forwards the WebSocket handshake headers. The easiest solution for the demo is using the managed AllViewer policy:

default_cache_behavior {
  target_origin_id       = "alb-vpco"
  viewer_protocol_policy = "redirect-to-https"
  allowed_methods        = ["GET", "HEAD", "OPTIONS", "PUT", "POST", "PATCH", "DELETE"]
  cached_methods         = ["GET", "HEAD"]
  # Managed-CachingDisabled - WebSockets aren't cacheable
  cache_policy_id          = "4135ea2d-6df8-44a3-9df3-4b5a84be39ad"
  # Managed-AllViewer - forwards Sec-WebSocket-Key, Sec-WebSocket-Version, etc.
  origin_request_policy_id = "216adef6-5c7f-47e4-b989-5492eafa07d3"
}

4. Security groups

When you create a VPC Origin, AWS provisions a managed security group in your VPC named CloudFront-VPCOrigins-Service-SG. Your ALB's security group needs to allow ingress from that SG on the ports CloudFront talks to.

data "aws_security_group" "cloudfront_vpc_origins" {
  filter {
    name   = "group-name"
    values = ["CloudFront-VPCOrigins-Service-SG"]
  }
  filter {
    name   = "vpc-id"
    values = [aws_vpc.this.id]
  }
  depends_on = [aws_cloudfront_vpc_origin.alb]
}

resource "aws_vpc_security_group_ingress_rule" "alb_from_cf_http" {
  security_group_id            = aws_security_group.alb.id
  referenced_security_group_id = data.aws_security_group.cloudfront_vpc_origins.id
  ip_protocol                  = "tcp"
  from_port                    = 80
  to_port                      = 80
}

The app itself

The app is a basic chat window showcasing the different messages in present in the system:

const WebSocket = require("ws");
const wss = new WebSocket.Server({ server, path: "/ws" });

wss.on("connection", (ws) => {
  ws.send(JSON.stringify({ type: "welcome", clients: wss.clients.size }));

  ws.on("message", (data) => {
    const msg = JSON.parse(data);
    for (const client of wss.clients) {
      if (client.readyState === WebSocket.OPEN) {
        client.send(JSON.stringify({ type: "chat", text: msg.text }));
      }
    }
  });
});

A note on scaling this demo:

The demo runs a single ECS task (desired_count = 1), which keeps things simple: every WebSocket connection lands on the same container, so any in-memory state (the set of connected clients, recent messages, etc.) is implicitly shared.

A production-grade deployment would run multiple tasks behind the ALB for availability and horizontal scale, and that changes the picture in two ways:

  1. Connection affinity: A WebSocket is a long-lived TCP connection pinned to one task. A client that connects through the ALB stays on whichever task answered the upgrade request, but two clients in the same “chat room” can easily land on different tasks.
  2. Shared session state: Because tasks no longer see each other’s clients, broadcasting a message to wss.clients only reaches the subset connected to the local task. To fan out across tasks you need an out-of-process backplane, for example Amazon ElastiCache (Redis) Pub/Sub, Amazon SNS, or Amazon MQ, where each task subscribes and republishes incoming messages to its own local clients. Any session data that must survive a reconnect (which may land on a different task) belongs in a shared store such as Amazon DynamoDB or ElastiCache rather than in process memory.

None of this is specific to VPC Origins, it’s the standard “sticky, stateful protocol behind a horizontally scaled service” problem, but it’s worth calling out so the demo isn’t mistaken for a production blueprint.

The result

When opening two browsers tabs connected to CloudFront, we can verify that the WebSocket connection is properly working by sending and receiving messages through the same connection.

We can also review the network properties through your browser inspection (Ctrl+Shift+C). In the Network tab, filter on WS and open the /ws request. A single, long-lived connection in the Messages / Frames sub-tab, with each chat message appearing as an individual frame in both directions, rather than a new HTTP request per message, confirms that the WebSocket connection is active and working.

While not the most eye-catching feature release, every small improvement that impacts both your cost and security footprint is worth the implementation.


메타데이터
post_id
e9fd222552dc
slug
websockets-through-aws-cloudfront-vpc-origins-keeping-your-load-balancer-private-e9fd222552dc
url
https://towardsaws.com/websockets-through-aws-cloudfront-vpc-origins-keeping-your-load-balancer-private-e9fd222552dc
canonical_url
https://towardsaws.com/websockets-through-aws-cloudfront-vpc-origins-keeping-your-load-balancer-private-e9fd222552dc
author_url
https://medium.com/@cedric.deschepper
status
ok
fetched_at
2026-07-08 21:56:07