A reverse proxy sits in front of your application and passes requests to it. The visitor talks to the proxy; the proxy talks to your application on a local port.
On a VPS this is how you run a Node.js, Python or Ruby application on a real domain over HTTPS, and it solves several problems at once, which is why it is the standard arrangement in place of one option among many.
Why not just run the application on port 80
You can, and here is what you give up.
Ports below 1024 require root. Running your application as root so it can bind port 80 means any flaw in it is a flaw with full system access.
One port, one application. Two applications cannot both hold port 80, so a second site means a second server.
You would implement TLS yourself. Certificate loading, renewal, protocol configuration, in your application, and again in the next one.
Static files would go through your application. A web server serves a CSS file far faster than an application framework does.
The proxy handles all four. Your application listens on 127.0.0.1:3000 as an unprivileged user and knows nothing about certificates.
The minimum configuration
server {
listen 80;
server_name example.com;
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;
}
}
Those four headers are not optional decoration. Without them the application sees every request as coming from 127.0.0.1 over plain HTTP, so rate limiting by IP limits the proxy, logs record nothing useful, and any redirect the application builds points at http://.
Test and reload:
sudo nginx -t sudo systemctl reload nginx
Always test before reloading. A syntax error on reload takes down every site on the server, not only the one you were editing.
Bind the application to localhost only
The step that gets skipped.
An application listening on 0.0.0.0:3000 is reachable directly from the internet on that port, bypassing the proxy entirely: along with whatever HTTPS, rate limiting and access rules you configured there.
Bind to 127.0.0.1 so only the local machine can reach it. Confirm from outside rather than trusting the configuration, and remember that a container publishing a port writes firewall rules ahead of your firewall's own. Running Docker on a VPS deals with that specific trap.
Add HTTPS at the proxy
With the proxy working over HTTP, obtain a certificate. Certbot's Nginx plugin edits the configuration for you and adds the redirect. Setting up Let's Encrypt certificates on a VPS deals with issuance and, more importantly, renewal.
Your application stays on plain HTTP over the loopback interface. That is correct and not a weakness: the traffic never leaves the machine.
What it does mean is that the application must trust X-Forwarded-Proto to know the visitor is on HTTPS. Frameworks have a setting for this: often called trusting the proxy, and without it you get redirect loops between HTTP and HTTPS.
WebSockets need two extra lines
If the application uses WebSockets, the default configuration breaks them silently: the page loads, the live features do not work.
proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade";
The connection upgrade needs to be passed through explicitly. Without it the handshake fails and the browser retries forever.
Serve static files from the proxy
Let the web server deliver assets directly instead of proxying them:
location /static/ {
alias /var/www/app/static/;
expires 30d;
}
This removes a large share of requests from your application entirely, and adds caching headers so returning visitors do not download them again.
It is usually the single biggest performance gain in a proxied setup, and it costs three lines.
Keep the application running
A reverse proxy does not start or restart your application. If it crashes, the proxy returns 502 until somebody notices.
Run the application under a process manager (a systemd service is the simplest and needs nothing installed) so it starts at boot and restarts on failure.
Without that, a reboot leaves a working proxy pointing at nothing, which is a confusing failure to debug at short notice.
Reading the errors
502 Bad Gateway; the application is not running, or not on the port you configured.
504 Gateway Timeout; it is running and did not answer in time. Either it is genuinely slow or proxy_read_timeout is too short for a long-running request.
Redirect loop: the application does not know it is behind HTTPS. That is the X-Forwarded-Proto setting above.
The proxy's error log names the upstream and the reason, which makes these fast to diagnose once you know to look there rather than in the application's own log. Common VPS issues sets out the wider set.
Pass the visitor's details through
Behind a proxy the application sees the proxy's address on every request, and anything depending on the visitor's address stops working correctly.
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_set_header Host $host;
The protocol header is the one that causes outages. Without it the application believes every request arrived insecurely, and a rule that redirects insecure requests produces an endless loop.
The application also has to be configured to trust these headers, or it ignores them. Both halves are required, and configuring one is a common half fix that appears to work until something reads the address.
Set timeouts that match the application
Default proxy timeouts are shorter than some legitimate requests, and the failure looks like an application error.
proxy_connect_timeout 10s; proxy_read_timeout 60s; proxy_send_timeout 60s;
A report, an export or an import can exceed the default and produce a gateway error while the application is still working perfectly and completes the task.
Raise it for the specific paths that need it rather than everywhere, since a long global timeout means a genuinely stuck request holds a connection for that entire period.
Check the errors on the right side
A failure produces messages in two places and they say different things.
tail -20 /var/log/nginx/error.log journalctl -u myapp -n 20 --no-pager curl -sI http://127.0.0.1:3000/ | head -1
A gateway error in the proxy log with nothing in the application log means the application was not reachable. The same error with an application error alongside it means the application failed while answering.
Requesting the application directly, bypassing the proxy, settles which side is at fault in one command and saves reading either log speculatively.