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 sudo privileges.
  • A domain name with an A record pointing to the server. This guide uses your_domain as 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

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_pass forwards every request to the backend. Without a URI after the port, the original path is passed unchanged, so /api/users reaches the backend as /api/users.
  • Host $host passes the domain the client requested. By default Nginx would send 127.0.0.1:3000, which breaks apps that build absolute URLs or serve several domains.
  • X-Real-IP and X-Forwarded-For carry the client's IP address. Without them, your app sees every request as coming from 127.0.0.1.
  • X-Forwarded-Proto tells 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_conn sends each request to the server with the fewest active connections, which suits requests of uneven duration. ip_hash keeps each client IP on the same server if your app stores sessions in memory.
  • max_fails=3 fail_timeout=10s marks 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 backup to a server line 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_size defaults to 1 MB. Larger uploads fail with 413 Request Entity Too Large before they reach your app.
  • proxy_read_timeout defaults 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.