ALB's Hidden Cost Is One Public IPv4 per Zone
The load balancer line is $16.43 a month. Under it sit public IPv4 addresses at $3.65 each, two minimum, allocated in your account as Elastic IPs you cannot release. Two ways to remove them, and only one of them is a rewrite.
An Application Load Balancer in us-east-1 bills $0.0225 an hour, which is $16.43 a month before a request arrives. That is the number people put in the budget. The number they leave out sits underneath it: one public IPv4 address per enabled Availability Zone, $0.005 an hour each, and an ALB will not start with fewer than two zones.
Two addresses is $7.30 a month. So the floor for an idle ALB is $23.73, and about a third of it is a line nobody picked. Run three zones for the usual reasons and it is $27.38.
Prices and behavior below came off the AWS and Cloudflare pricing and documentation pages on 31 August 2026.
The addresses really do show up in your account
The folk version of this is “an ALB creates an EIP per zone and bills you for it.” That is closer to right than it sounds. AWS documents that Application Load Balancers “use Public Elastic IPv4 addresses from EC2’s public IPv4 address pool,” that those addresses are visible in your account through describe-addresses, and that each one carries a service_managed attribute set to ALB.
aws ec2 describe-addresses
What the folk version gets wrong is who is in charge. These allocations do not count against your account’s EIP limit, you cannot modify or release them, and the load balancer service hands them back to the pool when it is done. There is also no separate “EIP charge” involved. It is the ordinary public IPv4 charge that applies to every address in the account, and on the bill it lands under the usage type PublicIPv4:InUseAddress, mixed in with your EC2 instances and any idle Elastic IPs you forgot about. Idle addresses bill at the same $0.005.
Two is the floor because the docs give you no way below it. An ALB requires “at least two Availability Zone subnets,” and “the Application Load Balancer has one IP address per enabled Availability Zone.” There is no knob for one.
The cheap fix keeps the ALB and turns its IPv4 off
Since 16 May 2024 an internet-facing ALB accepts a third IP address type, dualstack-without-public-ipv4. Clients must reach the load balancer over IPv6 by resolving its AAAA records. The ALB still talks to targets over private IPv4 or IPv6, so nothing behind it changes. AWS says plainly that this exists for “avoiding IPv4 charges for clients that don’t require it,” and that there is no charge for the feature itself.
Read on its own, that sounds like it breaks every IPv4 client on the internet, which is most of them. It does, if the ALB is what the client resolves. It is not, if Cloudflare is already in front. The Cloudflare edge answers both A and AAAA for a proxied hostname, so an IPv4 client reaches Cloudflare over IPv4 and never learns what the origin speaks. On the back half, Cloudflare prefers IPv4 when a proxied record carries both an IPv6 and an IPv4 origin address. Publish only the AAAA and there is nothing left to prefer.
Which makes the whole change one call, once the VPC and the load balancer subnets have IPv6 CIDRs and the ALB is already dualstack:
aws elbv2 set-ip-address-type \
--load-balancer-arn <arn> \
--ip-address-type dualstack-without-public-ipv4
Rollback is the same command with dualstack. That is a rare shape for an infrastructure cost change, and it is why I would do this one before thinking about anything larger.
Three things break, and they are all findable before you flip it. ALB built-in authentication is the sharp one: the docs note that it “only supports IPv4 when connecting to an Identity Provider (IdP) or Amazon Cognito endpoint,” so without a public IPv4 the load balancer cannot finish the flow and returns HTTP 500. IPAM address pools are not supported with this type either, which matters only if you brought your own IPv4 space. And anything that resolves the load balancer directly over IPv4 loses it: an uptime checker, a partner allowlist, a stale CNAME that skips Cloudflare entirely.
Bringing your own space is the other lever, if you already own a range. The ALB docs are explicit that when an IPAM pool runs dry the service falls back to AWS managed addresses, that those carry “additional charges,” and that adding ranges to the pool avoids them.
Deleting the ALB is a bigger trade than it looks
Cloudflare Tunnel is the other direction. You run cloudflared next to the application, it opens outbound connections to Cloudflare on port 7844 over TCP or UDP, and traffic arrives through those. No inbound rule, no listener, no certificate. The docs say it is available on all plans, and each tunnel holds four long-lived connections across two Cloudflare data centers on its own.
That removes $16.43 and $7.30 in the same afternoon, along with the ACM certificate, because there is no longer a listener to attach one to. What you hand over in exchange is worth listing before you agree to it. Host and path routing, TLS termination, rate limiting and WAF all move to Cloudflare, which is fine, because most of that was probably already running there. Target group health checks are gone with no direct replacement. And the origin stops seeing the client. It sees cloudflared. For HTTP you read CF-Connecting-IP instead, and for SSH, RDP, or raw TCP the client address is not available to the origin at all.
I have written about how Cloudflare Tunnel compares to Tailscale and ngrok for the developer-tunnel job. This is the other use, as permanent ingress, and it has a cost trap the developer-tunnel case never hits.
The trap: the host still has to get out
Deleting the load balancer does not delete public IPv4 from the picture. It moves the question one box to the right, onto whatever runs cloudflared, and that box has three options with no free one among them.
Leave it in a public subnet with an auto-assigned public IPv4 and you keep paying $0.005 an hour for that one address. The saving is $20.08 a month, not $23.73. Move it to a private subnet instead and cloudflared still needs a way out, so you add a NAT gateway at $0.045 an hour, $32.85 a month plus $0.045 per GB processed. That is more than the load balancer you just deleted. AWS publishes a break-even formula for exactly this trade, and at one address it is not close.
Which leaves IPv6-only egress, the option that looks cheapest on paper and takes the most work to be sure about.
IPv6-only egress is a project, not a checkbox
The mechanics are short. Give the subnet an IPv6 CIDR, route ::/0 to an egress-only internet gateway, which AWS documents as “no charge,” and take the public IPv4 off the instance. Then ask cloudflared for IPv6, because it defaults edge-ip-version to 4. The tunnel edge does publish AAAA records for every region, region1.v2.argotunnel.com and region2.v2.argotunnel.com resolve into 2606:4700:a0:: and 2606:4700:a8::.
cloudflared tunnel --edge-ip-version 6 run <uuid>
The list of what you give up is longer than the list of what you do.
Start with what does not change. You are not getting off IPv4 inside the VPC, because AWS does not offer that: “when you create a VPC, you must specify an IPv4 CIDR block,” and “you can’t disable this behavior.” An instance in a dual-stack subnet “receives a primary private IP address from the IPv4 address range of the subnet,” and it always will. That address is not billed, so this is not a cost problem. It is a reminder that the thing you are switching off is one public address on one ENI, not IPv4.
What does change is reachability to anything IPv4-only on the internet, and the state of that is worse than the general sense that IPv6 is settled. Resolving the usual build-time hosts on 31 August 2026: Docker Hub, PyPI, npm, and the Debian and Ubuntu archives all answer AAAA. GitHub does not. github.com, api.github.com, codeload.github.com, objects.githubusercontent.com, and ghcr.io came back A-only. If anything on that host clones a repository, pulls an action, or fetches a release artifact, IPv6-only egress breaks it.
AWS’s answer to that is DNS64 and NAT64. Enable DNS64 on the subnet, Route 53 Resolver synthesizes a 64:ff9b::/96 address for IPv4-only destinations, and you route that prefix through a NAT gateway. Read it again with the invoice open. The documented path from an IPv6-only workload to an IPv4-only destination runs through the $32.85 line you were trying to delete.
Then the smaller edges, each cheap to fix and expensive to discover at three in the morning. The IMDS IPv6 endpoint at fd00:ec2::254 stays off even in an IPv6-only subnet until you turn it on with --http-protocol-ipv6 enabled. A genuinely IPv6-only subnet requires a Nitro instance type. AWS service endpoints are not uniformly dual-stack, so anything the host calls directly rather than through a VPC endpoint has to be checked one at a time.
None of that makes IPv6-only egress wrong. It makes it a compatibility audit with an outbound dependency inventory at the end of it, and the prize for finishing is $3.65 a month. Worth doing if IPv6 was already on the roadmap and this is the excuse to start. Not worth starting to close out a cost ticket.
So the honest default for a single origin is a public subnet, one public IPv4 on the instance, no load balancer. $3.65 against the ALB’s $23.73, which is 85 percent of the line, taken without an audit. The last 15 percent is a different kind of work and should be scheduled as such.
Where the tunnel stops being the answer
Replicas are the obvious reach for redundancy: run the same tunnel UUID on more hosts and one of them can die. That works, and it is also the ceiling. Cloudflare’s own reference architecture is blunt about it, saying that “by design, replicas do not offer any level of traffic steering,” and the load balancing docs add that the load balancer “treats all replicas of the same tunnel UUID as a single endpoint.” For per-host health checks or session affinity you need one tunnel UUID per host plus Cloudflare Load Balancing, which is a paid add-on. TCP monitors are not supported against tunnel endpoints at all, so the documented workaround is to publish an HTTP_STATUS route and monitor that, which confirms cloudflared is alive and tells you nothing about the application behind it.
For an autoscaling group this is where the saving evaporates. Every instance that launches needs a tunnel identity and every instance that terminates needs one cleaned up. A token in user data handles it, and now you maintain a registration path to replace the target group you deleted, to save $23.73 a month. That trade only makes sense if you wanted to be off ALB for some other reason.
One origin, or a small fixed set of them, is the shape the tunnel fits, and the price happening to be zero is a bonus rather than the argument. A fleet that scales is the shape where you keep the ALB and delete its addresses instead.
| Option | Fixed monthly, us-east-1, two zones | Billed public IPv4 | What it costs you |
|---|---|---|---|
ALB, dualstack | $23.73 | 2 | nothing |
ALB, dualstack-without-public-ipv4 | $16.43 | 0 | ALB built-in auth, direct IPv4 clients |
cloudflared, public subnet | $3.65 | 1 | health checks, access logs, client IP off HTTP |
cloudflared, IPv6-only egress | $0.00 | 0 | the same, plus an outbound compatibility audit |
cloudflared, private subnet with NAT | $32.85 plus $0.045/GB | 0 | the same, and more than the ALB |
LCU charges of $0.008 per LCU-hour sit on top of both ALB rows and are not in the table. At low traffic they are small. At high traffic they are the whole conversation, and nothing here changes them.
An order that lets you back out
Confirm the line exists first. Group Cost Explorer by usage type and look for PublicIPv4:InUseAddress. If it is a rounding error next to your LCU charges, this is not where your money is going and you should go read the LCU dimensions instead.
Then put Cloudflare in front and proxy the record, if that is not already true. Add IPv6 to the VPC and to the load balancer subnets. Flip the IP address type. Wait a full billing day and check that the usage line went to zero rather than assuming it did.
Only after that is it worth asking whether the ALB is still doing anything Cloudflare is not. If the honest answer is host and path routing and nothing else, the tunnel is on the table. If the answer includes health-checked failover across a fleet that changes size on its own, it is not, and the four steps above already took the part of the bill that was never a decision. Either way, leave the last address on the host alone until IPv6 is a project you actually want.
Run aws ec2 describe-addresses the morning after each change. If the count is not what you expected, something in the path still wants an IPv4 address, and the API is a cheaper place to learn that than the invoice.