--- title: "How to Configure Nginx as a Reverse Proxy on Ubuntu 24.04" description: "Set up Nginx as a reverse proxy on Ubuntu 24.04: forward requests to a backend app, pass client headers, support WebSockets, load balance and add HTTPS." category: "Web Servers" language: en source: https://cubepath.com/docs/web-servers/nginx-as-reverse-proxy updated: 2026-09-25 --- # How to Configure Nginx as a Reverse Proxy on Ubuntu 24.04 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: ```bash sudo apt update sudo apt install nginx ``` The package enables and starts the service. Check it: ```bash systemctl status nginx --no-pager ``` ```output ● 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: ```bash 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: ```bash mkdir -p ~/backend1 echo "Hello from backend 1" > ~/backend1/index.html python3 -m http.server 3000 --bind 127.0.0.1 --directory ~/backend1 ``` ```output 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: ```bash curl http://127.0.0.1:3000/ ``` ```output Hello from backend 1 ``` > Note: `python3 -m http.server` is 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: ```bash sudo nano /etc/nginx/sites-available/your_domain ``` Add the following configuration: ```nginx 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`: ```bash sudo ln -s /etc/nginx/sites-available/your_domain /etc/nginx/sites-enabled/ sudo rm /etc/nginx/sites-enabled/default ``` Test and reload: ```bash sudo nginx -t sudo systemctl reload nginx ``` ```output 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: ```bash curl -i http://your_domain/ ``` ```output 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: ```bash sudo nano /etc/nginx/conf.d/websocket-map.conf ``` ```nginx map $http_upgrade $connection_upgrade { default upgrade; '' close; } ``` Then add three lines to the `location /` block in `/etc/nginx/sites-available/your_domain`: ```nginx 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: ```bash 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: ```bash 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: ```bash sudo nano /etc/nginx/sites-available/your_domain ``` Add the `upstream` block above the `server` block and change `proxy_pass` to use its name: ```nginx 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: ```bash sudo nginx -t sudo systemctl reload nginx for i in 1 2 3 4; do curl -s http://your_domain/; done ``` ```output 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: ```bash 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: ```bash curl -sI https://your_domain/ | head -n 1 curl -sI http://your_domain/ | head -n 1 ``` ```output 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`: ```nginx 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: ```bash 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.