DeployWise
HomeGuidesNginx Reverse Proxy Guide
NginxReverse ProxyDevOpsLoad BalancingSSL

Nginx Reverse Proxy: Complete Setup Guide

The definitive guide to configuring Nginx as a reverse proxy for any backend. Covers Node.js, Python, Go, PHP, Docker, and static sites with real, production-ready config examples for SSL termination, load balancing, WebSockets, caching, security, and performance tuning.

18 min read
Published March 2026

What is a reverse proxy and why use one?

A reverse proxy sits between the internet and your backend servers. Instead of clients connecting directly to your application, they connect to Nginx, which forwards the request to the correct backend and returns the response.

Client (Browser)
       |
       v
   [ Nginx :80/:443 ]  <-- SSL termination, caching, compression
       |
       v
   [ Your App :3000 ]  <-- Node.js, Python, Go, PHP, Docker

Your application runs on a high port (3000, 8000, 8080) and never touches SSL, compression, or rate limiting. Nginx handles all of that at the edge. If your app crashes, Nginx returns a friendly error page instead of a connection refused.

Why use a reverse proxy:
SSL/TLS termination — Nginx handles certificates, your app talks plain HTTP
Load balancing — distribute traffic across multiple backend instances
Gzip compression — compress responses without app-level code
Static file serving — serve assets directly, bypassing your app
Rate limiting — protect against abuse and DDoS at the edge
Security headers — add HSTS, CSP, X-Frame-Options globally
Zero-downtime deploys — swap backends without dropping connections
Multiple apps on one server — route domains to different backends

1Install Nginx on Ubuntu

Install Nginx from the default Ubuntu repositories and start it:

sudo apt update
sudo apt install nginx -y
sudo systemctl enable nginx
sudo systemctl start nginx

# Verify Nginx is running
sudo nginx -t
curl -I http://localhost

Nginx config files live in /etc/nginx/. You create site configs in /etc/nginx/sites-available/ and symlink them to /etc/nginx/sites-enabled/.

# Create a new site config
sudo nano /etc/nginx/sites-available/myapp

# Enable it
sudo ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/

# Remove default site (optional)
sudo rm /etc/nginx/sites-enabled/default

# Test and reload
sudo nginx -t && sudo systemctl reload nginx

2Basic reverse proxy configuration

The minimal Nginx reverse proxy config uses proxy_pass to forward requests and proxy_set_header to preserve client information:

server {
    listen 80;
    server_name example.com;

    location / {
        proxy_pass http://localhost:3000;
        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_pass tells Nginx where to forward requests. The headers ensure your backend sees the real client IP and protocol, not Nginx's internal address. This pattern works for any HTTP backend.

3Backend-specific configurations

Different backends require slightly different proxy setups. Here are production-ready configs for the most common stacks.

Node.js / Express (port 3000)

upstream node_backend {
    server 127.0.0.1:3000;
    keepalive 32;
}

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

    location / {
        proxy_pass http://node_backend;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header 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;
        proxy_cache_bypass $http_upgrade;
    }
}

The keepalive 32 directive reuses TCP connections between Nginx and Node.js, reducing latency. The Upgrade headers support WebSocket connections out of the box.

Python / Gunicorn (Unix socket)

upstream gunicorn_backend {
    server unix:/run/gunicorn/myapp.sock fail_timeout=0;
}

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

    client_max_body_size 10M;

    location /static/ {
        alias /var/www/myapp/static/;
        expires 30d;
        add_header Cache-Control "public, immutable";
    }

    location / {
        proxy_pass http://gunicorn_backend;
        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_redirect off;
    }
}

Unix sockets are faster than TCP for same-server communication. Start Gunicorn with: gunicorn myapp.wsgi:application --bind unix:/run/gunicorn/myapp.sock --workers 4

Go (port 8080)

upstream go_backend {
    server 127.0.0.1:8080;
    keepalive 64;
}

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

    location / {
        proxy_pass http://go_backend;
        proxy_http_version 1.1;
        proxy_set_header Connection "";
        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_buffering on;
        proxy_buffer_size 4k;
        proxy_buffers 8 4k;
    }
}

Go apps are fast, so Nginx mainly adds SSL and security. The empty Connection "" header enables HTTP keepalive between Nginx and Go. Higher keepalive 64 matches Go's concurrency.

Docker container

# Docker container running on port 8080
# docker run -d --name myapp -p 127.0.0.1:8080:8080 myapp:latest

upstream docker_backend {
    server 127.0.0.1:8080;
}

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

    location / {
        proxy_pass http://docker_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_connect_timeout 30s;
        proxy_read_timeout 300s;
    }
}

Bind the container to 127.0.0.1 so it's only accessible through Nginx, not directly from the internet. If using Docker Compose, reference the service name instead of localhost.

PHP-FPM (FastCGI)

server {
    listen 80;
    server_name example.com;
    root /var/www/myapp/public;
    index index.php index.html;

    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }

    location ~ \.php$ {
        fastcgi_pass unix:/run/php/php8.3-fpm.sock;
        fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
        include fastcgi_params;
        fastcgi_hide_header X-Powered-By;
    }

    location ~ /\.(?!well-known) {
        deny all;
    }
}

PHP uses fastcgi_pass instead of proxy_pass because PHP-FPM speaks the FastCGI protocol, not HTTP. The last location block denies access to hidden files like .env.

4WebSocket proxying

WebSocket connections require an HTTP upgrade. Without the correct headers, Nginx will close the connection after the handshake:

map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}

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

    location / {
        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_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

        # Prevent Nginx from closing idle WebSocket connections
        proxy_read_timeout 86400s;
        proxy_send_timeout 86400s;
    }
}

The map block dynamically sets the Connection header: "upgrade" for WebSocket requests, "close" for regular HTTP. The 86400s timeout (24 hours) prevents Nginx from killing idle WebSocket connections.

5Load balancing multiple backends

Nginx supports several load balancing algorithms. Define backends in an upstream block:

# Weighted round-robin (default algorithm)
upstream app_weighted {
    server 127.0.0.1:3000 weight=3;
    server 127.0.0.1:3001 weight=2;
    server 127.0.0.1:3002 weight=1;
}

# Least connections — best for variable response times
upstream app_least_conn {
    least_conn;
    server 127.0.0.1:3000;
    server 127.0.0.1:3001;
    server 127.0.0.1:3002;
}

# IP hash — sticky sessions
upstream app_ip_hash {
    ip_hash;
    server 127.0.0.1:3000;
    server 127.0.0.1:3001;
}

server {
    listen 80;
    server_name example.com;
    location / {
        proxy_pass http://app_least_conn;
        proxy_http_version 1.1;
        proxy_set_header Connection "";
        include proxy_params;
    }
}

Round-robin (default) distributes requests evenly. Weighted sends more traffic to stronger servers. least_conn is best when requests have variable processing times. ip_hash provides session persistence when your app stores state in memory.

6SSL termination with Let's Encrypt

Install Certbot and obtain a free certificate, then configure Nginx to handle HTTPS:

# Install Certbot
sudo apt install certbot python3-certbot-nginx -y

# Obtain certificate (Nginx must be running with server_name set)
sudo certbot --nginx -d example.com -d www.example.com

# Verify auto-renewal
sudo certbot renew --dry-run

Certbot modifies your config automatically. For manual control, here is the full SSL config:

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

server {
    listen 443 ssl http2;
    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;
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_prefer_server_ciphers off;
    ssl_session_cache shared:SSL:10m;
    ssl_session_timeout 1d;
    ssl_stapling on;
    ssl_stapling_verify on;
    resolver 1.1.1.1 8.8.8.8 valid=300s;

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

The first block redirects HTTP to HTTPS. OCSP stapling speeds up SSL handshakes. Certbot handles certificate renewal automatically via a systemd timer.

7Proxy headers explained

These headers pass client information from Nginx to your backend. Without them, your app only sees requests from 127.0.0.1:

Host $host

Passes the original Host header so your app knows which domain was requested. Essential for apps serving multiple domains.

X-Real-IP $remote_addr

Sets the real client IP address. Your app reads this instead of seeing Nginx's loopback address.

X-Forwarded-For $proxy_add_x_forwarded_for

Appends the client IP to the forwarding chain. Supports multiple proxies (CDN → Nginx → App).

X-Forwarded-Proto $scheme

Tells your app whether the original request was HTTP or HTTPS. Critical for redirect logic and secure cookies.

8Performance: gzip, caching, and buffer tuning

Add these directives to your http block in /etc/nginx/nginx.conf:

http {
    # Gzip compression
    gzip on;
    gzip_vary on;
    gzip_proxied any;
    gzip_comp_level 5;
    gzip_min_length 1000;
    gzip_types text/plain text/css text/xml text/javascript
               application/json application/javascript
               application/xml+rss image/svg+xml;

    # Proxy buffer tuning
    proxy_buffer_size 8k;
    proxy_buffers 16 8k;
    proxy_busy_buffers_size 16k;

    # Response caching
    proxy_cache_path /var/cache/nginx levels=1:2
                     keys_zone=app_cache:10m max_size=1g
                     inactive=60m use_temp_path=off;

    server {
        listen 80;
        server_name example.com;

        location ~* \.(jpg|jpeg|png|gif|ico|css|js|woff2)$ {
            proxy_pass http://localhost:3000;
            proxy_cache app_cache;
            proxy_cache_valid 200 7d;
            add_header X-Cache-Status $upstream_cache_status;
            expires 30d;
        }

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

gzip_comp_level 5 balances CPU usage and compression ratio. Buffer tuning prevents Nginx from writing responses to disk. The cache stores upstream responses so repeated requests skip the backend entirely.

9Security: rate limiting, request limits, and server hardening

Harden your Nginx reverse proxy against common attacks:

http {
    server_tokens off;                  # Hide Nginx version
    client_max_body_size 25M;           # Limit upload size
    large_client_header_buffers 4 8k;   # Limit header size

    # Rate limiting zones
    limit_req_zone $binary_remote_addr zone=general:10m rate=10r/s;
    limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;

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

        # Security headers
        add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
        add_header X-Frame-Options "SAMEORIGIN" always;
        add_header X-Content-Type-Options "nosniff" always;
        add_header Referrer-Policy "strict-origin-when-cross-origin" always;
        add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;

        location /api/ {
            limit_req zone=general burst=20 nodelay;
            proxy_pass http://localhost:3000;
            include proxy_params;
        }

        location /api/auth/ {
            limit_req zone=login burst=3 nodelay;
            proxy_pass http://localhost:3000;
            include proxy_params;
        }

        location ~ /\.(env|git|htaccess) { deny all; return 404; }

        location / {
            limit_req zone=general burst=30 nodelay;
            proxy_pass http://localhost:3000;
            include proxy_params;
        }
    }
}

server_tokens off hides the Nginx version from response headers. Rate limiting prevents brute-force attacks — the login zone allows only 5 requests per minute per IP. The client_max_body_size directive prevents oversized uploads from consuming memory.

10Multiple domains on one server

Nginx routes requests to different backends based on the server_name directive. Each domain gets its own server block:

# App 1: Node.js on port 3000
server {
    listen 80;
    server_name app1.example.com;
    location / {
        proxy_pass http://127.0.0.1:3000;
        include proxy_params;
    }
}

# App 2: Python/Gunicorn on Unix socket
server {
    listen 80;
    server_name app2.example.com;
    location / {
        proxy_pass http://unix:/run/gunicorn/app2.sock;
        include proxy_params;
    }
}

# App 3: Go on port 8080
server {
    listen 80;
    server_name app3.example.com;
    location / {
        proxy_pass http://127.0.0.1:8080;
        include proxy_params;
    }
}

# Catch-all: reject unknown domains
server {
    listen 80 default_server;
    server_name _;
    return 444;
}

Put each server block in a separate file under /etc/nginx/sites-available/. Use include proxy_params; to share common headers (create /etc/nginx/proxy_params with your standard proxy_set_header directives). The catch-all block with return 444 silently drops requests for unrecognized domains.

Troubleshooting common errors

502 Bad Gateway

Cause: Nginx cannot connect to the backend. The application is not running, listening on the wrong port, or the socket file does not exist.

Fix: Check your app is running: ss -tlnp | grep 3000. For Unix sockets, verify the file exists: ls -la /run/gunicorn/myapp.sock. Check Nginx error log: tail -f /var/log/nginx/error.log

504 Gateway Timeout

Cause: Backend is running but responding too slowly. Nginx's default proxy timeout is 60 seconds.

Fix: Increase timeouts: proxy_connect_timeout 120s; proxy_read_timeout 300s; proxy_send_timeout 120s; — or optimize your backend.

Permission denied connecting to socket

Cause: Nginx worker (www-data) does not have permission to read the Unix socket file.

Fix: Set socket permissions: chmod 660 /run/gunicorn/myapp.sock and ensure www-data is in the socket's group. On SELinux systems: setsebool -P httpd_can_network_connect 1

Client IP shows 127.0.0.1

Cause: Missing proxy headers. Your app sees Nginx's address instead of the real client IP.

Fix: Add proxy_set_header X-Real-IP $remote_addr and X-Forwarded-For $proxy_add_x_forwarded_for. In Express: app.set('trust proxy', 1). In Django: use django-ipware or REMOTE_ADDR.

WebSocket connections fail immediately

Cause: Missing HTTP/1.1 upgrade headers or proxy_read_timeout too short for idle connections.

Fix: Add proxy_http_version 1.1, proxy_set_header Upgrade $http_upgrade, proxy_set_header Connection 'upgrade'. Set proxy_read_timeout 86400s for long-lived connections.

413 Request Entity Too Large

Cause: Upload exceeds Nginx's default 1MB body size limit.

Fix: Add client_max_body_size 25M; (or your desired limit) to the server or location block.

How DeployWise automates Nginx configuration

Manually writing Nginx configs works, but it's tedious and error-prone across dozens of servers. DeployWise detects your application type and generates production-optimized Nginx configs automatically:

Auto-detects your stack (Node.js, Python, Go, PHP, Docker, static) and generates the right proxy config
Provisions free Let's Encrypt SSL certificates with auto-renewal
Enables gzip compression for all text-based content types
Configures WebSocket support when your app needs it
Sets up load balancing across multiple instances
Adds security headers (HSTS, X-Frame-Options, CSP) by default
Includes rate limiting and request size limits
Supports multiple domains on a single VPS with automatic routing
Full config override if you need custom Nginx directives

Push your code, and DeployWise handles Nginx, SSL, process management, and zero-downtime deploys. No SSH, no config files, no manual server management.

Frequently asked questions

What is an Nginx reverse proxy?

A server that sits between clients and your backend apps. It receives requests, forwards them to the right backend, and returns responses. It handles SSL, load balancing, caching, and security at the edge.

How do I set up Nginx as a reverse proxy?

Install Nginx, create a config in /etc/nginx/sites-available/ with a server block containing proxy_pass to your backend, symlink to sites-enabled, then run nginx -t && systemctl reload nginx.

What is the difference between proxy_pass and fastcgi_pass?

proxy_pass forwards HTTP requests to backends like Node.js, Python, or Go. fastcgi_pass uses the FastCGI protocol for PHP-FPM. Use proxy_pass for HTTP backends, fastcgi_pass for PHP.

How do I proxy WebSocket connections through Nginx?

Add proxy_http_version 1.1, proxy_set_header Upgrade $http_upgrade, and proxy_set_header Connection "upgrade". Set proxy_read_timeout to a high value (86400s) for idle connections.

Why am I getting a 502 Bad Gateway error?

Nginx cannot reach the backend. Verify your app is running (ss -tlnp | grep PORT), check proxy_pass points to the right address, and read /var/log/nginx/error.log for details.

Can Nginx load balance across multiple servers?

Yes. Define an upstream block with your backends and reference it in proxy_pass. Supports round-robin, least_conn, ip_hash, and weighted algorithms.

How do I add SSL to an Nginx reverse proxy?

Install Certbot (apt install certbot python3-certbot-nginx) and run certbot --nginx -d yourdomain.com. It configures SSL automatically with Let's Encrypt and sets up auto-renewal.

How does DeployWise handle Nginx configuration?

DeployWise detects your app type, generates optimized Nginx configs, provisions SSL, enables gzip, adds security headers, and configures WebSocket support automatically. Override any setting from the dashboard.

Stop writing Nginx configs by hand

DeployWise auto-generates production Nginx configs for any stack. SSL, load balancing, and security included.

Deploy Your App Now