A reverse proxy sits between visitors and your application. Nginx accepts the public connection on port 80 or 443, handles HTTPS, and passes the request to your app on a private address such as 127.0.0.1:3000. Your app never has to manage certificates or listen on a public port, and one server can host several apps under different domain names.

The short version is one location block with four directives:
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
The rest of this guide builds that into a complete setup on Ubuntu or Debian and explains each part, so you can adapt it to Node.js, Python, Go, PHP or a Docker container.
What you need before you start
- A Linux server with a public IP address. A small VPS is enough: Nginx itself uses very little memory, so size the server for the apps behind it.
- A domain name with an
Arecord (andAAAAif you use IPv6) pointing at that server. Certbot needs this to issue a certificate. - An app that already runs and answers on a local port. Check it from the server first with
curl -I http://127.0.0.1:3000. If that fails, Nginx cannot fix it. - SSH access with
sudo.
Make the app listen on 127.0.0.1 rather than 0.0.0.0 where it lets you. Then the only way in from the internet is through Nginx, which is the point of the setup.
Install Nginx and open the firewall
sudo apt update
sudo apt install nginx
sudo systemctl enable --now nginx
If you use UFW, the Nginx package adds application profiles. Nginx Full opens ports 80 and 443:
sudo ufw allow OpenSSH
sudo ufw allow 'Nginx Full'
sudo ufw enable
Leave your app's own port (3000 in these examples) closed to the outside. Browse to http://your-server-ip/ and you should see the Nginx welcome page.
Write the reverse proxy configuration
On Ubuntu and Debian, each site gets a file in /etc/nginx/sites-available/ and a symlink in /etc/nginx/sites-enabled/. Create /etc/nginx/sites-available/app.example.com:
server {
listen 80;
listen [::]:80;
server_name app.example.com;
# Allow uploads up to 20 MB (the default is 1 MB).
client_max_body_size 20m;
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;
proxy_set_header X-Forwarded-Host $host;
}
}
Enable it, test the syntax and reload:
sudo ln -s /etc/nginx/sites-available/app.example.com /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
Always run nginx -t before reloading. A reload with a broken file is refused and the old configuration keeps running, but a full restart with a broken file takes every site down.
If the default site still answers for your domain, remove its symlink with sudo rm /etc/nginx/sites-enabled/default and reload.
What each header does
Without these lines your app sees every request as coming from Nginx itself, which causes wrong redirects, broken login links and logs full of 127.0.0.1.
Host $hostpasses the domain the visitor asked for. By default Nginx sends the address fromproxy_passinstead, so an app that builds links from the Host header would produce127.0.0.1:3000links.X-Real-IPandX-Forwarded-Forcarry the visitor's IP address.$proxy_add_x_forwarded_forappends the client address to any existing list, which keeps the chain intact if another proxy (such as a CDN) is in front.X-Forwarded-Proto $schemetells the app whether the visitor usedhttporhttps. Apps that force HTTPS use this to avoid redirect loops once TLS ends at Nginx.
Your app also has to trust these headers, or it will ignore them. Most frameworks have a setting for it: app.set('trust proxy', 'loopback') in Express, the trusted proxies setting in Laravel, ProxyFix in Flask, SECURE_PROXY_SSL_HEADER in Django. Trust only the proxy's address, not everyone, or visitors can fake their IP.
The trailing slash in proxy_pass
This one line causes more confusion than anything else in Nginx proxying.
proxy_pass http://127.0.0.1:3000;(no path) sends the request URI unchanged. A request for/api/usersreaches the app as/api/users.proxy_pass http://127.0.0.1:3000/;(with a path, even just/) replaces the part of the URI that matched thelocation.
So to serve an app under a sub-path and strip that prefix:
location /api/ {
proxy_pass http://127.0.0.1:4000/;
}
A request for /api/users now reaches the app as /users. Keep the trailing slashes consistent on both lines; mixing /api with .../ produces paths such as //users.
Add free HTTPS with Certbot
Once plain HTTP works through the proxy, add a certificate from Let's Encrypt. Certbot's own instructions install it as a snap; on Ubuntu and Debian the distribution package also works:
sudo apt install certbot python3-certbot-nginx
sudo certbot --nginx -d app.example.com
Certbot proves you control the domain over port 80, saves the certificate, adds a listen 443 ssl block with the certificate paths to your site file and, if you accept, a redirect from HTTP to HTTPS. Both packages set up automatic renewal. Confirm renewal works before you forget about it:
sudo certbot renew --dry-run
If issuance fails, the usual causes are DNS still pointing somewhere else, port 80 blocked by a firewall or cloud security group, or an AAAA record pointing at an address where Nginx is not listening.
HTTP/2: from Nginx 1.25.1 the setting is a separate http2 on; line in the server block. Older versions, such as the 1.24 package in Ubuntu 24.04, add http2 to the listen 443 ssl line instead. Run nginx -v to see which you have.
Proxy WebSockets
Chat apps, live dashboards, Socket.IO, Home Assistant, n8n's editor and many development servers use WebSockets. They need an HTTP/1.1 connection upgrade, which Nginx does not pass on unless told to.
Add this map once, in the http context. A file such as /etc/nginx/conf.d/websocket-map.conf works, since conf.d is included inside http:
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
Then, in the location that serves the app:
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Keep idle WebSocket connections open longer than the 60 s default.
proxy_read_timeout 1h;
}
The map sends Connection: upgrade only when the browser asked for an upgrade, so ordinary requests behave normally.
Several apps on one server

Each app gets its own server block with its own server_name, in its own file:
server {
listen 80;
server_name blog.example.com;
location / { proxy_pass http://127.0.0.1:2368; include /etc/nginx/proxy_headers.conf; }
}
server {
listen 80;
server_name n8n.example.com;
location / { proxy_pass http://127.0.0.1:5678; include /etc/nginx/proxy_headers.conf; }
}
Put the repeated proxy_set_header lines in a small file such as /etc/nginx/proxy_headers.conf and include it, so a fix only has to be made once. Then run Certbot with one -d per domain, or run it once for each site.
Load balancing between several app instances
If you run the same app on several ports, or on several private servers, define an upstream group:
upstream app_backend {
least_conn;
server 127.0.0.1:3001;
server 127.0.0.1:3002;
keepalive 16;
}
server {
listen 80;
server_name app.example.com;
location / {
proxy_pass http://app_backend;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
Without least_conn Nginx uses round robin. keepalive reuses connections to the app, and it only works with proxy_http_version 1.1 and an empty Connection header, as shown. Open-source Nginx checks a server's health passively: after failed requests it stops sending traffic there for a while (max_fails and fail_timeout).
Proxying to Docker containers
If your app runs in Docker and Nginx runs on the host, publish the container port on the loopback address only:
docker run -d -p 127.0.0.1:3000:3000 your-image
A plain -p 3000:3000 publishes the port on every interface, and Docker's own firewall rules can let traffic past UFW. Binding to 127.0.0.1 avoids that.
If Nginx runs in a container too, 127.0.0.1 means that container, not the host. Put both containers on the same user-defined Docker network (Docker Compose creates one for each project) and use the service name: proxy_pass http://app:3000;.
Fixing the common errors
502 Bad Gateway. Nginx could not get a response from the app. The app is stopped, crashed, listening on a different port, or listening only on IPv6 while Nginx connects over IPv4. Check with curl -I http://127.0.0.1:3000 on the server and read /var/log/nginx/error.log; "connection refused" means nothing is listening there. On RHEL-family systems with SELinux, also check that Nginx is allowed to make network connections (setsebool -P httpd_can_network_connect 1).
504 Gateway Timeout. The app accepted the connection but took longer than proxy_read_timeout (60 seconds by default) to answer. Find out why the request is slow first; raise the timeout only for the routes that genuinely need it, such as report exports.
413 Request Entity Too Large. An upload was larger than client_max_body_size, which defaults to 1 MB. Raise it in the server or location block.
Redirect loop after adding HTTPS. The app does not know the visitor is already on HTTPS and keeps redirecting. Send X-Forwarded-Proto and configure the app to trust the proxy.
The app logs show 127.0.0.1 for every visitor. The forwarding headers are missing, or the app is not set to trust them.
WebSocket connects, then drops after a minute. Add the upgrade headers and raise proxy_read_timeout for that location.
A final checklist
- The app answers locally on
127.0.0.1and its port is closed to the internet. nginx -tpasses, and each site has its ownserver_name.Host,X-Forwarded-ForandX-Forwarded-Protoare set, and the app trusts the proxy.- HTTPS works, HTTP redirects to it, and
certbot renew --dry-runsucceeds. - Upload limits and timeouts match what the app actually needs.
If you are still choosing a server for this, the VPS resource planner estimates memory and CPU for the apps you plan to run, and the price explorer lists current plans side by side.