,

Build a Dual-Stack VPC Origin for CloudFront, Step by Step

Workbench with a laptop and a model data center rack behind glass connected by two cables

Since October 15, 2026, you can build a dual-stack VPC origin and have CloudFront reach a private load balancer over both IPv4 and IPv6, with no public address anywhere on the origin side. In this walkthrough you will stand up the whole thing from an empty account: a VPC with IPv6, an internal dual-stack ALB, a CloudFront VPC origin, a distribution, and proof that IPv6 traffic is really flowing.

We will use CloudFormation for the resources and the AWS CLI for the glue and the checks. The template is built in pieces so you can see why each part exists. It is the same shape as the Classmethod walkthrough of the feature, adapted so that both address families are allowed, which is what production needs. The background lives in the AWS announcement; here we just build.

Prerequisites

  • An AWS account and a user or role that can create VPC, EC2, load balancing, CloudFront, and CloudFormation resources.
  • AWS CLI v2, configured, and a Region where CloudFront VPC origins are supported. This guide uses us-east-1 as an example.
  • A shell with curl and a text editor. Nothing else needs installing.
  • Ten minutes of patience for the CloudFront deployment, which is the slowest part by a wide margin.
  • An understanding that this builds real resources. The load balancer costs money while it exists, so do the cleanup at the end.

How the pieces fit together

Before typing anything, get the picture straight, because the most common mistakes here are mental-model mistakes, not syntax mistakes. A viewer connects to a CloudFront edge location over IPv4 or IPv6, depending on their network and your distribution settings. CloudFront then opens a connection to your origin. With a VPC origin, that connection does not cross the public internet to a public address. Instead, CloudFront uses a network interface that the service itself creates inside your private subnet, and traffic from CloudFront to your load balancer flows through it.

That interface is why the subnet needs spare addresses, and why dual-stack asks for a spare IPv6 address on top of the usual IPv4 one. It is also why your security group rules matter so much. The load balancer sees connections coming from CloudFront ranges, so the allow rules reference the CloudFront origin-facing prefix lists instead of any address you own. Get those rules right and the load balancer is reachable by CloudFront and nobody else.

Finally, keep the two halves of the dual-stack idea separate. One half is the origin side: the load balancer has IPv6 addresses and the VPC origin is marked dual-stack. The other half is the viewer side: the distribution is IPv6 enabled. You can have either without the other. This tutorial turns on both so you can see the whole path, but in your own environment you may roll them out on different days, and that is fine. The CloudFront IPv6 documentation covers the viewer side in detail.

Build the dual-stack VPC origin

The plan: network first, then security, then the load balancer, then the CloudFront pieces. Each step appends to one template file. We keep the content trivial, a fixed response from the ALB, because the interesting part is the path, not the payload.

Step 1: Set your variables and find the prefix lists

CloudFront publishes two AWS-managed prefix lists for traffic leaving the edge toward your origin: one for IPv4 and one for IPv6. A dual-stack origin needs both on its security group, and the IDs differ per Region, so we look them up rather than hard-coding anything. If you only allow one list, roughly half your requests will fail depending on which family CloudFront picks, and the symptom is intermittent errors that make you question your sanity.

export AWS_REGION=us-east-1
export STACK=vpco-dualstack

V4_PL=$(aws ec2 describe-managed-prefix-lists \
  --filters Name=prefix-list-name,Values=com.amazonaws.global.cloudfront.origin-facing \
  --query 'PrefixLists[0].PrefixListId' --output text)

V6_PL=$(aws ec2 describe-managed-prefix-lists \
  --filters Name=prefix-list-name,Values=com.amazonaws.global.ipv6.cloudfront.origin-facing \
  --query 'PrefixLists[0].PrefixListId' --output text)

echo "$V4_PL $V6_PL"

You should see two IDs that start with pl-. If either prints None, check that you are in the Region you meant to be in, and that your credentials work.

One sizing remark on the CIDR choices in this tutorial. The /16 for IPv4 is generous for a demo, and the two /24 subnets leave plenty of room for the managed interface and the load balancer nodes. In a shared environment, check your address plan with the network team first, because overlapping ranges are what ruin peering and transit routing a year from now. IPv6 is easier: AWS hands you a block, and each subnet takes a /64, so there is nothing to fight over.

Also decide now where this stack will live in your account structure. A demo can sit anywhere, but a production VPC origin tends to be owned by whichever team owns the edge, and the load balancer behind it by the application team. Agree on that boundary before the first deployment, since the security group rules sit exactly on it and someone has to own them when the CloudFront ranges change.

Step 2: Create the VPC with IPv6 and the required internet gateway

Start the template with the two prefix list parameters and the VPC. We attach an Amazon-provided IPv6 block to the VPC as a separate resource. We also create an internet gateway and attach it. That surprises people: the VPC origins documentation says an internet gateway must be attached to the VPC for this feature. We will not route anything to it, so the subnets remain private.

cat > template.yaml <<'EOF'
AWSTemplateFormatVersion: '2010-09-09'
Description: CloudFront dual-stack VPC origin demo
Parameters:
  CloudFrontIpv4PrefixListId:
    Type: String
  CloudFrontIpv6PrefixListId:
    Type: String
Resources:
  Vpc:
    Type: AWS::EC2::VPC
    Properties:
      CidrBlock: 10.20.0.0/16
      EnableDnsSupport: true
      EnableDnsHostnames: true
  Ipv6Block:
    Type: AWS::EC2::VPCCidrBlock
    Properties:
      VpcId: !Ref Vpc
      AmazonProvidedIpv6CidrBlock: true
  Igw:
    Type: AWS::EC2::InternetGateway
  IgwAttachment:
    Type: AWS::EC2::VPCGatewayAttachment
    Properties:
      VpcId: !Ref Vpc
      InternetGatewayId: !Ref Igw
EOF

Step 3: Add two private subnets and outbound-only IPv6

Now the subnets. Each gets an IPv4 range and a /64 carved out of the VPC’s IPv6 block. This matters more than it looks: CloudFront places a service-managed network interface in your subnet, and in dual-stack mode that interface needs a free IPv6 address as well as a free IPv4 one. Subnets without IPv6 will make the VPC origin creation fail.

We also add an egress-only internet gateway with a default IPv6 route. Your origin does not strictly need it for this demo, since the ALB only answers requests, but real workloads fetch updates and call APIs, and an outbound-only path keeps them private. Pick two Availability Zones whose zone IDs are not on the exclusion list in the docs (use1-az3, usw1-az2, cac1-az3, apne1-az3). Check yours with aws ec2 describe-availability-zones if you are unsure.

cat >> template.yaml <<'EOF'
  PrivateA:
    Type: AWS::EC2::Subnet
    DependsOn: Ipv6Block
    Properties:
      VpcId: !Ref Vpc
      AvailabilityZone: !Select [0, !GetAZs '']
      CidrBlock: 10.20.1.0/24
      Ipv6CidrBlock: !Select [0, !Cidr [!Select [0, !GetAtt Vpc.Ipv6CidrBlocks], 2, 64]]
      AssignIpv6AddressOnCreation: true
  PrivateB:
    Type: AWS::EC2::Subnet
    DependsOn: Ipv6Block
    Properties:
      VpcId: !Ref Vpc
      AvailabilityZone: !Select [1, !GetAZs '']
      CidrBlock: 10.20.2.0/24
      Ipv6CidrBlock: !Select [1, !Cidr [!Select [0, !GetAtt Vpc.Ipv6CidrBlocks], 2, 64]]
      AssignIpv6AddressOnCreation: true
  Eigw:
    Type: AWS::EC2::EgressOnlyInternetGateway
    Properties:
      VpcId: !Ref Vpc
  PrivateRt:
    Type: AWS::EC2::RouteTable
    Properties:
      VpcId: !Ref Vpc
  PrivateV6Route:
    Type: AWS::EC2::Route
    Properties:
      RouteTableId: !Ref PrivateRt
      DestinationIpv6CidrBlock: '::/0'
      EgressOnlyInternetGatewayId: !Ref Eigw
  RtAssocA:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      SubnetId: !Ref PrivateA
      RouteTableId: !Ref PrivateRt
  RtAssocB:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      SubnetId: !Ref PrivateB
      RouteTableId: !Ref PrivateRt
EOF
Conveyor belt carrying blocks through a tunnel toward a magnifying glass inspecting a log scroll

Step 4: Lock down the security group with both prefix lists

The load balancer needs a security group that admits CloudFront and nothing else. We create the group and then two separate ingress rules, one per prefix list. Port 80 keeps the demo simple. For anything real, terminate TLS at the ALB and use port 443, and set the origin protocol policy to match.

There is a tighter alternative: after the VPC origin exists, you can reference the service-managed group CloudFront creates, named CloudFront-VPCOrigins-Service-SG, which limits access to your own distributions rather than all of CloudFront. It cannot be wired up on the first deploy, though, because it does not exist yet. Start with prefix lists, then tighten later if your risk appetite says so. Never edit that service-managed group, and never name one of your own with that prefix.

cat >> template.yaml <<'EOF'
  AlbSg:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: Allow CloudFront origin-facing traffic only
      VpcId: !Ref Vpc
  AlbIngressV4:
    Type: AWS::EC2::SecurityGroupIngress
    Properties:
      GroupId: !Ref AlbSg
      IpProtocol: tcp
      FromPort: 80
      ToPort: 80
      SourcePrefixListId: !Ref CloudFrontIpv4PrefixListId
  AlbIngressV6:
    Type: AWS::EC2::SecurityGroupIngress
    Properties:
      GroupId: !Ref AlbSg
      IpProtocol: tcp
      FromPort: 80
      ToPort: 80
      SourcePrefixListId: !Ref CloudFrontIpv6PrefixListId
EOF

Step 5: Create the internal dual-stack ALB

The origin itself: an application load balancer with the internal scheme and the dual-stack IP address type. Internal means no public addresses, and dual-stack means it gets both IPv4 and IPv6 addresses inside the VPC. The listener returns a fixed 200 response, so we do not have to run any servers to test the path. The load balancer takes a few minutes to become active, and CloudFront requires the origin to be fully deployed and Active before you can create the VPC origin, which CloudFormation handles through the dependency.

cat >> template.yaml <<'EOF'
  Alb:
    Type: AWS::ElasticLoadBalancingV2::LoadBalancer
    DependsOn: [RtAssocA, RtAssocB, IgwAttachment]
    Properties:
      Scheme: internal
      Type: application
      IpAddressType: dualstack
      Subnets: [!Ref PrivateA, !Ref PrivateB]
      SecurityGroups: [!Ref AlbSg]
  AlbListener:
    Type: AWS::ElasticLoadBalancingV2::Listener
    Properties:
      LoadBalancerArn: !Ref Alb
      Port: 80
      Protocol: HTTP
      DefaultActions:
        - Type: fixed-response
          FixedResponseConfig:
            StatusCode: '200'
            ContentType: text/plain
            MessageBody: hello over a private path
EOF

Step 6: Define the dual-stack VPC origin

This is the new part. The VPC origin resource points at the ALB’s ARN and carries the IP address type. Setting it to dualstack is the whole feature. Leave it off and you get the old IPv4-only behavior, which is the default. We use the HTTP-only protocol policy to match the listener from the previous step.

The explicit dependency on the gateway attachment is deliberate. The service wants the internet gateway in place when it builds its network interface, and CloudFormation will otherwise happily race ahead of it.

cat >> template.yaml <<'EOF'
  VpcOrigin:
    Type: AWS::CloudFront::VpcOrigin
    DependsOn: [IgwAttachment, AlbListener]
    Properties:
      VpcOriginEndpointConfig:
        Name: dualstack-demo-origin
        Arn: !Ref Alb
        HTTPPort: 80
        HTTPSPort: 443
        OriginProtocolPolicy: http-only
        IpAddressType: dualstack
EOF

Step 7: Add the distribution

Now the distribution. Two settings keep the two ends of the connection separate in your head. IPV6Enabled controls whether viewers can reach CloudFront over IPv6. The origin’s IP address type, set on the VPC origin, controls the edge-to-origin leg. You want both for a true end-to-end path.

The origin domain name is the ALB’s DNS name, with a VPC origin configuration that references the origin resource we just made. We attach the managed CachingDisabled cache policy, so each request reaches the ALB and shows up in the metrics. In production you would pick a real caching policy and enjoy the cache hits, but for verification we want every request to travel the full path. Finish with outputs for the distribution domain name and ID.

cat >> template.yaml <<'EOF'
  Distribution:
    Type: AWS::CloudFront::Distribution
    Properties:
      DistributionConfig:
        Enabled: true
        IPV6Enabled: true
        HttpVersion: http2and3
        Origins:
          - Id: private-alb
            DomainName: !GetAtt Alb.DNSName
            VpcOriginConfig:
              VpcOriginId: !Ref VpcOrigin
        DefaultCacheBehavior:
          TargetOriginId: private-alb
          ViewerProtocolPolicy: redirect-to-https
          CachePolicyId: 4135ea2d-6df8-4a3d-b2d6-3bb4d08f6e6e
Outputs:
  DistributionDomain:
    Value: !GetAtt Distribution.DomainName
  AlbFullName:
    Value: !GetAtt Alb.LoadBalancerFullName
EOF

Step 8: Deploy the stack

Time to deploy. Validate the template first, because a typo discovered ten minutes into a CloudFront deployment is a small tragedy. Then create the stack, passing the prefix list IDs from step one, and wait. The wait command blocks until everything is up. Expect several minutes: the load balancer and the distribution each take their time.

If the stack fails, read the event list for the first failed resource, not the last. The usual suspects are a subnet with no free IPv6 address, an Availability Zone on the exclusion list, or a missing dependency that left the network interface unable to attach. Fix the cause, delete the failed stack, and run it again.

aws cloudformation validate-template --template-body file://template.yaml

aws cloudformation create-stack \
  --stack-name "$STACK" \
  --template-body file://template.yaml \
  --parameters \
    ParameterKey=CloudFrontIpv4PrefixListId,ParameterValue="$V4_PL" \
    ParameterKey=CloudFrontIpv6PrefixListId,ParameterValue="$V6_PL"

aws cloudformation wait stack-create-complete --stack-name "$STACK"

aws cloudformation describe-stacks --stack-name "$STACK" \
  --query 'Stacks[0].Outputs' --output table

Step 9: Send traffic and prove the path

First the easy check: curl the distribution domain. A 200 with your fixed message means the whole chain works, from viewer through CloudFront to a private load balancer you cannot reach from the internet. Try to hit the ALB’s DNS name directly from your laptop; it should not resolve to anything routable, which is the point of the exercise.

Now the proof that IPv6 was involved. The ALB publishes an IPv6 request count metric to CloudWatch, which is easier than setting up access logs for a demo. Generate some traffic, wait a couple of minutes for metrics to land, and query the sum. A non-zero number means CloudFront reached the ALB over IPv6. Because the origin is dual-stack, CloudFront chooses between families and spreads load across both, so send a decent batch of requests rather than one, and compare against the total request count if you want to see the split.

DOMAIN=$(aws cloudformation describe-stacks --stack-name "$STACK" \
  --query "Stacks[0].Outputs[?OutputKey=='DistributionDomain'].OutputValue" --output text)
ALB_FULL=$(aws cloudformation describe-stacks --stack-name "$STACK" \
  --query "Stacks[0].Outputs[?OutputKey=='AlbFullName'].OutputValue" --output text)

curl -sS -i "https://$DOMAIN/"

for i in $(seq 1 60); do curl -s -o /dev/null "https://$DOMAIN/?n=$i"; done

aws cloudwatch get-metric-statistics \
  --namespace AWS/ApplicationELB \
  --metric-name IPv6RequestCount \
  --dimensions Name=LoadBalancer,Value="$ALB_FULL" \
  --start-time "$(date -u -d '-15 minutes' +%Y-%m-%dT%H:%M:%SZ)" \
  --end-time "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
  --period 300 --statistics Sum

If the sum stays at zero but requests succeed, CloudFront is using IPv4 for those requests. Send more traffic and wait a little longer. If requests fail intermittently instead, re-check step four: one of the two prefix lists is missing from the security group. For deeper inspection in a real environment, enable ALB access logging and look at the client address field, which will show a CloudFront IPv6 address on the requests that arrived that way.

Step 10: Promote the pattern safely

You have a working demo. Moving a real distribution to this pattern deserves care, because changing an origin touches live traffic. CloudFront continuous deployment lets you create a staging distribution, send a percentage of requests to it, compare, and promote when you are happy. Use it. The networking blog post on IPv6 origins recommends exactly that approach for origin changes.

Before you promote, run through this short list. Switch the listener to HTTPS and the origin protocol policy to https-only. Confirm network ACLs allow return traffic on ephemeral ports 1024 to 65535, since outbound rules are evaluated on the response path. Confirm you are not relying on features the docs list as unsupported, such as gRPC or Lambda@Edge origin request and response triggers. And check anything downstream that reads client addresses, because dual-stack means your logs will start to contain colons.

Troubleshooting notes

Things go sideways in predictable ways, so here is the short field guide. If the VPC origin fails to create, look at the subnets first. A subnet that is out of IPv4 or IPv6 addresses cannot host the managed interface, and a subnet with no IPv6 range at all cannot satisfy a dual-stack origin. If creation fails in a specific Availability Zone, compare its zone ID against the exclusion list in the documentation, because zone names map to different IDs in different accounts.

If the distribution deploys but returns 502 or 504 errors, suspect the security group. Check that both prefix list rules exist, that they use the right port, and that the load balancer listener is actually on that port. Remember that the managed prefix lists belong to CloudFront origin-facing traffic only. Rules that reference your corporate ranges will not help here. If errors come and go, you almost certainly allowed one family and not the other.

If requests work but the IPv6 metric stays at zero, give it time and send more traffic. Metrics lag, and with a dual-stack origin the split between families is CloudFront’s decision. If you need deterministic behavior while testing, you can temporarily set the origin to a single address family, test it, and set it back. Just remember you changed it. Finally, if the load balancer refuses to become active, check that its subnets are in different Availability Zones and that its security group belongs to the same VPC.

A last tip for anyone adapting this to an existing application: put the infrastructure behind your normal pipeline, parameterize the CIDR ranges, and keep the prefix list lookups in the pipeline rather than pasting IDs. The lists are managed by AWS, and looking them up each time means a Region change does not turn into a debugging session. See also the continuous deployment guide for staging origin changes.

What you built

You created an IPv6-enabled VPC with two private subnets and no routable path to the internet, an internal dual-stack application load balancer protected by both CloudFront origin-facing prefix lists, a CloudFront VPC origin set to dual-stack, and a distribution that serves viewers over IPv4 and IPv6. You then measured IPv6 requests arriving at the origin. The origin never held a public address, and you did not pay extra for the feature itself.

The three things most likely to bite you later are the same three that probably nearly bit you today: a subnet with no spare IPv6 address, a security group with only one prefix list, and an Availability Zone on the exclusion list. For the full rules, keep the VPC origins guide bookmarked.

Cleanup

Delete the stack so the load balancer stops billing. CloudFormation disables and removes the distribution for you, which takes a few minutes, and then tears down the VPC origin, load balancer, and network.

aws cloudformation delete-stack --stack-name "$STACK"
aws cloudformation wait stack-delete-complete --stack-name "$STACK"
rm template.yaml

ENKOMPASS IS HERE TO HELP

At Enkompass (enkompass.net), this is our home turf. We build and migrate CloudFront, VPC, and load balancer architectures to private dual-stack designs, complete with infrastructure as code and staged rollouts. If you’d rather have seasoned cloud people handle it instead of learning the hard way at 2 AM, let’s talk. Reach out through our contact page or call us directly at +1-828-436-5667 — we’ll help you turn this from a someday-project into a done-deal.

Leave a Reply

Ready when you are

Let’s map your route to AWS

A free 60-minute architecture review with a senior engineer. No slides, no SDR, just a plan.