What you'll learn
Quick Answer
Nginx sits in front of your app, terminates TLS, serves static files itself and forwards everything else to your process on localhost. You configure it with server blocks matched by port and Host header, and location blocks matched by URL prefix. The rule that catches everyone: proxy_pass with a trailing slash strips the matched location prefix, without one it keeps it. When something breaks, read the error log, not the browser.
What a reverse proxy actually does
Your Node or Django app listens on a port such as 3000 and speaks HTTP perfectly well. So why put nginx in front of it? Because several jobs are not your app's job, and nginx does them better.
A reverse proxy accepts the connection from the internet on ports 80 and 443, and decides what to do with each request. Static files it reads from disk and returns itself, without your application process ever waking up. Everything else it forwards to your app over the loopback interface and passes the response back. To the browser it looks like one server; behind it you might have a React build, a Node API and a PHP admin panel.
The concrete things you get:
- TLS termination. Certificates live in one place, so your app keeps speaking plain HTTP on localhost and you renew certificates without touching application code.
- One public port for many apps.
/api/to Node on 3000,/blog/to something else on 4000, everything else to static files, all on the same domain. - Protection from slow clients. Nginx buffers requests and responses, so a phone on a weak 3G signal in a moving train ties up nginx rather than one of your limited application workers.
- Static files served properly. Compression, caching headers and byte-range requests, handled without application code.
- A safe restart. You can deploy and restart your app while nginx keeps returning a maintenance page instead of a browser connection error.
The opposite of a reverse proxy is a forward proxy, which sits in front of clients and hides them from the server. Nginx can do both, but when people say "nginx proxy" in a deployment context they always mean the reverse one, sitting in front of your servers.
Server blocks and how nginx picks one
The main configuration file is /etc/nginx/nginx.conf, but you rarely edit it. Site files live in different places depending on distribution, and this alone wastes a lot of time. On Debian and Ubuntu you write a file in /etc/nginx/sites-available/ and enable it by symlinking into /etc/nginx/sites-enabled/. On RHEL, Rocky, Alma and Amazon Linux there is no such split; you drop a .conf file into /etc/nginx/conf.d/ and it is live.
server {
listen 80;
server_name priodemy.com www.priodemy.com;
root /var/www/priodemy/dist;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
}
A server block is a virtual host. Nginx chooses one by first matching the IP and port from listen, then comparing the request's Host header against server_name. Matching order is exact name, then a leading wildcard such as *.priodemy.com, then a trailing wildcard, then regular expressions in file order.
If nothing matches, nginx uses the default server for that port, which is whichever block is marked listen 80 default_server; or, failing that, simply the first one it loaded. This produces a bewildering symptom: you set up a second site on the same machine, visit it, and see the first site instead. Nothing is broken. Your DNS or server_name just does not match, so you landed on the default. A good habit is to define an explicit catch-all that returns 444 so unmatched hosts fail loudly instead of leaking another site.
Inside a server block, location blocks match the URL path. Prefix matches such as location /api/ are the ones you will use most; nginx picks the longest matching prefix, unless a regex location matches first. Do not scatter twenty locations across a file, because working out which one won is genuinely hard. Two or three clear prefixes will cover almost every application.
proxy_pass and the trailing slash trap
Forwarding to your application is one directive, plus a handful of headers you should never omit.
location /api/ {
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;
}
Now the single most confusing behaviour in nginx. Whether proxy_pass ends with a slash changes the path your backend receives.
proxy_pass http://127.0.0.1:3000/;with the slash: the matched location prefix is replaced by that URI, so a request for/api/usersarrives at your app as/users.proxy_pass http://127.0.0.1:3000;without it: the full original path is passed through, so/api/usersarrives as/api/users.
Neither is wrong. What is wrong is not knowing which one you wrote. If your Express routes are defined as app.get('/api/users') and you used the trailing slash, every request 404s while working perfectly when you curl the app directly on port 3000. The fastest way to diagnose it is to log the incoming path in your app and hit the endpoint through nginx once.
The headers matter too. Without Host, your framework sees the upstream address and any redirect it generates points at 127.0.0.1. Without X-Forwarded-For, every request in your logs appears to come from the server itself, which makes rate limiting by IP useless. Without X-Forwarded-Proto, a framework configured to force HTTPS sees a plain HTTP request, redirects to HTTPS, gets proxied back as HTTP and loops until the browser gives up with a redirect error. Frameworks generally need to be told to trust these headers, for example app.set('trust proxy', 1) in Express.
Two more you will need eventually. File uploads fail with 413 Request Entity Too Large because nginx defaults to a 1 MB body limit; raise it with client_max_body_size 20m;. And WebSockets need the upgrade handshake forwarded explicitly:
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 300s;
Serving static files and SPA routing
Let nginx serve your build output directly. It reads from disk far more cheaply than a Node process can, and it frees your workers for actual requests.
server {
listen 80;
server_name priodemy.com;
root /var/www/priodemy/dist;
index index.html;
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
}
location / {
try_files $uri $uri/ /index.html;
}
}
try_files is the directive that makes single page apps work. It tries the exact file, then the directory, then falls back to index.html so React Router or Vue Router can handle the path client side. Without it, the homepage loads fine but refreshing on /courses/python returns a 404, because nginx looked for a directory of that name on disk and did not find one.
The long cache header is safe only for files with a content hash in the name, which is what Vite and webpack produce by default. Never apply expires 1y to index.html itself, or returning visitors will keep loading a cached page that references JavaScript files you have already deleted, and they will see a blank screen you cannot reproduce.
Then there is root versus alias, which trips up nearly everyone. With root, nginx appends the whole request path to the root path. With alias, it replaces the matched location prefix.
location /assets/ {
root /var/www/priodemy/dist;
# /assets/app.js -> /var/www/priodemy/dist/assets/app.js
}
location /assets/ {
alias /var/www/media/;
# /assets/app.js -> /var/www/media/app.js
}
Drop the trailing slash from that alias value and paths silently concatenate into something like /var/www/mediaapp.js. When using alias, keep trailing slashes on both the location and the alias.
One permissions detail that costs people an afternoon: nginx workers run as a separate user, usually www-data or nginx, and that user needs execute permission on every directory in the path. A build sitting in /home/ubuntu/app/dist returns 403 because /home/ubuntu is not traversable by others. Deploy to /var/www instead of arguing with home directory permissions.
Reading nginx error logs
When a page breaks, the browser tells you almost nothing. "502 Bad Gateway" means nginx could not get a usable response from upstream and has no idea why. The answer is always in the logs, in two files: /var/log/nginx/access.log records every request served, and /var/log/nginx/error.log records what went wrong. Debug the error log.
sudo nginx -t # validate config before touching anything
sudo systemctl reload nginx # apply config, keeps connections alive
sudo tail -f /var/log/nginx/error.log
Always run nginx -t before reloading. It parses the config and prints the file and line number of any mistake. Reloading a broken config leaves the old one running, but restarting with a broken config takes the site down, so build the habit of testing first. Prefer reload over restart: reload starts new workers with the new config and lets old ones finish their current requests, while restart drops connections.
Four messages cover most real incidents:
connect() failed (111: Connection refused) while connecting to upstreammeans nothing is listening at the address in yourproxy_pass. Your app crashed, is on a different port, or is bound to a container interface rather than 127.0.0.1.open() "/var/www/html/app.js" failed (2: No such file or directory)is arootoraliasmistake. The path in the message is exactly where nginx looked, so compare it with where the file actually is.(13: Permission denied)is the worker user unable to read the file or traverse a parent directory. On RHEL-family systems with SELinux enforcing, this appears even when the Unix permissions look correct.upstream timed out (110: Connection timed out)means your app accepted the connection but took longer thanproxy_read_timeout, 60 seconds by default. Raising the timeout is a bandage; the endpoint is usually doing something that should be a background job.
A trick that saves real time: add a request id to the log format and return it to the client, then any user complaint comes with a token that finds the exact line in your logs. Nginx exposes $request_id for this, and passing it upstream as a header lets you trace one request from nginx into your application logs.
