Nginx Isn't Acting Up — You're Just Reading the Config Wrong

The core idea: the config is a declaration, not a script
This is the root of nearly every misunderstanding about Nginx. Burn these three things into your memory:
First, Nginx doesn't run top to bottom in the order you wrote it. When a request arrives, Nginx doesn't "walk through" the location blocks like an if-else chain. It selects exactly one best-matching location block based on a priority algorithm (more on that below), then executes the directives inside that block.
Second, directives are inherited by context. Configuration flows from the outside in: http → server → location. A directive set at the server level applies to every location below it — unless it's overridden. Simple enough, except there's a special group of directives that inherit in an all-or-nothing way, which I'll point out in trap #5.
Third, once you grasp the first two, the classic "gotchas" below stop being magic. They're all logical consequences of the same set of rules.
Keep this framework in your head as you read on.
Why Nginx deserves its spot at the front line
Before dissecting the traps, it helps to understand why practically the whole industry trusts it to guard the gate.
One worker, ten thousand connections
A traditional app server (think Apache prefork, or a Tomcat thread pool) serves on a "one thread per request" model: a customer walks in, you assign an employee to follow them around. The moment that customer slows down to study the menu — slow I/O, laggy network — that employee is stuck standing there, waiting. Ten thousand customers at once means ten thousand employees; RAM evaporates and the system collapses.
Nginx takes a different route: the event-driven model. Each worker process runs an event loop and uses non-blocking I/O (epoll on Linux) to hold thousands of connections at once. The worker takes this customer's order, throws it to the kitchen, turns to take the next order, and comes back to serve each dish as the kitchen finishes it — nobody stands around waiting on anyone. This is exactly why Nginx was born: Igor Sysoev wrote it in the early 2000s to solve the C10k problem — serving ten thousand concurrent connections on a single server without falling over. The result: a tiny RAM footprint with enormous connection capacity.
A shield in front of the backend
Standing at the very edge, Nginx also takes on the heavy, repetitive work: the SSL/TLS handshake, compression (Gzip/Brotli), rate limiting, filtering out junk traffic, serving static files. That frees the backend to focus on the one thing that matters: business logic. Separating the "public-facing" work from the "processing" work — that's the real architectural value of a reverse proxy, not just "adding a layer to look fancy."
Seven traps — and why every one of them is completely predictable
1. The fateful / in proxy_pass
This is the legendary 404 that has people questioning their life choices.
# Option A — proxy_pass with ONLY a host, no URI
location /api/ {
proxy_pass http://backend;
}
# /api/users -> Backend receives: /api/users (unchanged)
# Option A — proxy_pass with ONLY a host, no URI
location /api/ {
proxy_pass http://backend;
}
# /api/users -> Backend receives: /api/users (unchanged)The rule lives right here, and it's absolutely consistent: if proxy_pass carries a URI part (even just a /), Nginx replaces the exact segment that matched in the location with that URI. If proxy_pass has only a host, Nginx forwards the original URI untouched. No exceptions, no randomness. When the backend throws a 404 and you're tearing your hair out, the first thing to do is turn on the backend's logging and see what path it actually received — the answer is almost always sitting in that trailing slash.
2. Location matching priority
You want to block a sensitive directory, so you write an actual deny all block. But Nginx leaves the door wide open anyway.
location /uploads/ { # plain prefix — LOW priority
deny all;
}
location ~* \.(jpg|jpeg|png)$ { # regex — HIGHER priority than a plain prefix
proxy_pass http://backend;
}
# /uploads/secret.jpg -> matches the regex first -> deny all is skipped -> file leakedRemember the core idea: Nginx doesn't evaluate top to bottom, it picks based on the priority of the operator type. The exact order is: exact match = → priority prefix ^~ → regex (~, ~*) in order of appearance → and finally plain prefix (longest match wins). The ~* \.jpg$ block is a regex, so it sails right past location /uploads/, which is only a plain prefix. The fix is to bump up the priority of the blocking block:
location ^~ /uploads/ { # ^~ : once this prefix matches, stop — don't even check regexes
deny all;
}Not Nginx's fault — you just didn't know the rules.
3. The 1MB default and the 413 gut-punch
On your local machine, uploads fly through. You Dockerize it, ship it to the server, a user uploads a 5MB avatar — boom, 413 Request Entity Too Large. The client complains, the boss starts asking questions.
The reason: by default, Nginx caps the request body at 1MB (client_max_body_size 1m). This is a textbook case of the "safe defaults" philosophy Nginx follows — and those safe defaults are often downright hostile to production if you don't tune them. The fix is easy:
client_max_body_size 20M;
But remember two things: put it in the right context (http, server, or a dedicated upload location), and don't be reckless and set it to 0 (unlimited) on a public endpoint — that's an open invitation to a memory-exhaustion attack.
4. Failover isn't as instant as you think
You've got two backends behind load balancing, and one crashes. You're certain Nginx will switch to the surviving one immediately? It will — but whether "immediately" actually happens depends on how the other one died:
If the server dies by refusing connections (process killed, port closed, returns an RST): the connection fails instantly and Nginx moves to the other one almost immediately. The user barely notices.
If the server dies by going dark (whole machine down, firewall swallowing packets with no response): Nginx has to wait out the full
proxy_connect_timeout— 60 seconds by default — before it gives up. This is the real culprit behind the "endless loading / works sometimes, not others" that users hit.
Meanwhile, max_fails (default 1) and fail_timeout (default 10s) decide how many failures it takes before Nginx crosses that server off the list, and for how long. A realistic config should look like this:
upstream backend {
server 10.0.0.1:8080 max_fails=2 fail_timeout=10s;
server 10.0.0.2:8080 max_fails=2 fail_timeout=10s;
}
server {
location / {
proxy_pass http://backend;
proxy_connect_timeout 2s; # don't wait 60s on a server that's already dead
proxy_next_upstream error timeout http_502 http_503;
}
}
The deeper point: open-source Nginx only has passive health checks — it judges whether a server is alive based on actual failing requests, not by actively pinging it. So don't expect the defaults to cover everything: they assume one generic scenario, but your topology is something you have to declare yourself.
5. The trap nobody mentions: add_header and proxy_set_header swallow the parent config
This is the "all-or-nothing" group of directives I promised at the start — and it's the kind of thing that costs people hours without them ever suspecting it.
The rule: with add_header (and proxy_set_header), a child block inherits the parent's headers if and only if it doesn't declare a single directive of the same kind. The moment you add even one add_header inside a location, every inherited add_header from server/http vanishes for that location.
server {
add_header X-Frame-Options "DENY";
add_header Strict-Transport-Security "max-age=31536000";
location /static/ {
add_header Cache-Control "public, max-age=86400";
# The X-Frame-Options and HSTS above JUST EVAPORATED from /static/
}
}
You thought you were just adding one cache header, and you actually blew away the security headers at that location. Same logic with proxy_set_header: if you declare any proxy_set_header inside a location, you lose the inherited ones, and if you don't set Host yourself, the backend receives Host: $proxy_host (the name from proxy_pass) instead of the client's original Host — enough to break redirects or routing in plenty of apps. That's why these four lines are practically a good-luck charm every time you proxy:
location /api/ {
proxy_pass http://backend;
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;
}
6. CORS that "works sometimes, not others" — the classic phantom
CORS is the error frontend and backend devs blame on each other the most, and it's the "random" part that's truly maddening: the same endpoint works fine on one call, then the browser lights up red with "No 'Access-Control-Allow-Origin' header is present." Almost every "random" case traces back to one of three causes — and all three come straight from how Nginx handles headers.
Cause number one: the missing always. This is the sneakiest one. By default, Nginx's add_header only attaches a header when the response code is a 2xx or 3xx (200, 201, 204, 301, 302...). Which means: when the request succeeds, the browser sees all the CORS headers — works great. But the moment the backend returns an error (500, 401, 403), the CORS headers vanish, and instead of reporting the real 500, the browser screams "CORS error." So it's "random" in the most literal sense: CORS breaks exactly when the backend errors. The fix fits in a single keyword — always:
location /api/ {
add_header Access-Control-Allow-Origin "https://app.example.com" always;
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" always;
add_header Access-Control-Allow-Headers "Authorization, Content-Type" always;
# Handle the preflight right here in Nginx — don't make the browser wait on the backend
if ($request_method = OPTIONS) {
add_header Access-Control-Allow-Origin "https://app.example.com" always;
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" always;
add_header Access-Control-Allow-Headers "Authorization, Content-Type" always;
return 204;
}
proxy_pass http://backend;
}
(Notice the header set has to be repeated inside the if block — exactly like trap #5, headers declared outside don't carry into the if.)
Cause number two: the header set twice. If both Nginx and the backend set Access-Control-Allow-Origin, the browser receives two values and rejects it outright ("contains multiple values"). It looks "random" because on some paths only Nginx returns the header, while on others the backend returns it too. The rule: pick one single place responsible for CORS, don't split it.
Cause number three: preflight and inconsistent config. For a "non-simple" request (one with an Authorization header, a PUT/DELETE method...), the browser sends an OPTIONS preflight first. If Nginx forwards that OPTIONS to a backend that doesn't return CORS headers, the preflight fails and the real request is never sent. Likewise, if you run multiple Nginx nodes with mismatched CORS config, which node a request lands on decides whether CORS lives or dies — "random" again, courtesy of load balancing (trap #4).
7. The WAF blocking by mistake — disguised as a CORS error
You stand up a WAF (ModSecurity + the OWASP CRS, or a Cloudflare/AWS WAF) at the front line to block attacks. Then one day, a few users report operations failing at random — one person submits a form and gets a 403, another doesn't. You dig through the backend logs: spotless, the request never even arrived.
That's a WAF false positive: a legitimate request that happened to match an attack-detection rule. A user pastes some content that looks like SQL syntax, a payload with odd characters, a long base64 string... the WAF thinks it's SQL injection / XSS and kicks it out with a 403. It's "random" because it only fires on the exact payloads that trip a rule — most normal requests sail right through.
And here's where the two traps collide — the deepest part: the WAF blocks the request before it reaches the app, so that 403 response carries no CORS headers at all. The browser sees the missing Access-Control-Allow-Origin and reports a "CORS error." So you spend hours fixing CORS while the real culprit is a WAF rule quietly doing the blocking. The tell: the error shows up as CORS, but the real status is 403, and the backend logged no request.
The right move isn't to rip the WAF out (throwing away your shield) — it's to:
Read the WAF's own logs (ModSecurity's audit log), where the blocking rule ID and the real reason live — not guesswork from the browser screen.
Tune it: add a rule exclusion for the specific rule causing false positives on the specific endpoint, or lower the paranoia level if the CRS is too aggressive — instead of disabling everything.
Make sure the WAF/Nginx error page also attaches CORS headers (
alwaysagain), so at least the browser reports the real 403 instead of hiding it behind a CORS error.
The shared lesson of both traps: the browser saying "CORS" doesn't mean the bug is in CORS. Very often CORS is just the symptom; the disease is an error response, or a WAF doing its job while you forgot it was even there.
The 15 minutes worth spending: what to do before every deploy
No need to memorize — just walk through this checklist:
Set
client_max_body_sizeto match your app's real needs (don't let the 1MB default bite you).When proxying, always forward
Host,X-Real-IP,X-Forwarded-For,X-Forwarded-Proto.Double-check your
add_header: is a child location accidentally swallowing the parent's security headers?For load balancing, lower
proxy_connect_timeoutand declareproxy_next_upstreamso failover is decisive.Test edge paths against each
locationto see which one actually matches (=,^~, regex, prefix).Read every
/inproxy_passcarefully before blaming the backend.Add
alwaysto every CORS header so they survive even on error responses.When you see a "CORS error," check the real status (403 or 500?) and the WAF logs before touching CORS.
Wrapping up
Nginx is like a manual-transmission car: seriously quick, seriously fuel-efficient, able to handle the roughest terrain. But if you don't know how to shift gears, if you don't understand the rules, it'll lurch and throw you off the road without mercy.
The difference between someone who wrestles with Nginx and someone who rides it isn't memorizing more lines of config — it's grasping three things: the config is a declaration, locations are chosen by priority, and directives inherit by context (with a few all-or-nothing exceptions). Understand those three, and you stop debugging by trial and error — you predict the behavior before you even reload.
Don't just apt install nginx and grab a default config off the internet. Spend 15 minutes understanding how it routes requests, and your boss will say you really know your stuff, the system will run smooth — and most importantly, you'll sleep well on deploy night.
Which of these five traps have you been bitten by — or is there an even weirder one? Drop it in the comments so the rest of us can dodge it too.
Related reading
System designSix Stations, Six Ways to Die: A Request's Journey for Survival
Technical notes & reflection — on the road every request must travel, and why it travels that way.
Software EngineeringBreaking the Rules Safely: When a Tech Lead Purposefully Violates the Liskov Substitution Principle (LSP)
SOLID is not a religion, and design principles are not immutable commandments. From the perspective of a battle-tested Tech Lead, sometimes deciding to bend the Liskov Substitution Principle (LSP) is a mature choice to keep the system alive. Let’s analyze 4 classic trade-off scenarios and the art of safely isolating the 'toxic code'.
Software EngineeringLess Greedy Code, Less Misery: The Power of SRP Through a Battle-Tested Lens
Most critical production bugs don't come from bad algorithms—they come from cramming too many responsibilities into one place. This is a deep dive into the Single Responsibility Principle (SRP) from a pragmatic, real-world perspective: how the "convenience trap" breeds God Objects, why a typical order-processing function becomes a maintenance landmine, and how a Senior refactors it into clean, testable layers. Clean architecture isn't free—but when your project scales to 100 features, it's the very thing that keeps it from collapsing.
Discussion
0 Comments
Be the first to start the discussion.