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.

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


Go up

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