Skip to content
Networking7 min read

Dual-stack on a Network Load Balancer is a listener rule, not a checkbox

Flip an NLB to dualstack and IPv6 clients connect, but every one of them reaches an IPv4 target as the load balancer's own address, because translated traffic is never source-preserved. The fix is a listener rule that routes by address family to an IPv6 target group, and it brings four requirements the checkbox never mentioned, one of which makes a UDP service fail to build at all.

· Venerable Networks

Enabling IPv6 on a Network Load Balancer looks like one setting. Change the IP address type from ipv4 to dualstack, and from the NLB documentation:

When you enable dualstack mode for the Network Load Balancer, Elastic Load Balancing provides an AAAA DNS record for the Network Load Balancer. Clients that communicate with the Network Load Balancer using IPv4 addresses resolve the A DNS record. Clients that communicate with the Network Load Balancer using IPv6 addresses resolve the AAAA DNS record.

IPv6 clients now connect. Nothing else changed, so the targets are still in an IPv4 target group, and the next sentence on that page is the one that matters:

The Network Load Balancer communicates with targets based on the IP address type of the target group.

An IPv6 client reaching an IPv4 target group is a protocol translation, and translation has a cost that the target group attributes page states without qualification:

Client IP preservation has no effect on traffic converted from IPv6 to IPv4. The source IP address of this type of traffic is always the private IP address of the Network Load Balancer.

Every IPv6 client arrives at the target as the load balancer. Rate limits keyed on source address collapse to one bucket. Allow lists see one address. Access logs lose the client. The IPv4 clients are unaffected, so the regression is confined to whichever fraction of users happened to prefer IPv6, and it begins the moment the checkbox is ticked. The client IP guide has the full table of which family reaches which target type as what.

The mechanism that fixes it

Preservation survives when the client and the target are the same family. So the IPv6 clients need an IPv6 target group, and something has to send them there while IPv4 clients keep going to the IPv4 group. Until recently the only way was two load balancers. Now, from the listeners page:

You can add custom rules that evaluate conditions and route traffic to different target groups based on the source IP address type of incoming traffic. This enables you to route IPv4 and IPv6 traffic to separate target groups using a single dual-stack load balancer, eliminating the need for IPv4/IPv6 protocol translation and preserving end-to-end source IP connectivity.

The rule has exactly one condition type, and it is not the one an ALB engineer expects:

source-ip — Route based on the IP address type of the source traffic. You specify an IpAddressType value of ipv4 or ipv6. … The source-ip condition for Network Load Balancers uses IpAddressType to match traffic by IP version (IPv4 or IPv6). This differs from the Application Load Balancer source-ip condition, which uses CIDR ranges to match specific source IP addresses.

No CIDRs. The condition is "which family", and the action is a forward to a target group of the same family. One rule for ipv6 to the IPv6 group, with the default action sending everything else to the IPv4 group:

resource "aws_lb_listener_rule" "ipv6_clients" {
  listener_arn = aws_lb_listener.tcp.arn
  priority     = 10
 
  condition {
    source_ip {
      ip_address_type = "ipv6"
    }
  }
 
  action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.app_ipv6.arn
  }
}

The documentation's own note on the default is worth taking literally: "Set the default action to the IP address family you want as the fallback." If the rule covers IPv6, the default is IPv4, and the page observes that when rules cover both families "the default action may never be used. However, a default action is always required."

Then preservation works the way the attributes table says it does for same-family traffic, provided preserve_client_ip is on for the IPv6 group. The listeners page makes that explicit: "This requires preserve_client_ip to be enabled on the target group."

The four things the rule needs

This is where "it is a checkbox" stops being true.

1. An IPv6 target group, and IPv6 on the targets. The target groups page:

When registering targets by instance ID for a IPv6 target group, the targets must have an assigned primary IPv6 address.

An instance with an IPv6 address on its interface but no primary IPv6 address cannot be registered. The application also has to be listening on IPv6, which is not a given for software configured to bind 0.0.0.0.

2. Security groups that allow both families from the load balancer. From the listeners page:

Each target group health-checks its targets over its own IP address family. Confirm that target security groups allow health check traffic from the load balancer over both IPv4 and IPv6.

A target in both groups is health-checked twice, once per family. A security group that admits the load balancer's IPv4 range only leaves the IPv6 group's health checks failing and the IPv6 group unhealthy, and the zonal DNS behaviour then does what it does with an unhealthy zone.

3. The subnets, route tables and network ACLs, all IPv6-capable. The IP address type page lists them as requirements for dualstack itself:

The virtual private cloud (VPC) and subnets that you specify for the Network Load Balancer must have associated IPv6 CIDR blocks. … The route tables for the Network Load Balancer subnets must route IPv6 traffic. … The network ACLs for the Network Load Balancer subnets must allow IPv6 traffic.

Three places where an IPv4-era VPC will be missing something, and the third is the quiet one: a network ACL with explicit IPv4 allow rules and the default deny has no IPv6 rules at all.

4. No QUIC listeners. Same page: dualstack requires that "There are no QUIC or TCP_QUIC listeners attached to the Network Load Balancer." And the target groups page: "You can't use an IPv6 target group with QUIC or TCP_QUIC protocols." A service that adopted QUIC cannot adopt dualstack on the same load balancer.

UDP does not get the choice

For TCP, the IPv4 target group keeps working after the switch to dualstack, badly, with the translated source. UDP is stricter. From the listeners page:

A UDP listener for a dualstack load balancer requires IPv6 target groups.

And from the target groups page, as a hard constraint: "You can't use an IPv4 target group with a UDP listener for a dualstack load balancer."

So a UDP service behind an IPv4 target group does not degrade when the load balancer goes dualstack. It cannot be configured. The listener and the group are incompatible, and the change is refused rather than accepted with a worse source address. Which means a dualstack UDP service is an IPv6-target-group service from the start, with everything in the previous section as a prerequisite, and the documentation's ordering advice for anyone adding rules later: "make sure the IPv6 target group exists before you split UDP traffic."

There is one more UDP detail on the NLB page, stated as a requirement and worth knowing exists even though its mechanics are on another page:

To support source IP preservation for UDP IPv6 listeners, ensure that Enable prefix for IPv6 source NAT is turned on.

Two things that look like bugs and are not

An internal dualstack NLB cannot be reached through the internet gateway. From the NLB page:

Access to your internal dualstack Network Load Balancer through the internet gateway is blocked to prevent unintended internet access. However, this does not prevent other internet access (for example, through peering, Transit Gateway, AWS Direct Connect, or Site-to-Site VPN).

IPv6 addresses in a VPC are globally unique, so an "internal" load balancer with an IPv6 address would otherwise be one route-table entry away from the internet. AWS closes that path at the internet gateway specifically. If an internal dualstack NLB is unreachable from an IPv6 client on the internet, that is the design.

One load balancer now carries two populations. The listeners page, under performance:

A consolidated dual-stack Network Load Balancer carries the combined traffic of both IPv4 and IPv6 address families, potentially doubling the load compared to two individual Network Load Balancers. While one Network Load Balancer provides simplification, two separate Network Load Balancers provide natural fault isolation per address family.

The listener rule removed the need for two load balancers. It did not remove the reasons you might still want them.

What to do with it

Do not stop at dualstack. The checkbox gets IPv6 clients in the door and strips their address at the target. If anything downstream reads a source IP, the checkbox alone is a regression for IPv6 users.

Add the IPv6 target group and the source-ip rule in the same change. ip_address_type = "ipv6" to the IPv6 group, default action to the IPv4 group, preserve_client_ip on both. That is the configuration the documentation describes as preserving source end to end.

Audit the four prerequisites before the change, not after. Primary IPv6 on every target, IPv6 health-check rules in the target security group, IPv6 routes and network ACL rules on the load balancer subnets, no QUIC listeners. Each is a documented requirement and each fails differently.

Treat UDP as IPv6-first. A dualstack UDP listener will not accept an IPv4 target group. Build the IPv6 group before touching the address type.

Expect the internet gateway block on internal dualstack. It is deliberate, and the alternative paths it leaves open are the ones to check if "internal" is meant to mean unreachable.

The shape is the one from the NLB that forgot who was calling and the PrivateLink source that is always the load balancer: a path where the client's address is replaced by the load balancer's, for a documented reason, in a way the connection itself does not reveal. Dualstack adds a third such path, and it is the first of the three with a configuration that removes it.