Websites & apps

Set up Nginx as a reverse proxy for any backend app

Put Nginx in front of a Node.js, Python, Go or Java app running on a local port, forward the right headers, tune timeouts and buffering, and add free HTTPS.

IntermediateLinux server5 min read

Most application servers, such as Node.js, Gunicorn, Puma, Go binaries or Java apps, listen on a local port like 3000 or 8080. You do not want visitors connecting to that port directly. Instead, Nginx listens on ports 80 and 443, handles HTTPS, compression and static files, and passes each request to the app. This setup is called a reverse proxy, and this guide builds one that works for almost any backend.

Before you start

  • A Linux VPS with Nginx installed. See Install Nginx on Linux.
  • Your app running on the server and listening on a local port. This guide uses 127.0.0.1:3000.
  • A domain pointed at the server's IP.
  • SSH access with sudo.

Check the app answers locally before involving Nginx:

Bash
curl -I http://127.0.0.1:3000/

If that fails, fix the app first. Nginx cannot proxy to something that is not listening.

Bind the app to localhost

Configure your app to listen on 127.0.0.1, not 0.0.0.0. Then only Nginx on the same server can reach it, and nobody can bypass your HTTPS and headers by visiting http://203.0.113.10:3000.

How you set this depends on the framework, for example app.listen(3000, '127.0.0.1') in Express or --bind 127.0.0.1:8000 for Gunicorn. Confirm with:

Bash
sudo ss -ltnp | grep 3000

You want to see 127.0.0.1:3000, not 0.0.0.0:3000 or *:3000.

Create the proxy server block

Create a site file. On Ubuntu or Debian use /etc/nginx/sites-available/app.example.com; on AlmaLinux or Rocky use /etc/nginx/conf.d/app.example.com.conf.

Nginx
upstream app_backend {
    server 127.0.0.1:3000;
    keepalive 16;
}

server {
    listen 80;
    listen [::]:80;
    server_name app.example.com;

    client_max_body_size 20m;

    location / {
        proxy_pass http://app_backend;
        proxy_http_version 1.1;

        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 X-Forwarded-Host  $host;
        proxy_set_header Connection        "";

        proxy_connect_timeout 5s;
        proxy_send_timeout    60s;
        proxy_read_timeout    60s;
    }
}

On Ubuntu or Debian, enable it:

Bash
sudo ln -s /etc/nginx/sites-available/app.example.com /etc/nginx/sites-enabled/

What the headers do

Without these headers, your app sees every request as coming from 127.0.0.1 over plain HTTP.

  • Host passes the domain the visitor used. Apps use it to build links and pick tenants.
  • X-Real-IP and X-Forwarded-For carry the visitor's real IP address. $proxy_add_x_forwarded_for appends to any existing list instead of replacing it.
  • X-Forwarded-Proto tells the app whether the visitor used http or https, so it does not generate http:// links or redirect in a loop.
  • proxy_http_version 1.1 with an empty Connection header lets Nginx reuse connections to the app via keepalive.

Your app must be told to trust these headers. In Express that is app.set('trust proxy', 'loopback'); Django, Rails, Laravel and others have similar settings.

Timeouts and buffering

  • proxy_connect_timeout is how long Nginx waits to open a connection to the app. Keep it short; if the app is up, it connects instantly.
  • proxy_read_timeout is how long Nginx waits between bytes from the app. The default is 60 seconds. Raise it only for routes that really run long, such as reports or exports, in their own location.
  • Buffering is on by default. Nginx reads the whole response from the app quickly, frees the app, then sends it to the visitor at their speed. Leave it on, except for streaming responses such as server-sent events:
Nginx
location /events {
    proxy_pass http://app_backend;
    proxy_http_version 1.1;
    proxy_set_header Connection "";
    proxy_buffering off;
    proxy_read_timeout 1h;
}

For WebSockets, see Proxy WebSocket connections through Nginx.

Let Nginx serve static files

If your app has a folder of static assets, Nginx can send them directly, which is much faster than going through the app:

Nginx
location /static/ {
    alias /var/www/app/static/;
    expires 30d;
    access_log off;
}

alias replaces the /static/ part of the URL with the folder path. Keep the trailing slashes on both.

SELinux on AlmaLinux and Rocky Linux

SELinux blocks Nginx from making outbound network connections by default, which causes 502 Bad Gateway with "Permission denied" in the error log. Allow it once:

Bash
sudo setsebool -P httpd_can_network_connect 1

More in Fix Nginx permission denied caused by SELinux.

Test, reload and add HTTPS

Bash
sudo nginx -t && sudo systemctl reload nginx

Then request a certificate. Certbot adds a 443 block that keeps your location settings:

Bash
sudo certbot --nginx -d app.example.com

The full process is in Free SSL for Nginx with Let's Encrypt.

Check it works

Bash
curl -I https://app.example.com/

You want a 200 (or your app's normal redirect) and a server: nginx header. To confirm the headers reach your app, add a temporary debug route that prints X-Forwarded-Proto and X-Real-IP, or check your app's access log for your real IP instead of 127.0.0.1.

Troubleshooting

  • 502 Bad Gateway: the app is not running, is on a different port, or SELinux blocks the connection. Check sudo tail /var/log/nginx/error.log. See Fix 502 Bad Gateway in Nginx.
  • 504 Gateway Timeout: the app took longer than proxy_read_timeout. Speed up the route or raise the timeout for that location only.
  • App generates http:// links or loops on HTTPS: it is not reading X-Forwarded-Proto. Enable its trusted proxy setting.
  • All visitors show as 127.0.0.1 in app logs: the app ignores X-Forwarded-For. Configure it to trust the loopback proxy.
  • Uploads fail with 413: raise client_max_body_size in the server block.

Next steps

Was this guide helpful?

Still stuck?

Ask MdmHost AI for a quick answer, or open a ticket and our team will take a look. We are here 24/7.