Nginx as Reverse Proxy: Complete Setup Guide

Nginx as Reverse Proxy: Complete Setup Guide

Tested on: Ubuntu 26.04 LTS · Debian 12 · Rocky Linux 10 · Last updated: June 2026

Nginx handles reverse proxying better than almost anything else on Linux: it's fast, its configuration is explicit, and it composes cleanly across SSL termination, load balancing, caching, and WebSocket proxying. This guide covers the complete setup from a bare server to a production-ready reverse proxy configuration, including every header, directive, and edge case you'll encounter running real applications.

Practicing this on a live server, or need one to follow along? See our comparison of the best Linux VPS hosting providers for 2026 if you need a Linux box to work on.

Contents
  1. Prerequisites
  2. How a Reverse Proxy Works
  3. Install Nginx
  4. Create a Reusable proxy_params File
  5. Basic proxy_pass Configuration
  6. SSL Termination
  7. Multiple Sites on One Server
  8. Load Balancing
  9. Proxy Caching
  10. WebSocket Proxying
  11. Security Headers and Rate Limiting
    1. Further Reading

Prerequisites

  • A Linux server running Ubuntu 26.04, Debian 12, or Rocky Linux 10
  • Root or sudo access
  • One or more backend applications already running (Node.js, Python/gunicorn, Go, etc.), or you can substitute localhost:3000 with any running service for testing
  • DNS records pointing your domain(s) to this server (required for SSL certificates)
  • Ports 80 and 443 open in your firewall (ufw allow 'Nginx Full' on Ubuntu)

How a Reverse Proxy Works

Without a reverse proxy, every application needs its own port: your Node.js app on 3000, your FastAPI service on 8000, Grafana on 3001. None of them can share port 80 or 443. With Nginx in front, a single process owns the public ports and routes traffic based on hostname or URL path:

Internet → Nginx (port 80/443)
              ├── app.example.com        → Node.js   on localhost:3000
              ├── api.example.com        → FastAPI    on localhost:8000
              ├── example.com/grafana/   → Grafana    on localhost:3001
              └── static.example.com    → /var/www/static/ (served directly)

Nginx also becomes your single point for SSL certificates, security headers, rate limiting, and access logging. Your backend applications stay simple: they speak plain HTTP to localhost and never touch TLS or public IPs directly.

Install Nginx

# Ubuntu / Debian
sudo apt update && sudo apt install nginx -y

# Rocky Linux / RHEL / Fedora
sudo dnf install nginx -y

# Arch Linux
sudo pacman -S nginx

# Enable and start
sudo systemctl enable --now nginx

# Confirm version and status
nginx -v
sudo systemctl status nginx

# Quick smoke test
curl -I http://localhost

Expected output from nginx -v:

nginx version: nginx/1.24.0 (Ubuntu)

On Ubuntu and Debian, Nginx uses the sites-available / sites-enabled pattern. On RHEL-based systems, drop config files directly into /etc/nginx/conf.d/. The examples below use the Ubuntu layout: adjust the path if you're on Rocky Linux.

Create a Reusable proxy_params File

Before writing any server blocks, create a shared parameters file. Every proxy location will include this, so you define the headers once instead of repeating them everywhere:

sudo tee /etc/nginx/proxy_params << 'EOF'
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_set_header Upgrade           $http_upgrade;
proxy_set_header Connection        $connection_upgrade;
proxy_connect_timeout              60s;
proxy_send_timeout                 60s;
proxy_read_timeout                 60s;
EOF

The Upgrade and Connection headers are needed for WebSocket connections: they don't hurt regular HTTP proxying, so include them universally. The $connection_upgrade variable requires a map block in your http context (covered in the WebSocket section below).

Basic proxy_pass Configuration

Create a site configuration for your first application:

sudo nano /etc/nginx/sites-available/myapp
server {
    listen 80;
    server_name app.example.com;

    location / {
        proxy_pass http://localhost:3000;
        include /etc/nginx/proxy_params;
    }
}
# Enable the site and test the config
sudo ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx

The nginx -t command validates the config without reloading: always run it before applying changes. A successful test shows:

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

One critical detail with proxy_pass: the trailing slash matters. proxy_pass http://localhost:3000 passes the full URI as-is. proxy_pass http://localhost:3000/ (with trailing slash) strips the location prefix. For a root location / block these are equivalent, but for subpath proxying the difference is significant: covered in the Grafana example below.

SSL Termination

Install Certbot and get a certificate. Your DNS must already point to this server:

sudo apt install certbot python3-certbot-nginx -y
sudo certbot --nginx -d app.example.com

# Test automatic renewal
sudo certbot renew --dry-run

Certbot modifies your Nginx config automatically. For full control, write the SSL server block manually:

server {
    listen 80;
    server_name app.example.com;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl http2;
    server_name app.example.com;

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

    # Modern TLS - drops SSLv3, TLS 1.0, TLS 1.1
    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;
    ssl_prefer_server_ciphers off;

    # HSTS - tells browsers to always use HTTPS for this domain
    add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;

    # TLS session cache - improves performance for returning clients
    ssl_session_cache   shared:SSL:10m;
    ssl_session_timeout 1d;
    ssl_session_tickets off;

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

Multiple Sites on One Server

Each site gets its own file in sites-available. Nginx matches the server_name to route requests to the right block:

# /etc/nginx/sites-available/site1
server {
    listen 443 ssl http2;
    server_name site1.example.com;
    ssl_certificate     /etc/letsencrypt/live/site1.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/site1.example.com/privkey.pem;

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

# /etc/nginx/sites-available/site2
server {
    listen 443 ssl http2;
    server_name site2.example.com;
    ssl_certificate     /etc/letsencrypt/live/site2.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/site2.example.com/privkey.pem;

    location / {
        proxy_pass http://localhost:4000;
        include /etc/nginx/proxy_params;
    }
}
sudo ln -s /etc/nginx/sites-available/site1 /etc/nginx/sites-enabled/
sudo ln -s /etc/nginx/sites-available/site2 /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx

Load Balancing

Define an upstream block to distribute traffic across multiple backend instances. The upstream name is arbitrary: it's referenced in proxy_pass:

upstream myapp {
    server localhost:3000;
    server localhost:3001;
    server localhost:3002;
}

server {
    listen 443 ssl http2;
    server_name app.example.com;

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

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

The default algorithm is round-robin. Override it with one of these directives inside the upstream block:

# Least connections - best for long-running requests (file uploads, slow queries)
upstream myapp {
    least_conn;
    server localhost:3000;
    server localhost:3001;
}

# IP hash - same client always hits same server (sticky sessions, no shared session store needed)
upstream myapp {
    ip_hash;
    server localhost:3000;
    server localhost:3001;
}

# Weighted - send 3x more traffic to the first server
upstream myapp {
    server localhost:3000 weight=3;
    server localhost:3001 weight=1;
}

# Backup - only receives traffic when all primary servers are down
upstream myapp {
    server localhost:3000;
    server localhost:3001 backup;
}

Proxy Caching

Define the cache storage in the http block of /etc/nginx/nginx.conf, then reference it in location blocks:

# In /etc/nginx/nginx.conf - inside the http { } block
proxy_cache_path /var/cache/nginx
                 levels=1:2
                 keys_zone=APPCACHE:10m
                 max_size=1g
                 inactive=60m
                 use_temp_path=off;
# In your server block
location / {
    proxy_pass  http://myapp;
    include     /etc/nginx/proxy_params;

    proxy_cache                     APPCACHE;
    proxy_cache_valid               200 302  10m;
    proxy_cache_valid               404      1m;
    proxy_cache_use_stale           error timeout updating http_500 http_502 http_503 http_504;
    proxy_cache_background_update   on;
    proxy_cache_lock                on;

    # Expose cache status in the response - useful for debugging
    add_header X-Cache-Status $upstream_cache_status;
}

# Bypass cache entirely for admin and auth routes
location ~ ^/(admin|login|logout) {
    proxy_pass      http://myapp;
    include         /etc/nginx/proxy_params;
    proxy_no_cache  1;
    proxy_cache_bypass 1;
}

The X-Cache-Status header returns HIT, MISS, BYPASS, EXPIRED, or STALE, check it with curl -I https://app.example.com to confirm caching is working.

WebSocket Proxying

WebSockets require an HTTP Upgrade handshake. Add the map block to your http context first, then configure the location:

# In /etc/nginx/nginx.conf - inside http { }
map $http_upgrade $connection_upgrade {
    default  upgrade;
    ''       close;
}
# In your server block
location /ws/ {
    proxy_pass          http://localhost:3000;
    proxy_http_version  1.1;
    proxy_set_header    Upgrade    $http_upgrade;
    proxy_set_header    Connection $connection_upgrade;
    proxy_set_header    Host       $host;
    proxy_read_timeout  86400s;   # keep WS connection alive - adjust to your needs
    proxy_send_timeout  86400s;
}

If your proxy_params file already includes the Upgrade and Connection headers (as set up earlier), you only need the extended timeout overrides in the WebSocket location block.

Security Headers and Rate Limiting

# In http block - define rate limit zones
limit_req_zone  $binary_remote_addr  zone=api:10m    rate=10r/s;
limit_req_zone  $binary_remote_addr  zone=login:10m  rate=1r/m;
limit_conn_zone $binary_remote_addr  zone=addr:10m;
# In your server block
server_tokens off;   # don't expose Nginx version in headers or error pages

add_header X-Frame-Options           "SAMEORIGIN"                always;
add_header X-Content-Type-Options    "nosniff"                   always;
add_header Referrer-Policy           "no-referrer-when-downgrade" always;
add_header


JM Carreira

JM Carreira

I’ve used Linux since 2008 and it has been my main system since 2021. When an article has a “Tested on” box, I ran the steps on my Lenovo Tiny. More about me and the test machine.

Go up

This site uses cookies for analytics and advertising (Google AdSense). By continuing to browse, you accept our use of cookies. Read our privacy policy