There was a thread on Reddit a while back where someone wanted to do the simplest thing imaginable: run one small container and put it on the internet. Nothing fancy. A hobby API, a little dashboard, whatever. And they were annoyed, because to do that “properly” on AWS you’re told to stand up an Application Load Balancer (~$16/mo just for existing) and a NAT gateway (~$32/mo just for existing) before you’ve served a single request. Fifty dollars a month of infrastructure tax to host something that could run on a Raspberry Pi in your closet.
They were right to be annoyed.
The obvious answer used to be App Runner: hand AWS a container, get a URL, pay per request. Except AWS is deprecating App Runner and has offered no real replacement. Bad AWS. Bad. So I did what any reasonable person does when a service they liked gets killed - I got petty about it and went looking for the cheapest possible way to run a container with a public URL, using nothing but the primitives AWS still ships.
Turns out you can get it down to about $3.21 a month, running 24/7, regardless of traffic. No load balancer. No NAT gateway. Here’s how, and here are the two AWS papercuts I hit on the way.
The full stack (OpenTofu) is on GitHub: cheapest-container-on-aws.
The plan
The expensive parts of a “normal” setup are the two always-on boxes: the load balancer that terminates traffic and the NAT gateway that lets your private task pull its image. If I can get rid of both, the only thing left to pay for is the container itself plus per-request charges. That’s App Runner economics.
The shape I landed on:
API Gateway HTTP API (per-request, no hourly fee)
└─ VPC Link (no NLB, free on HTTP APIs)
└─ Cloud Map service (ECS service discovery)
└─ Fargate task (ARM64 + Spot, dual-stack subnet)
└─ egress over IPv6 only (egress-only IGW) - image pulls
Two pieces do the heavy lifting here.
API Gateway HTTP API instead of a load balancer. An HTTP API is billed purely per request ($1 per million) and has no hourly charge. Its VPC Link - the thing that lets the API reach into your VPC - is free on HTTP APIs and, crucially, needs no Network Load Balancer behind it. (REST API VPC links do need an NLB. HTTP APIs don’t. This matters.)
IPv6 egress instead of a NAT gateway. The only reason a private Fargate task normally needs a NAT gateway is to reach the internet to pull its image. But an egress-only internet gateway does outbound IPv6 for free. So if the task pulls its image over IPv6, the NAT gateway simply isn’t needed.
The glue between the API Gateway and the task is the part most people don’t reach for: Cloud Map. The idea of pointing an HTTP API’s private integration straight at a Cloud Map service - no load balancer in the middle - comes from an old AWS Architecture blog I had filed away, Integrating HTTP APIs with AWS Cloud Map and Amazon ECS Services. ECS registers each task’s address in Cloud Map, and the API Gateway integration resolves it at request time. No ALB, no NLB, no target group.
On paper, clean. In practice, AWS had two surprises waiting.
Papercut #1: API Gateway’s Cloud Map integration only speaks IPv4
My first instinct was to go fully IPv6-only. Give the task an IPv6 address, no IPv4 anywhere, egress over the egress-only IGW, done. And the container was perfectly happy with that. It started, it pulled its image over IPv6, logging worked, Cloud Map registration worked. Everything was green.
Every single request through the API Gateway returned a 500.
The task registers only its IPv6 address (AWS_INSTANCE_IPV6) in Cloud Map, and when API Gateway’s VPC Link integration calls DiscoverInstances to find a target, it comes back with “No target endpoints found.” The integration only resolves IPv4 targets. An IPv6-only task is invisible to it.
I spent a while looking for documentation that says this out loud. I did not find any. If you can find the actual docs explaining why the Cloud Map integration won’t resolve an IPv6 target, I’ll happily buy you a coffee. As far as I can tell it’s just an unwritten limit you discover by watching requests 500 at you.
The fix is the one thing I was trying to avoid: give the task an IPv4 address. But - and this is the whole trick - an IPv4 address is free. A NAT gateway is the expensive part. So the task subnet becomes dual-stack:
- It has a private IPv4 address, used only for the in-VPC hop from the VPC Link to the task. That’s what gets registered in Cloud Map and what the integration resolves.
- Its route table has no IPv4 route to the internet. There’s still the automatic local route for the VPC’s IPv4 CIDR - every subnet in an IPv4 VPC gets that, you can’t remove it - but there’s no
0.0.0.0/0pointing at a NAT gateway or internet gateway. The only default route is IPv6, to the egress-only IGW.
So the IPv4 reaches only within the VPC - enough for API Gateway to talk to the task - while all actual internet egress (pulling the image) goes over IPv6. No NAT, no cost.
resource "aws_subnet" "ipv6" {
count = 2
vpc_id = aws_vpc.main.id
cidr_block = cidrsubnet(aws_vpc.main.cidr_block, 8, count.index + 10)
ipv6_cidr_block = cidrsubnet(aws_vpc.main.ipv6_cidr_block, 8, count.index)
# ...
}
resource "aws_route_table" "ipv6" {
vpc_id = aws_vpc.main.id
}
# The only default route is IPv6 -> egress-only IGW. The subnet still has the
# automatic local route for the VPC IPv4 CIDR, but no IPv4 route to the
# internet, so no NAT gateway is ever needed.
resource "aws_route" "ipv6_default" {
route_table_id = aws_route_table.ipv6.id
destination_ipv6_cidr_block = "::/0"
egress_only_gateway_id = aws_egress_only_internet_gateway.eigw.id
}
A couple of related things that will also 500 or hang until you get them right:
- Pull from the dual-stack public ECR endpoint. Use
ecr-public.aws.com/<alias>/<repo>, not the classicpublic.ecr.aws. The classic endpoint is IPv4-only and fails with “network unreachable” the moment you have no IPv4 egress. - Use SRV records, not A/AAAA, in Cloud Map. API Gateway needs both an IP and a port from
DiscoverInstances. A/AAAA records carry only the IP, so you get a 500. SRV carries IP and port, so ECS registers the address and the container port together. - Flip the
dualStackIPv6account setting. Without it, tasks in a dual-stack subnet won’t get an IPv6 address at all. It’s an account/region-wide default, not something scoped to the stack, so keep that in mind.
Papercut #2: no container logs, on purpose
Here’s the one that had me staring at PENDING tasks for a while.
Once the networking was sorted, the task would refuse to start, dying with ResourceInitializationError: failed to validate logger args. The culprit: the awslogs log driver connects to CloudWatch over IPv4, even on a dual-stack task. And this subnet has no IPv4 egress. So the driver times out reaching CloudWatch, and the task never leaves PENDING.
The irony is that logging worked fine back when the task was IPv6-only - it’s specifically the dual-stack setup, the one I needed for API Gateway, that breaks it. awslogs just prefers IPv4 and there’s no knob to tell it otherwise.
The blunt fix is to drop the log configuration entirely. No logConfiguration on the container, and the task starts. For a proof of concept that serves a static page, that’s fine.
container_definitions = jsonencode([
{
name = var.name
image = var.image
essential = true
portMappings = [{ containerPort = var.container_port, protocol = "tcp" }]
# No logConfiguration on purpose: awslogs connects to CloudWatch over IPv4,
# and this subnet has no IPv4 egress, so the driver times out and the task
# never starts. Dropping it lets the task run.
}
])
AWS, if you’re reading: please let awslogs talk IPv6. Thanks.
If you actually want logs and still want to dodge the NAT gateway, add a CloudWatch Logs dual-stack interface VPC endpoint (about $7/mo) and put the log config back. That keeps log traffic private and on IPv6. It’s no longer free, but $7 is a lot less than $32, and you get your logs.
What it costs
At the smallest Fargate size (0.25 vCPU / 0.5 GB), ARM64 on Spot, running all month:
| Component | USD / mo |
|---|---|
| Fargate vCPU (ARM Spot) | 2.22 |
| Fargate memory (ARM Spot) | 0.49 |
| Cloud Map (Route 53 hosted zone) | 0.50 |
| API Gateway HTTP API @ 1,000 views | 0.00 |
| VPC Link | 0.00 |
| Egress-only IGW | 0.00 |
| Total | ~3.21 |
The cost is driven by container uptime, not traffic - a thousand page views is a rounding error. It’s basically “what it costs to keep one tiny container alive,” plus a $0.50 Cloud Map hosted zone, which is the one fixed charge you can’t dodge here.
For comparison, the things we avoided:
| Setup | ~USD / mo |
|---|---|
| This stack (ARM Spot) | ~3.21 |
| ARM on-demand Fargate | ~7.70 |
| x86 on-demand Fargate | ~9.50 |
| + NAT gateway (avoided) | +32 |
| + Load balancer (avoided) | +16 |
The ARM64 + Spot combo squeezes out the last bit: Graviton is roughly 20% cheaper than x86 for the same nginx image, and Spot knocks off up to ~70% more. The tradeoff is that Spot tasks can be reclaimed with a two-minute warning, so this isn’t where you put your payment processor. For a side project or an internal tool, who cares.
The honest caveats
I’m not going to pretend this is a general-purpose hosting platform. A few sharp edges to keep in mind:
- I haven’t run a real workload on this. It serves a static page and proves the path works end to end. That’s it. I have no doubt a real app would run fine, but consider this a pattern, not a product.
- Everything your container calls at runtime has to be reachable over IPv6. S3, DynamoDB, most modern AWS APIs, and a good chunk of the internet are dual-stack, so you’re mostly fine. But an IPv4-only dependency would need DNS64 + NAT64 - which means a NAT gateway - and that defeats the entire exercise.
- ECS Exec doesn’t behave the same way without IPv4 egress, so debugging is a little more old-school.
Wrapping up
So: no App Runner, no problem. An HTTP API for the front door, a free VPC Link, Cloud Map for discovery, and a dual-stack Fargate task that keeps IPv4 in-VPC and does its egress over IPv6. About $3.21 a month for an always-on public container - $2.71 of container plus a $0.50 Cloud Map hosted zone - and the only reason it’s even that much is the two AWS quirks that forced a private IPv4 address into the picture.
Was it worth the effort to save fifty dollars a month? Absolutely not, if you bill it by the hour. But it was a fun way to prove a point, and the point stands: you do not need to pay the ALB-plus-NAT tax to put a small thing on the internet.
The whole thing is on GitHub as cheapest-container-on-aws - tofu apply, grab the URL, done.
Paying more than you’d like for infrastructure that mostly just sits there? That’s most of what I do. Take a look at my AWS consulting services or get in touch for a free consultation.