A VPS running four apps on four local ports still has one public IP address, and ports 80 and 443 can only be claimed once. Nginx takes both ports, reads the Host header on each request, and forwards it to the matching local port.
This starts where our guide on how to serve your site over HTTPS with Nginx and Let's Encrypt finishes, so Nginx should already be installed. Ubuntu and Debian use apt and ufw; AlmaLinux, Rocky, and other RHEL-based distros use dnf and firewalld.
1. Bind every app to localhost
An app listening on 0.0.0.0 is reachable directly on its own port, which skips your certificate and your access log. Check what is actually listening:
$ sudo ss -tlnpAnything you plan to put behind Nginx should show 127.0.0.1:PORT or [::1]:PORT. Most apps have a bind or listen address setting. In Docker, publish the port to localhost by naming the address:
$ docker run -d -p 127.0.0.1:3001:3000 --name app1 myimageWithout the 127.0.0.1: prefix, Docker publishes on every interface.
2. One file per app
Nginx reads /etc/nginx/nginx.conf and pulls in other directories from there. Find out which ones on your system:
$ grep -n include /etc/nginx/nginx.confDebian and Ubuntu include both conf.d/ and sites-enabled/, and their nginx-common package also ships a snippets/ directory and a packaged site in sites-enabled. RHEL-based packages use conf.d/ only. Pick one directory and stay in it. Name each file after the hostname it serves, such as app1.example.com.conf. To see everything Nginx actually ended up with after all the includes:
$ sudo nginx -T3. Write the proxy headers once
Every proxied app needs the same handful of headers. Put them in one file:
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 Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; proxy_http_version 1.1;
Host has to be set, because Nginx defaults to proxy_set_header Host $proxy_host, which sends the upstream address instead of the name the visitor typed. Apps that build links from the Host header produce broken URLs when this is missing. $host is the name from the Host header, or the server's primary name when the header is absent.
proxy_http_version 1.1 is only a default from Nginx 1.29.7 onwards. Debian 13 ships 1.26.3, Ubuntu 24.04 ships 1.24.0, and Ubuntu 26.04 ships 1.28.3, so keep the line. Check yours with nginx -v.
$connection_upgrade comes from a map, which belongs in the http context. This is the form Nginx documents for locations that carry both WebSocket and ordinary traffic:
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}One header in a location silently drops all the others
Nginx inherits proxy_set_header from the level above only when the current level has no proxy_set_header of its own. Add a single extra header inside a location and every header set at server or http level stops being sent. Include the shared file at the same level where you add the extra header, and confirm with nginx -T.
4. One server block per app
server {
listen 80;
listen [::]:80;
server_name app1.example.com;
client_max_body_size 25m;
location / {
proxy_pass http://127.0.0.1:3001;
include /etc/nginx/proxy-common.conf;
}
}Copy the file for the next app, change the server_name and the port, and repeat. Nginx matches on the exact name first, then a wildcard like *.example.com, then a wildcard like example.*, then regular expressions in file order, and stops at the first match.
client_max_body_size defaults to 1m. A larger upload gets a 413 from Nginx before your app sees any of it, so set it per app to whatever that app really needs.
Point both DNS records at the server, then get certificates:
$ sudo nginx -t && sudo systemctl reload nginx $ sudo certbot --nginx -d app1.example.com -d app2.example.com
One certbot --nginx run can cover several names in one certificate, or you can run it once per domain. Either way it edits the matching server blocks and reloads Nginx.
5. Send unknown hostnames nowhere
A request whose Host header matches nothing goes to the default server for that listen port, and that is whichever block Nginx loaded first. Someone pointing a random domain at your IP address then gets one of your apps. Claim the default explicitly:
server {
listen 80 default_server;
listen [::]:80 default_server;
server_name _;
return 444;
}The name _ carries no meaning to Nginx. It is simply an invalid hostname that cannot collide with a real one. What actually makes this block the default is the default_server flag on listen, so server_name plays no part in it. Response code 444 closes the connection without sending anything.
Nginx refuses to start if two blocks claim default_server on the same listen port. On Debian and Ubuntu the packaged site in sites-enabled may already claim it, so read that file before adding yours.
6. Serving an app under a path
If you have one domain and no wish to add subdomains, give each app a path. The rule is about whether proxy_pass has a URI after the address, and Nginx documents it as replacement: with a URI, the part of the request URI that matched the location is replaced by it.
location /grafana/ {
proxy_pass http://127.0.0.1:3002/;
include /etc/nginx/proxy-common.conf;
}A request for /grafana/dashboard arrives at the app as /dashboard. Delete the trailing slash from the proxy_pass line and the same request arrives as /grafana/dashboard.
The app usually has to know its own path
Whichever form you choose, the app still generates its own links. If it does not know it lives under /grafana/, it will emit URLs pointing at /static/... and those will 404. Look for a base URL, root URL, or subpath setting in the app before blaming Nginx. Subdomains avoid this entirely.
One more limit: when a location is a regular expression, proxy_pass must be written without any URI. Nginx cannot work out which part to replace.
7. When it breaks
$ sudo nginx -t && sudo systemctl reload nginx $ sudo tail -f /var/log/nginx/error.log
| What you see | What it means |
|---|---|
| 502 Bad Gateway | Nginx reached nothing. Test the app directly with curl -I http://127.0.0.1:3001 |
| 504 Gateway Timeout | The app accepted the connection and did not answer. proxy_read_timeout defaults to 60s |
| 413 | client_max_body_size, still at its 1m default somewhere |
| A WebSocket that never connects | The map is missing, or a location dropped the inherited headers |
| The wrong app answers | No default_server block, or a server_name typo |
Two failures have exact messages worth knowing. On RHEL-based systems, connect() to 127.0.0.1:3001 failed (13: Permission denied) in the error log is SELinux. File permissions have nothing to do with it. Nginx runs in the httpd_t domain, and outgoing TCP connections from that domain are disabled by default:
$ sudo setsebool -P httpd_can_network_connect 1And if Nginx will not start at all with could not build the server_names_hash, you should increase server_names_hash_bucket_size: 32, one of your hostnames is longer than the bucket. Raise it to the next power of two in the http context:
server_names_hash_bucket_size 64;
Quick reference
| Task | Command or directive |
|---|---|
| Test the config | sudo nginx -t |
| Apply it | sudo systemctl reload nginx |
| See the merged config | sudo nginx -T |
| See what is listening | sudo ss -tlnp |
| Test an app directly | curl -I http://127.0.0.1:3001 |
| Send a request to an app | proxy_pass http://127.0.0.1:3001; |
| Strip the location prefix | add a trailing slash: proxy_pass http://127.0.0.1:3001/; |
| Raise the upload limit | client_max_body_size 25m; |
| Wait longer for a slow app | proxy_read_timeout 300s; |
| Certificates for two names | sudo certbot --nginx -d a.example.com -d b.example.com |
| Allow proxying on RHEL-based | sudo setsebool -P httpd_can_network_connect 1 |
Where to go next
With every app bound to localhost, ports 80 and 443 are the only ones that need to be open. Close the rest and check the result with sudo ufw status or sudo firewall-cmd --list-all, which our guide on how to set up a basic firewall on a new VPS covers. If the apps run in containers, install Docker on a VPS and run your first app shows the port publishing side.