AWS Network Load Balancer
Layer 4 load balancing on AWS. Static IPs, client IP preservation and the hairpin problem, cross-zone charges, and when an NLB beats an ALB.
An NLB forwards TCP and UDP without looking inside. It cannot route on a URL path, because it never parses HTTP, and that limitation is exactly what buys you the four things an ALB cannot give you:
- A static IP per availability zone, which is what firewall allow-lists and some DNS setups require.
- The client's real source IP, preserved and visible to your application.
- Any TCP or UDP protocol: Postgres, Redis, SMTP, MQTT, game traffic, DNS.
- Very high throughput at very low latency, with no connection termination overhead.
If your traffic is HTTP and you need none of those, an ALB is the better tool.
Creating one
aws elbv2 create-load-balancer --name db-nlb \
--type network --scheme internal \
--subnet-mappings 'SubnetId=subnet-0a1b2c3d' 'SubnetId=subnet-4e5f6a7b'
You can pin the addresses rather than letting AWS choose, which is the point of an NLB for many people:
aws elbv2 create-load-balancer --name api-nlb --type network \
--subnet-mappings \
'SubnetId=subnet-0a1b2c3d,AllocationId=eipalloc-0123456789abcdef0' \
'SubnetId=subnet-4e5f6a7b,AllocationId=eipalloc-0fedcba9876543210'
Those Elastic IPs never change, so you can hand them to a partner's firewall team once and not revisit it.
aws elbv2 create-target-group --name pg-tg \
--protocol TCP --port 5432 --vpc-id vpc-0abc123 --target-type ip \
--health-check-protocol TCP \
--health-check-interval-seconds 10 \
--healthy-threshold-count 2 --unhealthy-threshold-count 2
aws elbv2 create-listener --load-balancer-arn "$NLB_ARN" \
--protocol TCP --port 5432 \
--default-actions Type=forward,TargetGroupArn="$TG_ARN"
A TCP health check only proves something accepted a connection. Where the protocol allows it, an HTTP health check tells you considerably more:
--health-check-protocol HTTP --health-check-port 8080 --health-check-path /healthz
Client IP preservation and the hairpin problem
By default, an NLB with --target-type instance or ip passes the client's source IP straight through. Your application sees the real client with no X-Forwarded-For needed, which is excellent, until a target tries to reach the load balancer that is in front of it.
The failure looks like this: an EC2 instance behind the NLB connects to the NLB's own DNS name, the NLB routes that connection back to the same instance, and the instance receives a packet whose source IP is its own. The TCP stack drops it. The connection simply hangs, with nothing in any log.
Three ways out:
# 1. Turn preservation off. Targets then see the NLB's IP
aws elbv2 modify-target-group-attributes --target-group-arn "$TG_ARN" \
--attributes Key=preserve_client_ip.enabled,Value=false
-
Use
--target-type alband put an ALB behind the NLB, which terminates connections. -
Have the instance talk to the service directly rather than via the load balancer, which is usually the right answer anyway.
This is the single most common NLB surprise, and it is a design consequence rather than a bug.
Security groups work differently
For a long time NLBs had no security group at all, and traffic was filtered only by the targets' security groups, which meant those groups had to allow the client CIDR directly, because with client IP preservation the packets arrive with the original source address. An allow rule naming the load balancer's security group does nothing, since there was not one.
NLBs now support security groups, but only when created. You cannot add one later:
aws elbv2 create-load-balancer --name api-nlb --type network \
--security-groups sg-0123456789abcdef0 \
--subnet-mappings 'SubnetId=subnet-0a1b2c3d'
If an existing NLB needs one, it has to be replaced. Worth checking before you build.
Cross-zone load balancing costs money
The default differs between the two load balancer types, and this catches people on their bill.
ALB: cross-zone is on and cannot be turned off, at no charge. NLB: cross-zone is off by default, and enabling it incurs inter-AZ data transfer charges.
With it off, an NLB node only sends traffic to targets in its own availability zone. That is cheaper and keeps latency lowest, but it means uneven distribution when zones hold different numbers of targets, and if a zone has no healthy targets, clients resolving to that node fail.
aws elbv2 modify-load-balancer-attributes --load-balancer-arn "$NLB_ARN" \
--attributes Key=load_balancing.cross_zone.enabled,Value=true
Keep it off with a balanced number of targets per zone; turn it on when distribution matters more than transfer cost.
TLS listeners
An NLB can terminate TLS without becoming an ALB, which is useful for non-HTTP protocols:
aws elbv2 create-listener --load-balancer-arn "$NLB_ARN" \
--protocol TLS --port 443 \
--certificates CertificateArn="$CERT_ARN" \
--ssl-policy ELBSecurityPolicy-TLS13-1-2-2021-06 \
--default-actions Type=forward,TargetGroupArn="$TG_ARN"
Alternatively use TCP on 443 and pass encrypted traffic straight through, terminating on the target, which keeps the connection end-to-end encrypted and is sometimes a compliance requirement.
Terminating TLS at the NLB loses the client IP for the TLS listener unless you enable proxy protocol v2, which prefixes each connection with the original address:
aws elbv2 modify-target-group-attributes --target-group-arn "$TG_ARN" \
--attributes Key=proxy_protocol_v2.enabled,Value=true
Only enable it if the target application understands proxy protocol. Otherwise it reads the header as the first bytes of your protocol and the connection breaks in a genuinely confusing way.
PrivateLink
An NLB is what you put in front of a service you want to expose to other AWS accounts privately, without VPC peering or public internet:
aws ec2 create-vpc-endpoint-service-configuration \
--network-load-balancer-arns "$NLB_ARN" --acceptance-required
Consumers create an interface endpoint in their own VPC and reach your service over private addresses. Only an NLB (or a Gateway Load Balancer) can back an endpoint service, so if PrivateLink is on your roadmap, that decides the load balancer type.
Common errors
Connections hang with no error. Almost certainly the hairpin problem above, or a target security group that does not allow the client CIDR.
Targets healthy, connections refused. With client IP preservation on, the target security group must allow the client's CIDR, not the load balancer's.
ValidationError: Security groups cannot be added to a network load balancer
after creation
Exactly what it says. Recreate the NLB.
Uneven traffic across targets. Cross-zone load balancing is off, which is the NLB default.
Quick reference
| Task | Command |
|---|---|
| Create with static IPs | --subnet-mappings 'SubnetId=…,AllocationId=eipalloc-…' |
| Create with a security group | --security-groups sg-… (creation only) |
| HTTP health check on TCP target | --health-check-protocol HTTP --health-check-path /healthz |
| Check health | aws elbv2 describe-target-health --target-group-arn "$TG" |
| Disable client IP preservation | Key=preserve_client_ip.enabled,Value=false |
| Enable cross-zone | Key=load_balancing.cross_zone.enabled,Value=true |
| Enable proxy protocol | Key=proxy_protocol_v2.enabled,Value=true |
| Expose via PrivateLink | aws ec2 create-vpc-endpoint-service-configuration |
Key takeaways
- Choose an NLB for static IPs, non-HTTP protocols, client IP preservation, or PrivateLink.
- A target reaching the NLB in front of itself hangs: that is the hairpin problem, not a bug.
- With client IP preservation on, target security groups must allow the client CIDR, not the balancer.
- Security groups can only be attached at creation. Adding one later means replacing the NLB.
- Cross-zone is off by default on an NLB and costs inter-AZ transfer when enabled.
- A TCP health check only proves something answered. Use HTTP where you can.
- Proxy protocol v2 restores the client IP on TLS listeners: and breaks targets that do not speak it.
- Only an NLB can back a PrivateLink endpoint service.
Check your understanding
A connection that hangs forever
An EC2 instance registered behind an NLB tries to connect to that NLB's DNS name. The connection hangs with no error and nothing in the logs. Other clients work fine. Why?
Choosing a load balancer
A partner requires two fixed IP addresses to allow-list, and the service speaks a custom binary protocol over TCP on port 9000. Which load balancer, and why?
Frequently Asked Questions
When should I use an NLB instead of an ALB?
When you need any of four things: a static IP address per availability zone for firewall allow-listing, the client's real source IP preserved without relying on headers, a protocol other than HTTP or HTTPS, or the ability to back a PrivateLink endpoint service. Also when throughput and latency are extreme enough that terminating connections at layer 7 is a cost you would rather avoid. For ordinary web traffic where none of those apply, an ALB gives you path and host routing that an NLB fundamentally cannot.
Why do connections through my NLB hang with no error?
Most likely the hairpin problem. With client IP preservation enabled, the default, a target that connects to the NLB in front of it can have that connection routed back to itself, receiving a packet whose source address is its own, which the TCP stack silently drops. Nothing logs an error because nothing refused anything. Disable preserve_client_ip, use an ALB as the NLB's target, or have the instance connect to the service directly rather than through the load balancer.
Does an NLB have a security group?
Now yes, but only if you attach one when the load balancer is created. It cannot be added afterwards, and an existing NLB must be replaced to gain one. Historically NLBs had none at all, which is why so much documentation says traffic must be controlled entirely by the targets' security groups. That distinction matters because with client IP preservation the targets see the original client address, so their rules must allow the client CIDR rather than referencing the load balancer.
Why is traffic uneven across my NLB targets?
Cross-zone load balancing is disabled by default on an NLB, so each node only sends traffic to targets in its own availability zone. If one zone holds four instances and another holds one, the single instance takes a disproportionate share. Enabling cross-zone evens it out but incurs inter-AZ data transfer charges, which an ALB does not, cross-zone is always on there and free. Either keep target counts balanced per zone, or enable it and accept the transfer cost.
How do I preserve the client IP when the NLB terminates TLS?
Enable proxy protocol v2 on the target group. A TLS listener terminates the connection, so the original source address is otherwise lost; proxy protocol prefixes each connection with a header carrying the real client address and port. The important caveat is that the target application must understand proxy protocol, if it does not, it reads the header as the first bytes of your actual protocol and the connection fails in a confusing way. Many web servers and proxies support it with a single configuration flag.
Can an NLB do path-based routing?
No, and it never will. Path-based routing requires parsing HTTP, which is precisely what an NLB does not do. It forwards packets at layer 4 with no knowledge of what they contain. If you need both static IPs and HTTP routing, the standard pattern is an NLB in front of an ALB using --target-type alb: the NLB supplies the fixed addresses, and the ALB behind it does the layer 7 routing.