How to Set Up Nginx as a Reverse Proxy: A Step-by-Step Guide
A reverse proxy is a server that accepts requests from the internet and passes them to an application running on an internal port. A visitor opens https://app.example.com, and Nginx quietly forwards the request to 127.0.0.1:3000, where your app runs, then returns the response.
Why you need one:
- Several services on one server. Only one program can listen on ports 80 and 443. Nginx takes all requests and routes them by domain:
app.example.comto port 3000,n8n.example.comto port 5678. - HTTPS in one place. The certificate is set up in Nginx, while the apps speak plain HTTP and know nothing about SSL.
- The app isn't exposed to the internet. It listens only on localhost; only Nginx is reachable from outside.
- Extra protection and convenience. Rate limits, password login, compression and upload size limits are all configured in the proxy, with no changes to the app's code.
A reverse proxy is needed for almost everything people run on a VPS: Node.js and Python apps, Telegram bots on webhooks, n8n, Docker services.
Requirements:
- A server running Ubuntu 22.04, 24.04 or 26.04 and a user with sudo.
- An application running on a local port. The examples use port 3000.
- A domain or subdomain whose A record points to the server's IP.
Step 1. Install Nginx and Check Your App
Install Nginx and open the web ports in the firewall:
sudo apt update
sudo apt install -y nginx
sudo ufw allow 'Nginx Full'
Open http://SERVER_IP in your browser — you should see the "Welcome to nginx!" page.
Now make sure your app responds locally:
curl -I http://127.0.0.1:3000
The response should include a line like HTTP/1.1 200 OK. If you get Connection refused instead, the app isn't running or listens on a different port. Fix that first: Nginx can't proxy requests to an app that isn't working and will return a 502 error.
To see which ports programs on the server are listening on: sudo ss -tlnp.
Step 2. Create the Reverse Proxy Config
Create a config file for your domain. Replace app.example.com with your own:
sudo nano /etc/nginx/sites-available/app.example.com
Paste the contents. Replace the domain and the app's port:
server {
listen 80;
server_name app.example.com;
location / {
proxy_pass http://127.0.0.1:3000;
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;
}
}
The key line is proxy_pass: it tells Nginx where to send requests. Indentation and blank lines don't matter in Nginx configs; only the curly braces and the semicolon at the end of each directive do.
Enable the config: on Ubuntu, files live in sites-available, and only those linked from sites-enabled are active:
sudo ln -s /etc/nginx/sites-available/app.example.com /etc/nginx/sites-enabled/
Check the syntax and apply the changes:
sudo nginx -t && sudo systemctl reload nginx
Always run nginx -t before reloading: if the config has an error, it shows the file and line number, and the running Nginx stays untouched.
Open http://app.example.com — your app should appear.
Why You Need the proxy_set_header Lines
Without them, your app sees every request as if it came from Nginx itself: from 127.0.0.1, over HTTP, for the host 127.0.0.1:3000. The headers pass the visitor's real details to the app:
Host $host— the domain the visitor requested. Without it, the app builds wrong links and redirects to127.0.0.1:3000.X-Real-IP $remote_addr— the visitor's real IP. Needed for logs, rate limits and IP blocking inside the app.X-Forwarded-For $proxy_add_x_forwarded_for— the chain of IPs the request passed through. Most frameworks read the visitor's IP from here.X-Forwarded-Proto $scheme— the protocol the visitor used: http or https. Without it, the app doesn't know about HTTPS, which causes redirect loops or broken login cookies.
Your app must trust the proxy. Many frameworks ignore these headers by default for security reasons. In Express you enable them with app.set('trust proxy', 1), in Django with the SECURE_PROXY_SSL_HEADER setting, in n8n with the N8N_PROXY_HOPS=1 variable. Search your framework's docs for "behind a proxy".
To avoid repeating the headers in every config, put them in a separate file, /etc/nginx/snippets/proxy-headers.conf, and include it with one line inside location:
include snippets/proxy-headers.conf;
Create the file with sudo nano /etc/nginx/snippets/proxy-headers.conf and paste in the four proxy_set_header lines from step 2. The location /api/ example below relies on it: without the file, nginx -t reports an error.
Step 3. Enable HTTPS
Get a free Let's Encrypt certificate with Certbot:
sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx -d app.example.com
Certbot adds the SSL settings to your config, turns on the HTTP-to-HTTPS redirect and sets up auto-renewal. It leaves the location block with proxy_pass untouched.
Open https://app.example.com — your app should work with a padlock in the address bar. For more on certificates, checking auto-renewal and wildcards, see how to install a Let's Encrypt SSL certificate on Nginx.
How to Proxy WebSocket
WebSocket is a persistent connection between the browser and the server. It's used by chats, dashboards with live updates, the n8n editor and many modern web apps. By default, Nginx doesn't pass these connections through.
Signs of the problem: the app opens but says "Connection lost" or "Disconnected", data doesn't update without a page reload, and the browser console shows WebSocket connection failed errors.
Add three lines to the location block:
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
Apply the changes: sudo nginx -t && sudo systemctl reload nginx.
If the WebSocket connection drops after exactly one minute of inactivity, raise the timeout: proxy_read_timeout 3600s;. By default, Nginx closes a connection when nothing has been sent over it for 60 seconds.
Several Services on One Server
Method 1: by subdomain (recommended). Each service gets its own subdomain and its own config file. Repeat steps 2 and 3 for each one, changing server_name and the port in proxy_pass:
app.example.com→proxy_passhttp://127.0.0.1:3000;n8n.example.com→proxy_passhttp://127.0.0.1:5678;api.example.com→proxy_passhttp://127.0.0.1:8000;
Each subdomain needs an A record in DNS. This method works with any app without extra configuration.
Method 2: by path on one domain. For example, a site on example.com and an API on example.com/api/. Add a second location to the server block:
location /api/ {
proxy_pass http://127.0.0.1:8000/;
include snippets/proxy-headers.conf;
}
The trailing slash trap in proxy_pass. One character decides which path your app receives:
proxy_passhttp://127.0.0.1:8000/;(with a slash) — Nginx strips the prefix. A request for/api/usersreaches the app as/users.proxy_passhttp://127.0.0.1:8000;(no slash) — the path is passed as is. A request for/api/usersarrives as/api/users.
Pick the variant that matches the paths your app expects. A 404 on every request after setup almost always means the wrong one.
Keep in mind that ready-made web apps with a UI often don't work in a subfolder: styles and scripts are requested from the domain root and aren't found. Use a subdomain for those.
Timeouts, File Uploads and Streaming Responses
The defaults suit a regular website but get in the way of apps with long requests and file uploads. All the directives below go in the location or server block.
- Upload size. By default, Nginx accepts requests of up to 1 MB and answers larger files with a 413 error. Raise the limit:
client_max_body_size 50m; - Long requests. If the app takes more than 60 seconds to respond (reports, imports, AI model calls), Nginx cuts the connection with a 504 error. Extend the wait:
proxy_read_timeout 300s;andproxy_send_timeout 300s; - Streaming responses. Nginx collects the app's response in a buffer before sending it to the visitor. For Server-Sent Events, streamed AI responses and progress bars, turn buffering off:
proxy_buffering off; - Compression. On Ubuntu, gzip is already on for HTML. To compress JSON, CSS and JavaScript too, uncomment the
gzip_typesline in/etc/nginx/nginx.conf.
After any change: sudo nginx -t && sudo systemctl reload nginx.
How to Protect an App Behind the Proxy
Close direct access to the app's port. If the app listens on 0.0.0.0:3000, it's reachable from the internet at http://SERVER_IP:3000, bypassing Nginx: no HTTPS, no limits, no password. Configure the app to listen only on 127.0.0.1. For Docker, publish the port like this: -p 127.0.0.1:3000:3000. This matters all the more because Docker opens ports bypassing UFW — see how to install Docker on Ubuntu.
Check: sudo ss -tlnp | grep 3000 should show 127.0.0.1:3000, not 0.0.0.0:3000 or *:3000.
Password login. If the app has no authentication of its own (a monitoring dashboard, a staging site), protect it with a password at the Nginx level. Create a user file:
sudo apt install -y apache2-utils
sudo htpasswd -c /etc/nginx/.htpasswd admin
Add two lines to the location block:
auth_basic "Restricted";
auth_basic_user_file /etc/nginx/.htpasswd;
Use this kind of password only together with HTTPS; otherwise it travels over the network in plain text.
Access from your own IPs only. For an admin panel or internal service, add to location:
allow 203.0.113.10;
deny all;
Rate limiting. Protects against password guessing and simple attacks. First define a limit zone (10 requests per second per IP):
echo 'limit_req_zone $binary_remote_addr zone=app:10m rate=10r/s;' | sudo tee /etc/nginx/conf.d/limits.conf
Then enable it in the location block:
limit_req zone=app burst=20 nodelay;
Requests over the limit get a 503 error. For an API with frequent requests, raise rate and burst. For more on defending against attacks, see how to protect a VPS from DDoS attacks.
Common Errors
Nginx writes the cause of any error to its log: sudo tail -n 50 /var/log/nginx/error.log. Start troubleshooting there.
- 502 Bad Gateway. Nginx can't connect to the app: it isn't running, has crashed or listens on a different port. Check
curl -Ihttp://127.0.0.1:3000and the port inproxy_pass. - 504 Gateway Timeout. The app takes longer than 60 seconds to respond. Raise
proxy_read_timeoutor find out why the request is so slow. - 413 Request Entity Too Large. The file exceeds the limit. Raise
client_max_body_size. - 404 on every page when proxying by path. Wrong trailing-slash variant in
proxy_pass. See the section on several services. - Redirect loops or "mixed content" after enabling HTTPS. The app doesn't know the visitor came over HTTPS. Check the
X-Forwarded-Protoheader and the app's proxy trust setting. - Every visitor shows up as 127.0.0.1 in the app's logs. The
X-Real-IPandX-Forwarded-Forheaders aren't passed, or the app doesn't trust them. - You see "Welcome to nginx!" instead of your app. The domain in
server_namedoesn't match the address in the browser, or the config isn't linked insites-enabled.
Checklist
FAQ
How is a reverse proxy different from a regular proxy? A regular proxy sits on the user's side and hides the user from websites. A reverse proxy sits on the server's side and hides applications from visitors.
Do I need a reverse proxy for a single app? Yes. It gives you HTTPS, the standard ports 80 and 443 without running the app as root, rate limits and protection from slow connections.
Can I proxy to another server? Yes, use its address: proxy_pass http://10.0.0.5:3000;. Use a private network and allow access to that port on the second server only from the proxy's IP.
Which should I choose: Nginx, Caddy or Traefik? Nginx is the most widely used and has the most guides. Caddy is simpler and gets certificates on its own. Traefik is handy when Docker services change often. For most VPS tasks, Nginx is enough.
How many services can sit behind one Nginx? As many as you like: Nginx itself uses just a few megabytes of memory. The only limit is the server resources the apps themselves need.