Cirrostratus

AWS Consulting for Small Teams

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:

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:

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:

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.