The app listens on port 3000.
node server.js
# listening on 0.0.0.0:3000
On a laptop that is the whole story. It answers on localhost:3000 and there is nothing more to think about.
Going live changes the shape of the problem. There’s a domain now, HTTPS on 443, and the same process is reachable from the whole internet. The quick move is to make the app listen on 443 itself. That holds for about a day — until it also has to terminate TLS and keep the certificate renewed, serve static files without tying up a worker, ride out restarts without dropping in-flight requests, throttle a client pounding the login route, and eventually run as several processes behind one address.
None of that is application work. The app is the wrong place to put it.
Table of contents
Open Table of contents
- The Problem Nginx Is Solving
- The Big Idea
- How Nginx Is Built
- How A Request Flows
- Server Blocks
- Location Blocks
- proxy_pass And The Trailing Slash
- Passing The Real Client Information
- WebSockets Need An Upgrade
- Buffering And Timeouts
- HTTP/2 vs HTTP/1.1
- Compressing Responses
- Tuning Workers To The Server
- Rate Limiting
- Hiding Server Information
- Restricting Who Can Embed Your Site
- Multiple Backends With Upstream
- Failure Modes
- A Practical Config
- Operating Nginx
- Final Thoughts
The Problem Nginx Is Solving
Running application logic is what an application process is good at. Standing between the public internet and that logic is a different job, and most of it is the unglamorous work of saying no.
When the app is the only layer, the list it has to own gets long:
- terminating TLS, and renewing the certificate before it lapses
- serving static files without spending an app worker on each one
- holding open the sockets of slow clients
- enforcing request size limits and timeouts
- rate limiting and the rest of abuse control
- routing different paths to different services
- staying reachable while the app itself is restarting
Nginx exists to lift that whole column off the app. It sits in front and owns the public-facing work, and the process behind it goes back to running code.
The Big Idea
Nginx becomes the single front door.
The public talks to Nginx. Nginx talks to the app over a private connection that never leaves the box, or the private network.
client -> nginx (443, TLS) -> app (3000, plain http)
The app stops facing the internet. It listens on a local port only Nginx can reach, while Nginx holds everything exposed to the outside: the certificate, the public port, the timeouts, the limits.
The word “reverse” is carrying weight here. A forward proxy stands in front of clients and reaches out to servers for them. Turn that around — one proxy in front of your servers, fielding requests from many clients on their behalf — and you have a reverse proxy.
How Nginx Is Built
Nginx runs as one master process and a set of worker processes.
master process
├── worker process
├── worker process
└── worker process
The master reads the config, binds the ports, and manages the workers. It never serves a request itself. On a reload, the master is what brings up new workers and lets the old ones drain their in-flight requests before they exit.
The workers do the serving. Each one is a single process running an event loop.
Compare that to the model Apache used by default for years: a thread or process per connection. A thousand idle clients meant a thousand threads sitting around, each holding memory and making the kernel switch between them.
A worker in Nginx works differently. It keeps thousands of connections open at once and only touches one when something actually happens on it, through the kernel’s event notification — epoll on Linux. While a connection waits on the network, the worker isn’t stuck waiting with it. It moves on to the next one that has something to do.
So a slow client costs Nginx almost nothing. That same client would pin an entire worker in a thread-per-connection server. It’s the reason Nginx goes out front: it soaks up the slow and idle connections and hands the app only complete, ready requests.
How A Request Flows
A request to https://example.com/api/users goes through a few steps.
- The client opens a TLS connection to Nginx on port 443.
- Nginx terminates TLS and now has a plain HTTP request.
- Nginx matches the request against its
serverandlocationrules. - Nginx opens a connection to the app on
127.0.0.1:3000. - The app responds to Nginx.
- Nginx sends the response back to the client over the encrypted connection.
From the app’s side it’s just a plain HTTP request arriving from Nginx on the local machine. No TLS handshake, no direct view of the client.
Server Blocks
A server block tells Nginx how to handle traffic for a given name and port.
server {
listen 443 ssl;
server_name example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
location / {
proxy_pass http://127.0.0.1:3000;
}
}
listen 443 ssl means this block handles HTTPS.
server_name decides which block answers when a request arrives. One Nginx instance can hold many server blocks for many domains on the same port, and the Host header picks the right one.
The ssl_certificate lines point at the certificate and private key. Nginx uses them to terminate TLS.
Location Blocks
A location block decides what happens for a path.
location /api/ {
proxy_pass http://127.0.0.1:3000;
}
location /static/ {
root /var/www/app;
}
Requests under /api/ go to the application.
Requests under /static/ are read off disk and sent back directly. The application never runs for them. That is the point of the split: files go out fast from disk, and the app’s workers stay free for the requests that actually need code to run.
proxy_pass And The Trailing Slash
proxy_pass is the line that forwards a request to the backend. One detail of it is easy to miss the first time round: the trailing slash changes how the path gets rewritten.
location /api/ {
proxy_pass http://127.0.0.1:3000/;
}
With the trailing slash on proxy_pass, a request to /api/users reaches the app as /users. Nginx strips the matched /api/ prefix.
location /api/ {
proxy_pass http://127.0.0.1:3000;
}
Without it, the same request reaches the app as /api/users. The full path is passed through.
Passing The Real Client Information
The app sees the request coming from Nginx, so by default it thinks every client is 127.0.0.1.
That breaks logging, rate limiting, and anything that depends on the client address or the original protocol.
Nginx has to pass that information forward as headers.
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
Host carries the domain the client actually asked for. Without this line, the backend receives the proxy_pass target as its host, which is something like 127.0.0.1:3000. Any logic that depends on the requested domain breaks.
X-Real-IP carries the client address as a single value.
X-Forwarded-For carries the chain of client addresses the request passed through. The value $proxy_add_x_forwarded_for takes any existing X-Forwarded-For header and appends the current client’s address to it. With one proxy this is just the client IP. With several proxies in front, each hop adds its caller, so the app can read the original client at the start of the list.
X-Forwarded-Proto tells the app whether the original request was http or https. The app needs this because, after TLS termination, the request arriving at the app is plain HTTP. Without this header, an app trying to build absolute https:// URLs may build http:// ones instead.
WebSockets Need An Upgrade
A plain proxy_pass does not carry a WebSocket connection. The connection starts as HTTP and asks to upgrade, and Nginx has to forward that upgrade.
location /ws/ {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
Without these lines, the WebSocket handshake fails and the connection keeps falling back or dropping.
Buffering And Timeouts
When the backend sends a response, Nginx doesn’t have to relay it byte by byte. By default it pulls the whole response from the app into a buffer as fast as the app can produce it, then drips it out to the client at whatever pace the client can manage.
That gap between a fast app and a slow client is the whole reason this exists. Picture a phone on weak signal pulling down a response: without buffering, it keeps an app worker busy for the entire transfer. With buffering, the app hands the response to Nginx in milliseconds and moves on, and Nginx is left to babysit the slow connection.
location / {
proxy_pass http://app;
proxy_buffering on;
proxy_buffers 8 16k;
proxy_buffer_size 16k;
}
proxy_buffering is the master switch for the response side, on by default. proxy_buffers sets how many buffers there are and how big. When a response outgrows them, Nginx spills the rest to a temp file on disk — slower — so it pays to size the buffers around your typical response.
Streaming is where you turn it off. There the client wants bytes the moment they are produced, not after the whole response is assembled — server-sent events and long-polling are the usual cases.
location /events/ {
proxy_pass http://app;
proxy_buffering off;
}
The same thing happens in the other direction. By default Nginx reads the entire request body from the client before it even opens a connection to the backend. That is proxy_request_buffering, also on by default.
location /upload/ {
proxy_pass http://app;
proxy_request_buffering off;
}
With request buffering on, a slow upload becomes Nginx’s problem to hold. It collects the full body and hands the backend one complete request, so no backend worker sits tied up for the length of the transfer. Turn it off and the body streams straight through as it arrives — handy when uploads are huge and you would rather not buffer them in Nginx, at the cost of holding a backend connection open the whole time.
Timeouts decide how long Nginx waits at each stage. They come in two groups: timers for the connection to the backend, and timers for the connection to the client.
# backend side
proxy_connect_timeout 5s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
# client side
client_header_timeout 10s;
client_body_timeout 10s;
keepalive_timeout 65s;
proxy_connect_timeout is how long to wait to open the connection to the backend. A low value here fails fast when a backend is down instead of hanging.
Once the connection is open, proxy_send_timeout governs sending the request: how long Nginx waits between successive writes to the backend. It comes into play on large uploads. If the backend stops reading and its receive buffer fills, the writes stall, and when none gets through for this long, Nginx closes the connection. The clock resets on each successful write, so it caps the pause between writes — a slow but steady upload won’t trip it.
proxy_read_timeout is how long to wait between reads once the backend has started responding. This is the timer behind a 504: the backend went quiet for longer than the limit and Nginx gave up. You can raise it to buy room for genuinely slow work. But if a request truly needs a 120-second read timeout, the honest fix is usually to move that work into a background job.
client_header_timeout and client_body_timeout cap how long a client can take to send its headers and body. They cut off clients that open a connection and then dribble bytes slowly to hold a worker open, which is the shape of a slowloris attack.
keepalive_timeout is how long an idle client connection stays open for reuse before Nginx closes it.
These are the timers worth knowing first. There are more, and the exact rules for when each one resets matter once you start tuning, so read the proxy module and core module docs before changing them in production.
HTTP/2 vs HTTP/1.1
Under HTTP/1.1, a connection carries one request at a time. The next request on that connection waits for the previous response to finish. Browsers work around this by opening several connections to the same host, usually around six, and spreading requests across them. Each connection costs a TLS handshake and its own memory.
HTTP/2 changes the shape of the connection. One connection carries many requests at once as independent streams, so a page with dozens of small assets does not need a pile of parallel connections. It also compresses headers and uses a binary framing instead of plain text.
Turning it on in Nginx is one line.
server {
listen 443 ssl;
http2 on;
server_name example.com;
}
Older Nginx versions wrote this as listen 443 ssl http2;. Either way, browsers only speak HTTP/2 over TLS, so it travels with HTTPS.
Nginx talks HTTP/2 out to the client and, in almost every setup, plain HTTP/1.1 back to the app. The multiplexing payoff lives entirely on the browser-to-Nginx hop. The hop from Nginx to your app doesn’t get it, and mostly doesn’t need it.
Compressing Responses
Text responses like HTML, CSS, JavaScript, and JSON compress well. Sending them compressed cuts transfer size, which is the slowest part of a request for a user on a phone or a far-away network. Nginx can compress on the way out with gzip.
gzip on;
gzip_types text/css application/javascript application/json image/svg+xml;
gzip_min_length 1024;
gzip_comp_level 5;
gzip_types lists what to compress. HTML is always included, so it does not appear in the list. Images and video in their normal formats are already compressed, so running gzip over them burns CPU for no gain.
gzip_min_length skips tiny responses, where the compression overhead is larger than the saving.
gzip_comp_level trades CPU for size. Higher levels compress a little more but cost more CPU per response, and the gain past the middle of the range is small. A level around 5 is a reasonable balance for most sites.
The cost is real: compression uses CPU on every response it touches. For a busy site serving large text payloads, that CPU is well spent. For static assets that never change, compressing them once ahead of time and serving the precompressed file avoids paying for it on every request.
Tuning Workers To The Server
worker_processes auto;
worker_rlimit_nofile 65535;
events {
worker_connections 16384;
}
worker_processes auto starts one worker per CPU core. One event loop per core, running in parallel rather than fighting over a single core. Set the number well above the core count and nothing improves. Those extra workers only compete for CPUs that are already busy.
worker_connections is how many connections a single worker can hold at once. Multiply it by the worker count and you get the figure people quote as the limit:
max connections = worker_processes * worker_connections
That number is only an upper bound. What a server actually sustains depends on how the connections get used and which resource runs out first. Keep-alive is the obvious example: it holds connections open for reuse, which saves repeated TCP handshakes, but every idle one still occupies a slot and a file descriptor. HTTP/2 pushes the other way — many requests ride a single connection, so the raw connection count understates the real work. As a reverse proxy, Nginx often opens a second connection to the backend, so one request can tie up two descriptors at once. The real ceiling is rarely the number you configured. It’s whichever resource gives out first: file descriptors, memory, CPU, or network bandwidth.
Of those, file descriptors are usually the first resource to run out. worker_rlimit_nofile is the setting that controls them. Every connection needs a file descriptor, and the OS caps how many a single process can open. Set worker_connections high while that cap stays low, and Nginx hits the descriptor limit before anything else. The error log fills with:
socket() failed (24: Too many open files)
The descriptor limit lives in two places: inside Nginx and in the OS, so both have to be increased. worker_rlimit_nofile raises it inside Nginx. The OS side is separate: ulimit -n for the shell, /etc/security/limits.conf for the user, and LimitNOFILE when Nginx runs under systemd. Raise only the Nginx number while the OS still caps a worker process at 1024 open files, and the effective limit remains 1024.
A worker also uses file descriptors for more than client connections. Log files, upstream sockets, and temporary files each consume descriptors. So give the limit some headroom above worker_connections.
Rate Limiting
Without a limit, one client can send requests as fast as the network allows, and a login endpoint or a search route becomes easy to hammer.
Nginx rate limiting works in two parts: a zone that tracks clients, and a rule that applies a rate.
http {
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
}
server {
location /api/ {
limit_req zone=api burst=20 nodelay;
proxy_pass http://app;
}
}
limit_req_zone defines the tracking. It keys on $binary_remote_addr, which is the client IP in a compact form, reserves 10 MB of shared memory to hold the counters, and sets a steady rate of 10 requests per second.
limit_req applies that zone to a location. burst=20 lets a client briefly go over the rate by queueing up to 20 extra requests, which covers normal bursts like a page firing several API calls at once. nodelay serves that burst immediately instead of spacing the requests out to fit the rate.
Over the limit, Nginx rejects the request. The default status is 503, which you can change to the more accurate 429:
limit_req_status 429;
Behind a CDN or another load balancer, this needs care. The key is the client IP, but in that setup $remote_addr holds the upstream proxy’s IP, not the user’s. Suddenly every request looks like it came from one client, and the whole internet shares a single bucket. To limit real users there, Nginx has to recover the true client IP from X-Forwarded-For with the real_ip module before the limit runs.
Hiding Server Information
By default, Nginx announces its version in every response and on its error pages.
Server: nginx/1.25.3
That version number is a gift to anyone scanning for hosts running a release with a known vulnerability. Turning it off removes the version.
http {
server_tokens off;
}
The response then says Server: nginx with no version.
One thing to be exact about: server_tokens off removes the version number, but the Server header still reads nginx. Hiding or renaming that takes more than this directive.
Restricting Who Can Embed Your Site
If any other site can load your pages inside an iframe, it can overlay its own controls on top of yours and trick a logged-in user into clicking something they did not intend. Telling browsers who is allowed to frame your site shuts that down.
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Content-Security-Policy "frame-ancestors 'self'" always;
X-Frame-Options: SAMEORIGIN is the older header, understood everywhere. frame-ancestors 'self' in a Content-Security-Policy is the modern replacement and takes priority in browsers that support it. Sending both covers old and new clients. Use 'none' or DENY if no one, including you, should ever frame the page.
Two Nginx details decide whether these headers actually show up.
The always parameter makes Nginx send the header on every response, including errors. Without it, add_header only applies to a set of success and redirect codes, so a 404 or 500 page would go out unprotected.
The second detail is inheritance, and it is a quiet one. Set add_header in the server block, then use add_header again inside a location, and the location’s headers don’t stack on top of the inherited ones — they replace them wholesale. A location that adds one header of its own silently drops every security header from the server block. Safest habit: define these in one place, or repeat them in each block that sets its own.
Multiple Backends With Upstream
One app process is a single point of failure and a single core’s worth of capacity. Running several and putting Nginx in front of them spreads the load.
An upstream block names a group of backends.
upstream app {
server 127.0.0.1:3000;
server 127.0.0.1:3001;
server 127.0.0.1:3002;
}
server {
listen 443 ssl;
http2 on;
server_name example.com;
location / {
proxy_pass http://app;
}
}
Nginx spreads requests across the three in round-robin order by default. Other methods exist for when round-robin is not a good fit: least_conn sends each request to the backend with the fewest active connections, and ip_hash pins a client to the same backend by its IP.
If one backend stops responding, Nginx marks it as failed for a while and sends traffic to the others. The site stays up on the remaining processes while one is restarting or deploying.
That is load balancing, with one honest limit worth stating. Nginx balances across backends it can open a connection to, and it judges health mostly by whether that connection succeeds. Whether the work inside a backend is actually healthy is past what it can see.
Failure Modes
The errors Nginx returns point at where the failure is.
502 Bad Gateway means Nginx reached the backend but could not get a valid response. Usually the app is down, crashed, or listening on a different port than the config expects.
curl -I http://127.0.0.1:3000
If that fails from the same host, Nginx will fail too.
504 Gateway Timeout is the backend accepting the connection and then taking too long to answer. The app is alive, just slow.
proxy_read_timeout 60s;
Raising the timeout treats the symptom. A 504 usually means the request itself is doing too much work.
413 Request Entity Too Large fires when the request body crosses Nginx’s limit before it ever reaches the app. Uploads hit this one first.
client_max_body_size 25m;
429 Too Many Requests is the rate limiter talking, before the request reaches the app at all. The client is sending faster than the configured rate allows.
Too many open files in the error log means the connection load crossed the file descriptor limit, which ties straight back to worker_connections and worker_rlimit_nofile.
A Practical Config
A setup that pulls the pieces together: worker tuning, HTTP to HTTPS redirect, HTTP/2, gzip, rate limiting, security headers, static files from disk, and everything else proxied to the app.
worker_processes auto;
worker_rlimit_nofile 65535;
events {
worker_connections 16384;
}
http {
server_tokens off;
gzip on;
gzip_types text/css application/javascript application/json image/svg+xml;
gzip_min_length 1024;
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
upstream app {
server 127.0.0.1:3000;
}
server {
listen 80;
server_name example.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
http2 on;
server_name example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
client_max_body_size 25m;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Content-Security-Policy "frame-ancestors 'self'" always;
location /static/ {
root /var/www/app;
expires 30d;
}
location /api/ {
limit_req zone=api burst=20 nodelay;
limit_req_status 429;
proxy_pass http://app;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 60s;
}
location / {
proxy_pass http://app;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
}
The port 80 block exists only to bounce traffic up to 443. The 443 block does the real work: terminates TLS, speaks HTTP/2, serves static files with a cache header, rate limits the API, and proxies everything else to the app.
Operating Nginx
Two commands cover most of the day-to-day.
Check the config before applying it:
nginx -t
This catches syntax errors and bad paths. Running it before a reload avoids taking the site down with a broken config.
Apply a new config without dropping connections:
nginx -s reload
A reload starts new worker processes with the new config and lets the old workers finish their in-flight requests before exiting. Existing requests are not cut off.
When something is wrong, the logs say where to look.
tail -f /var/log/nginx/access.log
tail -f /var/log/nginx/error.log
The access log shows what came in and what status went back out. The error log shows why a request failed inside Nginx, down to the upstream address it tried to reach. A 502 there usually names the backend that refused the connection, which tells you the problem is in the app rather than in Nginx.
Final Thoughts
The reason Nginx goes in front of an app is to keep two jobs from bleeding into each other.
The app runs application logic on a private port, and stays there.
Nginx takes the public side: TLS, HTTP/2, static files, rate limits, security headers, traffic spread across a few backends. The worker-and-event-loop design underneath is what lets it sit on a pile of slow connections so cheaply. Most of the config in this post is that design turned into numbers, and the only numbers worth setting are the ones the machine can actually back.