Most application servers, such as Node.js, Gunicorn, Puma, Go binaries or Java apps, listen on a local port like 3000 or 8080. You do not want visitors connecting to that port directly. Instead, Nginx listens on ports 80 and 443, handles HTTPS, compression and static files, and passes each request to the app. This setup is called a reverse proxy, and this guide builds one that works for almost any backend.
Before you start
- A Linux VPS with Nginx installed. See Install Nginx on Linux.
- Your app running on the server and listening on a local port. This guide uses
127.0.0.1:3000. - A domain pointed at the server's IP.
- SSH access with sudo.
Check the app answers locally before involving Nginx:
curl -I http://127.0.0.1:3000/If that fails, fix the app first. Nginx cannot proxy to something that is not listening.
Bind the app to localhost
Configure your app to listen on 127.0.0.1, not 0.0.0.0. Then only Nginx on the same server can reach it, and nobody can bypass your HTTPS and headers by visiting http://203.0.113.10:3000.
How you set this depends on the framework, for example app.listen(3000, '127.0.0.1') in Express or --bind 127.0.0.1:8000 for Gunicorn. Confirm with:
sudo ss -ltnp | grep 3000You want to see 127.0.0.1:3000, not 0.0.0.0:3000 or *:3000.
Create the proxy server block
Create a site file. On Ubuntu or Debian use /etc/nginx/sites-available/app.example.com; on AlmaLinux or Rocky use /etc/nginx/conf.d/app.example.com.conf.
upstream app_backend {
server 127.0.0.1:3000;
keepalive 16;
}
server {
listen 80;
listen [::]:80;
server_name app.example.com;
client_max_body_size 20m;
location / {
proxy_pass http://app_backend;
proxy_http_version 1.1;
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_set_header X-Forwarded-Host $host;
proxy_set_header Connection "";
proxy_connect_timeout 5s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}
}On Ubuntu or Debian, enable it:
sudo ln -s /etc/nginx/sites-available/app.example.com /etc/nginx/sites-enabled/What the headers do
Without these headers, your app sees every request as coming from 127.0.0.1 over plain HTTP.
Hostpasses the domain the visitor used. Apps use it to build links and pick tenants.X-Real-IPandX-Forwarded-Forcarry the visitor's real IP address.$proxy_add_x_forwarded_forappends to any existing list instead of replacing it.X-Forwarded-Prototells the app whether the visitor usedhttporhttps, so it does not generatehttp://links or redirect in a loop.proxy_http_version 1.1with an emptyConnectionheader lets Nginx reuse connections to the app viakeepalive.
Your app must be told to trust these headers. In Express that is app.set('trust proxy', 'loopback'); Django, Rails, Laravel and others have similar settings.
Timeouts and buffering
proxy_connect_timeoutis how long Nginx waits to open a connection to the app. Keep it short; if the app is up, it connects instantly.proxy_read_timeoutis how long Nginx waits between bytes from the app. The default is 60 seconds. Raise it only for routes that really run long, such as reports or exports, in their ownlocation.- Buffering is on by default. Nginx reads the whole response from the app quickly, frees the app, then sends it to the visitor at their speed. Leave it on, except for streaming responses such as server-sent events:
location /events {
proxy_pass http://app_backend;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_buffering off;
proxy_read_timeout 1h;
}For WebSockets, see Proxy WebSocket connections through Nginx.
Let Nginx serve static files
If your app has a folder of static assets, Nginx can send them directly, which is much faster than going through the app:
location /static/ {
alias /var/www/app/static/;
expires 30d;
access_log off;
}alias replaces the /static/ part of the URL with the folder path. Keep the trailing slashes on both.
SELinux on AlmaLinux and Rocky Linux
SELinux blocks Nginx from making outbound network connections by default, which causes 502 Bad Gateway with "Permission denied" in the error log. Allow it once:
sudo setsebool -P httpd_can_network_connect 1More in Fix Nginx permission denied caused by SELinux.
Test, reload and add HTTPS
sudo nginx -t && sudo systemctl reload nginxThen request a certificate. Certbot adds a 443 block that keeps your location settings:
sudo certbot --nginx -d app.example.comThe full process is in Free SSL for Nginx with Let's Encrypt.
Check it works
curl -I https://app.example.com/You want a 200 (or your app's normal redirect) and a server: nginx header. To confirm the headers reach your app, add a temporary debug route that prints X-Forwarded-Proto and X-Real-IP, or check your app's access log for your real IP instead of 127.0.0.1.
Troubleshooting
- 502 Bad Gateway: the app is not running, is on a different port, or SELinux blocks the connection. Check
sudo tail /var/log/nginx/error.log. See Fix 502 Bad Gateway in Nginx. - 504 Gateway Timeout: the app took longer than
proxy_read_timeout. Speed up the route or raise the timeout for that location only. - App generates
http://links or loops on HTTPS: it is not readingX-Forwarded-Proto. Enable its trusted proxy setting. - All visitors show as 127.0.0.1 in app logs: the app ignores
X-Forwarded-For. Configure it to trust the loopback proxy. - Uploads fail with 413: raise
client_max_body_sizein the server block.
Next steps
- A worked Node.js example with PM2: Deploy a Node.js app with PM2 and Nginx.
- Spread traffic across several app instances: Load balance backends with Nginx upstream.
