"Should we use an ALB or Nginx?" comes up the moment a product outgrows its first server. Both can terminate HTTPS, route requests by path and spread traffic across several Node.js processes. The difference is what they are: Nginx is software you run on a server you manage; an Application Load Balancer is a managed AWS service that runs across several Availability Zones and that you never patch.
That difference decides most of the answer. Here's how they compare feature by feature, what an ALB really costs, when each one wins, when running both makes sense, and the settings that stop the infamous random 502s between an ALB and Node.js.
Part of my guide to AWS infrastructure for small SaaS teams.
TL;DR#
- One server: use Nginx. It's free, does HTTPS with Let's Encrypt, caching, rate limiting and static files, and can balance across processes or a couple of servers.
- Two or more instances, Auto Scaling or ECS: use an ALB. It's highly available across AZs by design, gets free auto-renewing TLS certificates from ACM, health-checks targets and plugs into WAF.
- An ALB costs about $25–30 a month at low traffic in us-east-1, including its public IPv4 addresses.
- Run both only when you need something the ALB can't do on the instances, like response caching. Otherwise ALB → Node.js directly is simpler.
- Set Node's
keepAliveTimeoutabove the load balancer's idle timeout (65 seconds for the ALB's default 60), or you'll see intermittent 502s.
What each one is#
Nginx is a web server and reverse proxy. You install it on a Linux server, and it does whatever its configuration says: terminate TLS, serve files, cache responses, rate-limit clients, and balance requests across upstream servers. It's fast, free and endlessly flexible. It's also one more server process you configure, patch and keep alive, and on its own it's a single point of failure.
An Application Load Balancer is AWS's managed layer 7 (HTTP) load balancer. You don't see servers: you see listeners, rules and target groups. AWS runs it in every Availability Zone you enable, scales it with your traffic, and replaces failed nodes. You give up most of Nginx's flexibility in exchange for never thinking about the load balancer's own uptime.
Side by side#
| Nginx (open source) | AWS ALB | |
|---|---|---|
| Runs on | Your server | AWS, across 2+ Availability Zones |
| High availability of the balancer itself | Up to you (a second server plus failover) | Built in |
| TLS certificates | Let's Encrypt with Certbot | ACM: free public certificates, renewed automatically |
| Routing | Anything: paths, hosts, headers, regex, rewrites | Path, host, header, method, query string and source IP rules |
| Health checks | Passive only (max_fails); active checks need NGINX Plus |
Active, per target group |
| Rate limiting | limit_req, per second, per key |
Only through AWS WAF rate-based rules, counted over minutes |
| Response caching | proxy_cache |
None (use CloudFront) |
| Static files | Serves them directly | No |
| WebSockets, HTTP/2, gRPC | Yes | Yes |
| Sticky sessions | ip_hash or hash |
Load balancer or application cookies |
| Weighted routing (canary, blue-green) | weight per server |
Weighted target groups |
| Auto Scaling and ECS integration | Manual | Native: targets register themselves |
| Web application firewall | ModSecurity or similar, self-managed | AWS WAF, managed rule groups |
| Built-in login (OIDC, Cognito) | Third-party modules | Yes |
| Price | Free, plus the server it runs on | About $16 a month plus usage |
Both carry long-lived connections such as WebSockets and Server-Sent Events, as long as the idle timeout outlasts your heartbeat. Server-Sent Events vs WebSockets covers the heartbeat and the proxy settings a stream needs.
What an ALB costs#
In us-east-1, an ALB is billed for three things:
- Hours: $0.0225 an hour, about $16.43 a month.
- LCUs (Load Balancer Capacity Units): $0.008 per LCU-hour. One LCU covers, per hour, the highest of 25 new connections a second, 3,000 active connections a minute, 1 GB of traffic to EC2 targets or 1,000 rule evaluations a second. A small API uses about one: about $6 a month.
- Public IPv4 addresses: an internet-facing ALB has at least one per enabled AZ, at $0.005 an hour each: about $7.30 a month for two AZs.
So a small, internet-facing ALB costs roughly $25–30 a month, rising with traffic. Nginx costs nothing on a server you already run, but making Nginx itself highly available means a second server and failover, and on AWS there's no shared virtual IP to float between them, so you'd script an Elastic IP move or put a load balancer in front anyway.
When Nginx alone is enough#
If everything runs on one server, an ALB adds cost and a hop without removing the single point of failure: the server is still one machine. Nginx on that server is the right tool. It handles HTTPS, compression, rate limits and static files, and it can balance across several Node.js processes or containers on the box, or across a couple of servers:
upstream api {
least_conn;
server 127.0.0.1:3001 max_fails=3 fail_timeout=30s;
server 127.0.0.1:3002 max_fails=3 fail_timeout=30s;
keepalive 32;
}
server {
listen 443 ssl;
http2 on; # Nginx 1.25.1+; on older versions: listen 443 ssl http2;
server_name api.example.com;
# ssl_certificate and ssl_certificate_key, from Certbot
location / {
proxy_pass http://api;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_next_upstream error timeout http_502 http_503;
}
}
least_connsends each request to the upstream with the fewest active connections, which suits APIs with uneven request times better than the default round robin.max_failsandfail_timeoutare Nginx's passive health check: after 3 failures within 30 seconds, an upstream is skipped for 30 seconds.keepalivewith HTTP/1.1 and an emptyConnectionheader reuses connections to Node.js instead of opening one per request.proxy_next_upstreamretries a failed request on the next upstream. Nginx never retries non-idempotent requests likePOSTunless you explicitly allow it.
The full setup, with Let's Encrypt, is in Nginx reverse proxy for Node.js with free SSL.
When you need an ALB#
Move to an ALB when any of these is true:
- You run two or more instances and the product can't go down when one does. Put them in at least two AZs and the ALB stops sending traffic to an unhealthy one within seconds.
- Instances come and go: an Auto Scaling group, ECS tasks or EKS pods register with the target group by themselves.
- You want TLS you never touch. ACM certificates are free, renew automatically and never sit on your servers.
- You need AWS WAF in front of the app, for managed rules against common attacks, bot control or rate-based blocking.
- Nobody wants to own the proxy. No config to patch, no Nginx process to monitor.
When to run both#
ALB → Nginx → Node.js is common, and often unnecessary. Each hop adds latency, configuration and a place for timeouts to disagree. It earns its place when the instances need something only Nginx does:
- Response caching on the instance (or put CloudFront in front of the ALB instead).
- Per-second rate limiting by client or API key.
- Serving large static files from the instance's disk.
- Complex rewrites or legacy routing the ALB's rules can't express.
If you do run both, Express sees two proxies: set app.set('trust proxy', 2), so req.ip is the visitor's address and not the load balancer's.
Setting up an ALB for Node.js properly#
A few settings make the difference between a smooth ALB and a flaky one.
Match keep-alive timeouts. This one causes the classic "random 502" with Node.js. The ALB keeps idle connections to your targets open for its idle timeout, 60 seconds by default. Node's HTTP server closes idle connections after 5 seconds. Every so often the ALB sends a request down a connection Node is closing at that very moment, and the user gets a 502. Make Node wait longer than the load balancer:
const server = app.listen(3000);
// Longer than the ALB's 60-second idle timeout (and Nginx's 60-second upstream keepalive), so the
// load balancer always closes idle connections first.
server.keepAliveTimeout = 65_000;
server.headersTimeout = 66_000; // must be longer than keepAliveTimeout
Health checks that mean something. Point the target group at a /health route that checks the database. The defaults (every 30 seconds, 5 successes to become healthy) make a new instance wait 2.5 minutes before it takes traffic. An interval of 10 seconds and a healthy threshold of 2 to 3 is usually better.
Shorter deregistration delay. When a target is removed, the ALB waits for in-flight requests to finish, 300 seconds by default. For an API whose requests take seconds, 30 makes every deploy minutes faster.
Redirect HTTP to HTTPS with a listener on port 80 whose default action is a redirect, rather than letting the app do it.
Trust the proxy. The ALB adds X-Forwarded-For and X-Forwarded-Proto; with app.set('trust proxy', 1) Express reads the visitor's IP and the original protocol from them.
Only the ALB reaches the app. The instances' security group should admit the app's port from the ALB's security group, and nothing else. VPCs, subnets and security groups explained shows that chain.
Moving from Nginx to an ALB without downtime#
- Lower your DNS record's TTL to 60 seconds a day ahead.
- Create the ALB, its target group and an ACM certificate (DNS validation is quickest).
- Register the existing instances and wait for them to show healthy.
- Point DNS at the ALB, as a Route 53 alias record if you use Route 53.
- Keep Nginx running until traffic to it stops, then remove it, or keep it behind the ALB if you need what it does.
How to choose#
| Your situation | Use |
|---|---|
| One server, one or a few apps | Nginx |
| Two or more instances, or an Auto Scaling group | ALB |
| ECS, or EKS with the AWS Load Balancer Controller | ALB |
| Response caching or per-second rate limits on the instances | Nginx, behind an ALB if you need high availability |
| Static IP addresses, TCP or UDP | A Network Load Balancer instead |
| Tight budget, and a minute of downtime during a reboot is acceptable | Nginx |
FAQ#
Is an AWS ALB better than Nginx?#
Neither is better in general. An ALB is a managed, highly available load balancer that integrates with Auto Scaling, ECS, ACM certificates and AWS WAF, and costs about $25–30 a month at low traffic. Nginx is free and more flexible, with caching, rate limiting and static files, but runs on a server you manage. Use Nginx on a single server and an ALB once you run several instances.
Do I need Nginx behind an AWS load balancer?#
Usually not. An ALB can send traffic straight to Node.js, and it handles TLS, routing and health checks. Keep Nginx behind it only when the instances need something the ALB can't do, such as response caching, per-second rate limiting or serving static files, and then set Express's trust proxy to 2.
How much does an AWS Application Load Balancer cost?#
In us-east-1, $0.0225 an hour (about $16 a month), plus $0.008 per LCU-hour for usage, plus $0.005 an hour for each public IPv4 address, at least one per Availability Zone. A small internet-facing ALB in two zones comes to roughly $25–30 a month, rising with traffic.
Why does my AWS load balancer return random 502 errors with Node.js?#
Usually because Node.js closes idle keep-alive connections after 5 seconds, while the ALB keeps them for 60, so the ALB occasionally reuses a connection Node is closing. Set server.keepAliveTimeout to 65 seconds and server.headersTimeout slightly higher, so the load balancer always closes idle connections first.
Can Nginx do load balancing?#
Yes. An upstream block lists the servers, and Nginx balances requests across them with round robin, least connections or IP hash, retrying failed requests on another server. Open-source Nginx has passive health checks only; active health checks require NGINX Plus.
