AWS cost floors: ALB, NAT, ECS Express Mode, and free-tier traps
When people try to run something cheaply on AWS, they often think there are only two options: a Kubernetes cluster they don't need, or one server that keeps them up at night.
A better question is what the setup really costs in total. That includes the cloud bill, the time spent running it, how well it's secured, and what breaks when the single server goes down.
This post covers four parts of that question: a small EC2 bill, protecting a server that's open to the internet, ECS Express Mode compared with a single server, and a serverless demo that costs close to nothing.
All prices are On-Demand Linux prices for a 730-hour month. The EC2 numbers use Asia Pacific (Singapore), because that's where a t3.small costs about $19. The Fargate prices use US East (N. Virginia), the cheapest region, so treat them as the lowest you'll pay. Singapore costs more. Prices change, so check the AWS pricing pages before you rely on these numbers.
Why the bill is about $90, not $19
A common small setup is five containers or Next.js apps on one server, behind an Application Load Balancer (ALB) and a NAT Gateway. In Singapore, the fixed costs look like this:
| Resource | What you pay for | Monthly minimum |
|---|---|---|
EC2 t3.small | 2 vCPU (burstable), 2 GiB memory, $0.0264/hr | ~$19.27 |
| NAT Gateway | $0.059/hr, plus a charge for every GB it processes, plus internet data transfer | ~$43 before data |
| ALB | An hourly charge plus LCUs (load balancer capacity units) | ~$18-20 when idle |
| Public IPv4 addresses | $0.005/hr per address, including the ones the ALB and NAT use | ~$3.65 each |
The IP addresses are easy to miss. An internet-facing ALB in two Availability Zones usually has two public IPv4 addresses (about $7.30), and the NAT Gateway has one more (about $3.65). Add it all up and you pay around $90 a month before any data transfer. The server itself is only about a fifth of that.
You can make two separate decisions here:
- Remove the NAT Gateway. Put the server in a public subnet. Or keep it in a private subnet and use VPC endpoints for S3, ECR, and SSM. This saves the biggest line on the bill.
- Remove the ALB. Routing, TLS, health checks, and connection draining then move onto the server.
You can do the first without the second. Doing the second without the first usually isn't worth it, because the NAT Gateway costs more than the ALB.
What changes if you remove the ALB too
With a public subnet, no NAT Gateway, no ALB, and one Elastic IP on the server:
| Resource | Monthly |
|---|---|
EC2 t3.small | ~$19.27 |
30 GB gp3 disk | ~$2.40-2.70 |
| Public IPv4 address | ~$3.65 |
| ALB and NAT | $0 |
| Total | ~$25-26 |
Here's what that means for the design:
- Routing moves onto the server. Nginx or Caddy runs on the server and sends each request to the right container.
- One server can take everything down. There's no target group, so you can't drain traffic away from a container before you replace it. That's what causes a brief 502 error during deploys.
nginx -s reloaddoesn't cause it, because reloading is graceful. - The server is on the public internet. Anyone can reach its IP address directly unless you lock down the security group.
The savings show up on the invoice, but you pay for them with your time. Patching, disk space, log rotation, and a kernel panic at 2 a.m. are now your problem. A couple of hours of that work in a month can cost more than you saved.
Protecting a public EC2 server
If the server has a public IPv4 address, anyone who finds it can skip the CDN and go straight to it. You can still protect it in layers, but it isn't free once you add WAF.
Internet -> CloudFront (+ optional WAF) -> security group (CloudFront prefix list) -> Nginx
At the edge: Shield Standard and WAF
AWS Shield Standard is on by default. It absorbs a lot of network-level flood traffic, but it doesn't filter application requests.
AWS WAF costs extra, typically $5 per web ACL per month, $1 per rule, and about $0.60 per million requests. Some CloudFront flat-rate plans include WAF. Put WAF on the CloudFront distribution if you need rules for paths or request rates, but don't count it as a free protection.
On the network: the CloudFront prefix list
In the server's security group, allow traffic on the web port only from the AWS-managed prefix list com.amazonaws.global.cloudfront.origin-facing. For IPv6, use com.amazonaws.global.ipv6.cloudfront.origin-facing. AWS keeps the IP ranges up to date for you.
Watch the quota. That prefix list counts as 55 rules. A security group allows 60 inbound rules by default, so one rule that uses the list leaves you only five more. Opening both port 80 and port 443 from the list counts as 110 rules and needs a quota increase. Use one port, ideally 443.
This rule only filters the TCP port you attach it to. It doesn't block pings or anything else. Security groups already block ICMP unless you allow it.
Make sure CloudFront's origin port matches the one you open. Port 8080 works fine if you set the distribution to use it, but 443 is the usual choice.
In the app: a secret header from CloudFront
Configure the CloudFront distribution to add a custom header with a secret value to every request it sends to your server. Then have Nginx reject requests that don't have it. Doing the check with map keeps the logic out of if blocks. The one if left only contains return, which is one of the few safe ways to use if in Nginx.
map $http_x_origin_secret $origin_ok {
default 0;
"your-secret-value" 1;
}
server {
listen 443 ssl;
location / {
if ($origin_ok = 0) {
return 403;
}
# proxy_pass to the local containers
}
}
This is a simple shared secret, not real cryptography. Rotate it, keep it out of logs, and never show it on error pages. Use it together with the prefix list so the secret isn't the only thing protecting the server.
There's a stronger option: CloudFront VPC origins, which AWS released in late 2024. The EC2 server (or ALB) stays in a private subnet, so there's no public IP address to find. Use the prefix list and header only when the server has to stay public.
On the server: rate limits, Fail2ban, and SSM
- Rate-limit sensitive paths in Nginx with
limit_req_zone. It's better to do this in CloudFront or WAF, where you can see the real client IP. - Fail2ban behind CloudFront is easy to get wrong. The server sees CloudFront's IP addresses, not your visitors'. A basic Fail2ban setup ends up banning CloudFront itself and takes your own site offline. If you use Fail2ban on the server, configure Nginx's
real_ipmodule to trust CloudFront's addresses, and never ban those addresses. A WAF rate rule on the client IP is usually the better choice. - Close port 22. Use SSM Session Manager instead. It needs the SSM agent on the server, an IAM instance profile, and outbound HTTPS to SSM (or VPC endpoints).
ECS Express Mode vs a cheap EC2 server
Amazon ECS Express Mode was released in November 2025. You give it a container image and two IAM roles, and it sets up Fargate, an internet-facing ALB with HTTPS, autoscaling, safe deployments, and a *.ecs.<region>.on.aws URL. Express Mode itself is free. You pay for Fargate, the ALB, logs, and data transfer.
Express Mode can share one ALB across up to 25 services by routing on the hostname. That's where the savings come from compared with one ALB per service. You still pay for the ALB.
The default task size is 1 vCPU and 2 GB of memory. In US East, Fargate charges $0.000011244 per vCPU-second and $0.000001235 per GB-second, so one default task running all month costs about $36. Add the ALB (about $18-20) and its two public IPv4 addresses (about $7). One default Express service comes to roughly $55-65 a month.
You only get a bill of around $30 with a very small task (0.25 vCPU and 0.5 GB, about $9 a month) plus the ALB. That's one service, not five containers sharing a t3.small.
| Setup | Realistic monthly minimum | What you run |
|---|---|---|
5 containers on one public t3.small with Nginx | ~$25 | One shared server, one AZ, and you do the patching |
| 1 Express service, 0.25 vCPU | ~$30-40 including the ALB | One web app or API |
| 1 Express service, default 1 vCPU / 2 GB | ~$55-65 | One web app or API |
| 5 Express services, small tasks, one shared ALB | ~$60-80 | Five separate tasks |
| 5 Express services, default size, one shared ALB | ~$200+ | Five default-size tasks |
The real difference from Docker on one EC2 server is isolation. Each Fargate task runs in its own isolated environment and doesn't share a kernel with your other services. Deployments go through the CreateExpressGatewayService and UpdateExpressGatewayService APIs, not a plain update-service. Express Mode is a shortcut for HTTPS web apps and APIs, not for every kind of ECS workload.
My recommendation: remove the NAT Gateway first. Keep the ALB unless you're fine with a single server as your origin and you put CloudFront in front of it, ideally with a VPC origin. Use Express Mode when you want managed containers and can accept the ALB plus Fargate for each task as your minimum cost.
Marketplace scale is a different problem
A $25 server doesn't turn into a large marketplace just by adding more AWS services.
For a large marketplace on AWS, the usual design still holds up. That means CloudFront and WAF at the edge, containers on ECS or EKS, OpenSearch for search with filters, ElastiCache for sessions and fast-changing counters, Aurora for payments and orders, and DynamoDB for simple key-value data. Queues (SQS, EventBridge, or Kinesis) keep checkout from waiting on many services at once.
Treat that as a starting template, not as how any particular company does it. Real companies make different choices. Some publish AWS case studies using Aurora, DynamoDB, and Fargate. Others publish write-ups on stacks with no AWS reference architecture at all, built on Go services, Redis, Kafka, and TiDB.
Flash sales usually fail because of hot keys, race conditions on inventory, checkout calling too many services, and running out of database connections. Global Accelerator is optional and won't fix any of those.
A near-zero cost portfolio demo
For a demo that should cost almost nothing when nobody's using it, skip anything that runs all the time.
CloudFront + S3 -> API Gateway HTTP API -> Lambda -> DynamoDB
↑ Cognito
SQS (background work)
A bill of $0 a month only holds while you stay inside the free limits, so set up a billing alarm on the first day.
| Service | What's actually free |
|---|---|
| CloudFront | The pay-as-you-go plan's always-free tier is generous: 1 TB of data out and 10 million requests a month. The flat-rate free plan is a separate product with smaller limits, and it bundles some WAF and DNS features. |
| S3 | Not free forever. Stay within the free tier, your credits, or your plan's storage allowance. Use Origin Access Control instead of a public website bucket. |
| Cognito | 10,000 monthly active users free on the Lite and Essentials tiers. User pools created before November 22, 2024 keep the older 50,000 limit. The Plus tier has no free users. SMS and email still cost money through SNS and SES. |
| API Gateway | Not always free. Older accounts got 1 million free calls a month for 12 months. Newer accounts get credits and then pay. HTTP APIs cost about $1 per million requests, and REST APIs about $3.50 per million. |
| Lambda | Always free: 1 million requests and 400,000 GB-seconds a month. |
| DynamoDB | Always free in provisioned mode on the Standard table class: 25 GB of storage plus 25 read and 25 write capacity units. The "about 200 million requests" figure you'll see is calculated from that capacity. On-demand mode has no free requests. |
| SQS | Always free: 1 million requests a month. |
| EventBridge | Events from AWS services are free, but custom events are not. EventBridge Scheduler is a separate product with its own free tier. |
Watch for hidden costs too: a Route 53 hosted zone, CloudWatch logs, WAF, a NAT Gateway if Lambda has to run inside a VPC, and data transfer once you go past the free limits.
This design costs nothing when idle, but it isn't built for a busy marketplace. Lambda and DynamoDB still run into trouble with hot partitions, sudden traffic spikes, and API Gateway and Cognito limits. Say so plainly if you write it up as a case study.
Summary
- On a small AWS app, the NAT Gateway is usually the first thing to remove. That doesn't mean the ALB has to go next.
- A public EC2 server needs to be locked down behind CloudFront. Use the prefix list (it counts as 55 rules), one origin port, and a secret header, and don't use a basic Fail2ban setup. Use VPC origins if you can.
- ECS Express Mode is a good default for managed containers running a small HTTPS API. Budget for the ALB plus Fargate for each task, not "$30 for five services."
- Serverless is a good default for a portfolio demo. Treat $0 as a set of limits with a billing alarm, not as something that will handle marketplace traffic.
For the Google Cloud version of the same idea, blocking junk traffic and cutting fixed costs before requests reach your app, see Stopping bot traffic from running up your Cloud Run bill.