A reverse proxy receives requests from clients and forwards them to one or more backend applications, such as a Node.js, Python or Go service listening on a local port. Putting Nginx in front of your application lets it handle TLS, serve on ports 80 and 443, pass the real client IP to the app, balance load across several instances and keep the application itself off the public internet. In this tutorial you will configure Nginx on Ubuntu 24.04 as a reverse proxy for a local backend, add WebSocket support and load balancing, and secure it with HTTPS.
Prerequisites
To follow this tutorial you need:
- A server running Ubuntu 24.04 LTS, for example a CubePath VPS, with a non-root user that has
sudoprivileges. - A domain name with an
Arecord pointing to the server. This guide usesyour_domainas a placeholder. - UFW enabled with SSH allowed.
- Optionally, your own application. If you don't have one yet, this guide starts a small test backend with Python, which is preinstalled on Ubuntu.
Step 1 - Installing Nginx
Install Nginx from the Ubuntu repositories:
sudo apt update
sudo apt install nginx
The package enables and starts the service. Check it:
systemctl status nginx --no-pager
● nginx.service - A high performance web server and a reverse proxy server
Loaded: loaded (/usr/lib/systemd/system/nginx.service; enabled; preset: enabled)
Active: active (running) since Thu 2026-09-25 10:00:00 UTC; 5s ago
Allow HTTP and HTTPS through the firewall:
sudo ufw allow 'Nginx Full'
Only open ports 80 and 443. Your backend's port stays closed to the outside, because Nginx reaches it on the loopback interface.
Step 2 - Starting a test backend
Your application should listen on 127.0.0.1 rather than on all interfaces, so the only way to reach it is through Nginx. If you already have an app running on a local port, note the port and skip to Step 3.
To have something to proxy to, create a small page and serve it with Python's built-in HTTP server on port 3000. Open a second SSH session to the server and run:
mkdir -p ~/backend1
echo "Hello from backend 1" > ~/backend1/index.html
python3 -m http.server 3000 --bind 127.0.0.1 --directory ~/backend1
Serving HTTP on 127.0.0.1 port 3000 (http://127.0.0.1:3000/) ...
Leave it running. From your first session, confirm that the backend answers locally:
curl http://127.0.0.1:3000/
Hello from backend 1
Note
python3 -m http.serveris only for testing. Run your real application as a systemd service so it starts at boot and restarts on failure.
Step 3 - Creating the reverse proxy server block
Create a server block for your domain:
sudo nano /etc/nginx/sites-available/your_domain
Add the following configuration:
server {
listen 80;
listen [::]:80;
server_name your_domain www.your_domain;
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;
}
}
Here is what each part does:
proxy_passforwards every request to the backend. Without a URI after the port, the original path is passed unchanged, so/api/usersreaches the backend as/api/users.Host $hostpasses the domain the client requested. By default Nginx would send127.0.0.1:3000, which breaks apps that build absolute URLs or serve several domains.X-Real-IPandX-Forwarded-Forcarry the client's IP address. Without them, your app sees every request as coming from127.0.0.1.X-Forwarded-Prototells the app whether the client used HTTP or HTTPS, so it can generate correct redirects and secure cookies once you add TLS.
Enable the site and disable the default one, which would otherwise answer requests that don't match any server_name:
sudo ln -s /etc/nginx/sites-available/your_domain /etc/nginx/sites-enabled/
sudo rm /etc/nginx/sites-enabled/default
Test and reload:
sudo nginx -t
sudo systemctl reload nginx
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
From your local machine or the server, request the site through Nginx:
curl -i http://your_domain/
HTTP/1.1 200 OK
Server: nginx/1.24.0 (Ubuntu)
Content-Type: text/html
...
Hello from backend 1
In the backend's terminal you'll see the request logged as coming from 127.0.0.1, because Python's test server doesn't read X-Forwarded-For. Real frameworks do once you enable their "trust proxy" setting (for example app.set('trust proxy', 'loopback') in Express or ProxyFix in Flask).
Step 4 - Adding WebSocket support
WebSockets start as an HTTP request with an Upgrade header. Nginx doesn't forward hop-by-hop headers like Upgrade and Connection by default and talks HTTP/1.0 to backends, so WebSocket connections fail unless you configure them explicitly.
Create a small file in conf.d, which Nginx includes inside the http context. The map sets Connection: upgrade only when the client asks for an upgrade:
sudo nano /etc/nginx/conf.d/websocket-map.conf
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
Then add three lines to the location / block in /etc/nginx/sites-available/your_domain:
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-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
Nginx closes a proxied connection when no data flows for 60 seconds (proxy_read_timeout). If your WebSocket app doesn't send pings more often than that, raise the timeout in this location, for example proxy_read_timeout 1h;.
Test and reload:
sudo nginx -t
sudo systemctl reload nginx
These settings are harmless for normal HTTP requests, so you can keep them even if only part of your app uses WebSockets.
Step 5 - Load balancing across several backends
When you run more than one instance of the app, define them in an upstream block and point proxy_pass at it. Start a second test backend on port 3001 in a third SSH session:
mkdir -p ~/backend2
echo "Hello from backend 2" > ~/backend2/index.html
python3 -m http.server 3001 --bind 127.0.0.1 --directory ~/backend2
Edit your server block:
sudo nano /etc/nginx/sites-available/your_domain
Add the upstream block above the server block and change proxy_pass to use its name:
upstream app_backend {
least_conn;
server 127.0.0.1:3000 max_fails=3 fail_timeout=10s;
server 127.0.0.1:3001 max_fails=3 fail_timeout=10s;
}
server {
listen 80;
listen [::]:80;
server_name your_domain www.your_domain;
location / {
proxy_pass http://app_backend;
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-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
- Without a method directive, Nginx uses round robin.
least_connsends each request to the server with the fewest active connections, which suits requests of uneven duration.ip_hashkeeps each client IP on the same server if your app stores sessions in memory. max_fails=3 fail_timeout=10smarks a server as unavailable for 10 seconds after 3 failed attempts, and Nginx retries the request on the next server. This is passive health checking; the open source Nginx has no active health checks.- Add
backupto aserverline to use it only when all the others are down.
Test, reload and send a few requests:
sudo nginx -t
sudo systemctl reload nginx
for i in 1 2 3 4; do curl -s http://your_domain/; done
Hello from backend 1
Hello from backend 2
Hello from backend 1
Hello from backend 2
Stop one backend with Ctrl+C and repeat the loop: all requests are now answered by the remaining one.
Step 6 - Securing the proxy with HTTPS
Nginx terminates TLS and talks plain HTTP to the backends on the loopback interface. The quickest way to get a certificate is Certbot with the Nginx plugin:
sudo apt install certbot python3-certbot-nginx
sudo certbot --nginx -d your_domain -d www.your_domain
Certbot adds a listen 443 ssl configuration with the certificate to your server block and a redirect from HTTP to HTTPS. Because you already pass X-Forwarded-Proto $scheme, your app now receives https and can generate correct URLs.
Verify it:
curl -sI https://your_domain/ | head -n 1
curl -sI http://your_domain/ | head -n 1
HTTP/1.1 200 OK
HTTP/1.1 301 Moved Permanently
Step 7 - Tuning timeouts and request size
Two defaults commonly need adjusting for applications. Add them to the server block (for the whole site) or to a specific location:
client_max_body_size 50m;
proxy_read_timeout 120s;
client_max_body_sizedefaults to 1 MB. Larger uploads fail with413 Request Entity Too Largebefore they reach your app.proxy_read_timeoutdefaults to 60 seconds. Raise it only for endpoints that legitimately take longer (reports, exports); long timeouts everywhere hide slow backends.
Test and reload after the change with sudo nginx -t && sudo systemctl reload nginx.
Troubleshooting
Nginx logs proxy problems in /var/log/nginx/error.log. Watch it while you reproduce the problem:
sudo tail -f /var/log/nginx/error.log
502 Bad Gateway with "connect() failed (111: Connection refused)". Nothing is listening on the backend address. Check it with sudo ss -tlnp | grep 3000 and curl http://127.0.0.1:3000/. A frequent cause is an app that listens only on IPv6 ::1 while proxy_pass uses 127.0.0.1, or the other way round.
504 Gateway Timeout with "upstream timed out". The backend accepted the connection but didn't answer within proxy_read_timeout. Find out why the request is slow in the app's logs before raising the timeout.
Redirect loops or http:// links after enabling HTTPS. The app doesn't trust X-Forwarded-Proto and thinks the request came over HTTP. Enable your framework's proxy support so it reads that header from 127.0.0.1.
WebSocket fails with a 400 error or closes after a minute. Check that proxy_http_version 1.1 and both Upgrade/Connection headers are in the location that handles the WebSocket path, and raise proxy_read_timeout if the connection drops after exactly 60 seconds.
413 Request Entity Too Large. Increase client_max_body_size as shown in Step 7.
Conclusion
Nginx now accepts traffic on ports 80 and 443, terminates TLS, forwards requests with the client's real IP and protocol to your application, supports WebSockets and balances load across several backends. Your application stays bound to the loopback interface, out of reach from the internet. As next steps, run your application as a systemd service, enable HTTP/2 on the HTTPS listener, and add rate limiting with limit_req for login or API endpoints.
