Page 1 of 2
This is the single most common source of Nginx confusion, and getting it right will save you hours of head-scratching. A server block can contain many location blocks, and Nginx has to decide which one applies to a given request. There are four matching styles, and critically, they are not evaluated in the order they appear in your file.
location = /health {
# exact match: only matches the literal path "/health"
return 200 "ok";
}
location ^~ /static/ {
# preferential prefix match: stops regex checking if this wins
root /var/www;
}
location ~* \.(jpg|png|gif)$ {
# case-insensitive regex match
expires 30d;
}
location / {
# plain prefix match: matches anything, lowest priority
proxy_pass http://127.0.0.1:3000;
}
Here's the actual evaluation order Nginx uses, regardless of how the blocks are arranged in the file: first, it checks for an exact match (location = /path). If found, it's used immediately and nothing else is checked. If not, Nginx scans all prefix matches (plain paths, and ^~ paths) and remembers the longest one that matches. If that longest prefix match happens to be marked ^~, Nginx stops right there and uses it, skipping regex checks entirely. Otherwise, Nginx moves on to regex matches (~ for case-sensitive, ~* for case-insensitive), checked in the order they're written in the config, and uses the first one that matches. Only if no regex matches at all does Nginx fall back to that longest prefix match it remembered earlier. The practical takeaway: regex locations can silently override a prefix location you thought was more specific, and ^~ is your escape hatch when you need a prefix match to win outright, most often for a static asset directory you don't want falling into a regex block meant for something else.
When Nginx proxies a request, it opens a brand new connection to the backend by default. Unless you tell it otherwise, the backend sees that connection as coming from Nginx itself, on 127.0.0.1, with none of the original client's context. A handful of proxy_set_header directives fix this, and skipping them is one of the most common "why is my app behaving weird behind a proxy" bugs.
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host; # preserve the original domain
proxy_set_header X-Real-IP $remote_addr; # the actual client's IP
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; # client IP chain
proxy_set_header X-Forwarded-Proto $scheme; # was the original request http or https?
}
Without Host, a backend hosting multiple domains on one process can't tell which site the request was actually for. Without X-Forwarded-For, every request in your app's logs and rate limiting shows Nginx's own IP, which makes abuse detection and audit logs useless. Without X-Forwarded-Proto, an app that checks "was this request secure?" to decide whether to redirect to HTTPS or set a secure cookie flag will get it wrong, since the connection between Nginx and the backend is often plain HTTP even when the client connected over TLS. This is a very common cause of infinite redirect loops: the backend sees http, redirects to https, Nginx terminates that redirect back to http for the backend again, and round it goes.
Once you have more than one backend instance, an upstream block groups them and proxy_pass targets the group by name instead of a single address:
upstream backend_app {
least_conn; # send new requests to whichever server has fewest active connections
server 10.0.1.10:3000 weight=3; # gets roughly 3x the traffic of the others
server 10.0.1.11:3000;
server 10.0.1.12:3000 backup; # only used if the others are all down
}
server {
location / {
proxy_pass http://backend_app;
}
}
The default balancing algorithm, if you don't specify one, is round-robin: requests are handed out to each server in turn. least_conn instead sends each new request to whichever backend currently has the fewest open connections, which behaves better when some requests take much longer than others. ip_hash routes a given client IP to the same backend consistently, useful for session stickiness when your app keeps session state in memory rather than a shared store. As for health checks: open-source Nginx only does passive health checking out of the box, meaning it marks a server as down after a configurable number of failed requests (max_fails, fail_timeout) rather than proactively probing it. True active health checks that poll a health endpoint before routing traffic to a server require Nginx Plus or a third-party module, worth knowing so you don't go looking for a config directive that isn't there in the open-source build.
Real scenario-based DevOps questions, hands-on practice, and clear explanations for every answer.