Independent hosting operator since 2014 · New York · Dallas · Amsterdam Status Looking Glass Contact
D4 NetworksD4 Networks
Client area Deploy a server
Home / Resources / Run several apps behind one Nginx reverse proxy

Tutorials

Run several apps behind one Nginx reverse proxy

Jul 28, 2026 · 12 min read

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:

ssh session
$ sudo ss -tlnp

Anything 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:

ssh session
$ docker run -d -p 127.0.0.1:3001:3000 --name app1 myimage

Without 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:

ssh session
$ grep -n include /etc/nginx/nginx.conf

Debian 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:

ssh session
$ sudo nginx -T

3. Write the proxy headers once

Every proxied app needs the same handful of headers. Put them in one file:

/etc/nginx/proxy-common.conf
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:

/etc/nginx/conf.d/00-upgrade-map.conf
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

/etc/nginx/conf.d/app1.example.com.conf
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:

ssh session
$ 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:

/etc/nginx/conf.d/00-default.conf
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.

/etc/nginx/conf.d/example.com.conf
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

ssh session
$ sudo nginx -t && sudo systemctl reload nginx
$ sudo tail -f /var/log/nginx/error.log
What you seeWhat it means
502 Bad GatewayNginx reached nothing. Test the app directly with curl -I http://127.0.0.1:3001
504 Gateway TimeoutThe app accepted the connection and did not answer. proxy_read_timeout defaults to 60s
413client_max_body_size, still at its 1m default somewhere
A WebSocket that never connectsThe map is missing, or a location dropped the inherited headers
The wrong app answersNo 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:

RHEL-based
$ sudo setsebool -P httpd_can_network_connect 1

And 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:

/etc/nginx/conf.d/00-tuning.conf
server_names_hash_bucket_size 64;

Quick reference

TaskCommand or directive
Test the configsudo nginx -t
Apply itsudo systemctl reload nginx
See the merged configsudo nginx -T
See what is listeningsudo ss -tlnp
Test an app directlycurl -I http://127.0.0.1:3001
Send a request to an appproxy_pass http://127.0.0.1:3001;
Strip the location prefixadd a trailing slash: proxy_pass http://127.0.0.1:3001/;
Raise the upload limitclient_max_body_size 25m;
Wait longer for a slow appproxy_read_timeout 300s;
Certificates for two namessudo certbot --nginx -d a.example.com -d b.example.com
Allow proxying on RHEL-basedsudo 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.

nginx reverse proxy websockets selinux tls ubuntu debian almalinux

Related articles