$linuxjunkies
>

How to Configure nginx as a Reverse Proxy

Configure nginx as a reverse proxy: proxy_pass basics, correct header forwarding, WebSocket upgrade handling, and TLS termination with Let's Encrypt.

IntermediateUbuntuDebianFedoraArch9 min readUpdated June 7, 2026

Before you start

  • nginx installed and active (systemctl status nginx shows running)
  • A domain name pointed at your server's public IP
  • Port 80 and 443 open in your firewall (ufw, firewalld, or nftables)
  • A backend service already listening on a local port (e.g., 127.0.0.1:3000)

A reverse proxy sits in front of one or more backend services, forwarding client requests and returning responses on their behalf. nginx does this exceptionally well: low memory footprint, flexible configuration, and battle-tested SSL/TLS handling. This guide covers the four patterns you will reach for most often — basic proxy_pass, header forwarding, WebSocket proxying, and TLS termination — with concrete configuration blocks you can drop into a real server.

Prerequisites and Layout Assumptions

The examples assume nginx is already installed and running under systemd. Backend services listen on 127.0.0.1 on various ports (a common security posture — the backend never exposes itself directly). All config snippets belong under /etc/nginx/; the exact include layout varies by distro:

  • Debian/Ubuntu: drop site files in /etc/nginx/sites-available/ and symlink to sites-enabled/.
  • Fedora/RHEL/Rocky: place files in /etc/nginx/conf.d/ with a .conf extension.
  • Arch: same conf.d pattern, or edit /etc/nginx/nginx.conf directly.

Step 1 — Basic proxy_pass

The proxy_pass directive is the core of every reverse proxy setup. It tells nginx where to forward the request. The simplest case: forward all traffic for a virtual host to a local Node.js app on port 3000.

sudo nano /etc/nginx/sites-available/myapp.conf   # Debian/Ubuntu
# or
sudo nano /etc/nginx/conf.d/myapp.conf            # Fedora/RHEL/Arch

Paste this server block:

server {
    listen 80;
    server_name example.com www.example.com;

    location / {
        proxy_pass http://127.0.0.1:3000;
    }
}

On Debian/Ubuntu, enable the site and reload:

sudo ln -s /etc/nginx/sites-available/myapp.conf /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx

On Fedora/RHEL/Arch, just test and reload (no symlink step needed):

sudo nginx -t
sudo systemctl reload nginx

Trailing-slash subtlety: proxy_pass http://127.0.0.1:3000; (no trailing slash) forwards the full URI including the location prefix. Adding a trailing slash strips the prefix. For a root location / this makes no difference, but for location /api/ it matters — be deliberate.

Step 2 — Forwarding the Right Headers

Without extra headers, your backend sees every request as originating from 127.0.0.1 and has no idea about the original host, protocol, or client IP. Fix that with a shared snippet.

sudo nano /etc/nginx/proxy_params
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_http_version 1.1;
proxy_redirect     off;

Debian/Ubuntu ships this file already; on other distros create it manually. Include it in any location block that proxies:

location / {
    proxy_pass http://127.0.0.1:3000;
    include /etc/nginx/proxy_params;
}

Why proxy_http_version 1.1? nginx defaults to HTTP/1.0 for upstream connections, which disables keep-alives and breaks WebSockets. Setting 1.1 is almost always the right call.

If your backend framework (Express, Django, etc.) uses X-Forwarded-For to determine the client IP, make sure it is configured to trust the proxy. Otherwise an attacker can spoof the header. In Express that is app.set('trust proxy', 1); in Django it is USE_X_FORWARDED_HOST = True plus SECURE_PROXY_SSL_HEADER.

Step 3 — Proxying WebSockets

WebSocket connections start as an HTTP Upgrade handshake. nginx drops the Upgrade and Connection headers by default (they are hop-by-hop), so you must forward them explicitly.

location /ws/ {
    proxy_pass http://127.0.0.1:4000;
    include /etc/nginx/proxy_params;

    # WebSocket-specific
    proxy_set_header Upgrade    $http_upgrade;
    proxy_set_header Connection "upgrade";

    # Keep idle connections alive long enough for long-lived sockets
    proxy_read_timeout  3600s;
    proxy_send_timeout  3600s;
}

If your app mixes HTTP and WebSocket traffic on the same path, use a map to set Connection correctly for both:

# In the http {} block, outside server {}
map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}

# Then inside location {}
proxy_set_header Upgrade    $http_upgrade;
proxy_set_header Connection $connection_upgrade;

This map sends upgrade when the client requested an upgrade, and close for normal HTTP — no disruption to regular requests.

Step 4 — TLS Termination

TLS termination means nginx handles HTTPS with clients while the backend receives plain HTTP locally. This keeps certificate management centralised and offloads crypto from your app.

Get a Certificate

Use Certbot with the nginx plugin (the fastest path on most distros):

# Debian/Ubuntu
sudo apt install certbot python3-certbot-nginx

# Fedora/RHEL 9+
sudo dnf install certbot python3-certbot-nginx

# Arch
sudo pacman -S certbot certbot-nginx
sudo certbot --nginx -d example.com -d www.example.com

Certbot edits your nginx config automatically. If you prefer manual control, use certonly and manage the server block yourself (shown below).

A Complete TLS Server Block

server {
    listen 443 ssl;
    listen [::]:443 ssl;
    http2 on;                          # nginx >= 1.25.1 syntax
    server_name example.com www.example.com;

    ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;

    # Modern TLS settings (Mozilla Intermediate, as of 2024)
    ssl_protocols             TLSv1.2 TLSv1.3;
    ssl_ciphers               ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256;
    ssl_prefer_server_ciphers off;
    ssl_session_cache         shared:SSL:10m;
    ssl_session_timeout       1d;
    ssl_session_tickets       off;

    # OCSP stapling
    ssl_stapling            on;
    ssl_stapling_verify     on;
    resolver                1.1.1.1 8.8.8.8 valid=300s;

    add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;

    location / {
        proxy_pass http://127.0.0.1:3000;
        include /etc/nginx/proxy_params;
    }
}

# Redirect plain HTTP to HTTPS
server {
    listen 80;
    listen [::]:80;
    server_name example.com www.example.com;
    return 301 https://$host$request_uri;
}

If you are on nginx older than 1.25.1, replace http2 on; with adding http2 to the listen directive: listen 443 ssl http2;.

Step 5 — Upstream Blocks for Load Balancing

When you have more than one backend instance, define an upstream block. nginx round-robins by default; add least_conn for uneven workloads.

upstream app_backend {
    least_conn;
    server 127.0.0.1:3000;
    server 127.0.0.1:3001;
    server 127.0.0.1:3002;
    keepalive 32;   # idle keep-alive connections to the backend pool
}

server {
    listen 443 ssl;
    # ... ssl config ...

    location / {
        proxy_pass http://app_backend;
        include /etc/nginx/proxy_params;
    }
}

Step 6 — Verify the Configuration

Always test before reloading. A syntax error in a running nginx will leave the old config in place; a reload after a bad config change does the same — but it is still good practice to catch errors early.

sudo nginx -t

Expected output (will vary slightly by version):

nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
sudo systemctl reload nginx

Confirm the proxy is working end-to-end:

curl -I https://example.com

Look for HTTP/2 200 (or 301 if testing HTTP), the Strict-Transport-Security header, and — if your backend sets one — a custom Server or application header.

For WebSocket verification, websocat is handy:

websocat wss://example.com/ws/

Troubleshooting

  • 502 Bad Gateway: nginx reached the backend but got no response, or the backend is not listening. Check sudo systemctl status myapp and confirm the port with ss -tlnp | grep 3000.
  • 504 Gateway Timeout: the backend is responding but too slowly. Raise proxy_read_timeout if the long response is expected (e.g., a slow API), or investigate backend performance.
  • WebSocket returns 400: the Upgrade and Connection headers are not reaching the backend. Double-check your location block has both proxy_set_header lines and that proxy_http_version 1.1 is set.
  • Certificate errors after renewal: Certbot's systemd timer should handle this automatically. Confirm with systemctl status certbot.timer and test with sudo certbot renew --dry-run.
  • Backend sees wrong IP: check your framework's trusted-proxy setting. Also verify SELinux (RHEL/Fedora) is not blocking nginx from connecting: sudo setsebool -P httpd_can_network_connect 1.
tested on:Ubuntu 24.04Debian 12Fedora 40Arch rolling

Frequently asked questions

What is the difference between proxy_pass with and without a trailing slash?
Without a trailing slash nginx forwards the full URI including the location prefix. With a trailing slash it strips the prefix before forwarding. This only matters for non-root locations like /api/ — be deliberate about which behaviour you need.
Do I need to open port 3000 in my firewall if nginx is proxying it?
No — and you should not. Bind your backend to 127.0.0.1 so it is only reachable locally. nginx connects to it over the loopback interface, and only ports 80 and 443 need to be open to the outside world.
Why does my backend still see 127.0.0.1 as the client IP?
You need both the proxy_params headers in nginx and trusted-proxy configuration in your application framework. nginx sends X-Real-IP and X-Forwarded-For, but the app must be told to read them rather than the direct socket address.
Does TLS between nginx and the backend matter?
If both nginx and the backend run on the same host, plain HTTP on the loopback is generally fine. If they are on separate machines on an untrusted network, add TLS on that leg too — use proxy_ssl_* directives and point proxy_pass at https://.
How do I get nginx to automatically reload when Certbot renews the certificate?
Certbot installs a deploy hook at /etc/letsencrypt/renewal-hooks/deploy/. Create a script there with 'systemctl reload nginx' and make it executable. Certbot's systemd timer runs twice daily and will call the hook after a successful renewal.

Related guides