proxy_pass #

proxy_pass is the core directive of Nginx’s reverse proxy. It determines where requests should be forwarded. Although it looks simple, there’s one detail — the trailing slash — that can cause subtle bugs that are very hard to debug. This article dissects every aspect of it thoroughly.

The Most Basic Usage #

server {
    listen 443 ssl;
    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;
    }
}

Every request to example.com is forwarded to the process running on port 3000. This is what you most often need to deploy a Node.js, Python, or other backend framework application.


Critical Detail: The Trailing Slash in proxy_pass #

This is the most common source of bugs in reverse proxy configuration. proxy_pass behavior changes fundamentally depending on whether there’s a URI (including a trailing slash) in the proxy_pass value.

Rule: No URI in proxy_pass #

location /api/ {
    proxy_pass http://localhost:3000;
    # No URI after the port — just host:port
}

When proxy_pass doesn’t include a URI, Nginx forwards the request URI as-is to the backend. No path modification.

Request: GET /api/users/123
Backend receives: GET /api/users/123

Rule: With a URI in proxy_pass #

location /api/ {
    proxy_pass http://localhost:3000/;
    # There's a trailing slash — this is a URI
}

When proxy_pass includes a URI (even just /), Nginx replaces the part matching the location with the URI in proxy_pass.

Request: GET /api/users/123
Nginx: matches /api/ from the URI
       remainder after /api/ = users/123
       combine with the URI in proxy_pass: / + users/123 = /users/123
Backend receives: GET /users/123

Complete Comparison in One Table #

ConfigurationRequestWhat the Backend Receives
proxy_pass http://backend at location /api/GET /api/usersGET /api/users
proxy_pass http://backend/ at location /api/GET /api/usersGET /users
proxy_pass http://backend/v2/ at location /api/GET /api/usersGET /v2/users
proxy_pass http://backend/service at location /api/GET /api/usersGET /serviceusers ← BUG!
proxy_pass http://backend/service/ at location /api/GET /api/usersGET /service/users

The fourth row is the most common trap: if the URI in proxy_pass doesn’t end with / but the location does, the paths get concatenated without a separator.

flowchart TD
    REQ["Request: GET /api/users/123"] --> Q{"Is there a URI\nin proxy_pass?"}
    Q -- "No\nproxy_pass http://backend" --> NOURI["Forward the URI as-is\nBackend receives: /api/users/123"]
    Q -- "Yes\nproxy_pass http://backend/" --> WITHURI["Replace the location part\nwith the URI in proxy_pass\nBackend receives: /users/123"]
    Q -- "Yes with a prefix\nproxy_pass http://backend/v2/" --> WPREFIX["Replace the location part\nwith the new prefix\nBackend receives: /v2/users/123"]

When to Use Each #

# ─── Without trailing slash: backend expects the full path ────────────────────────
# Use this when the backend is coded to accept /api/... paths
location /api/ {
    proxy_pass http://localhost:3000;
    # GET /api/users → backend: GET /api/users (matching the backend route)
}

# ─── With trailing slash: strip the /api/ prefix ────────────────────────────────
# Use this when the backend is coded without the /api/ prefix in its routes
location /api/ {
    proxy_pass http://localhost:3000/;
    # GET /api/users → backend: GET /users (the /api/ prefix is removed)
}

# ─── With a path prefix: change the prefix ──────────────────────────────────────────
# Use this for versioning or path mapping
location /api/ {
    proxy_pass http://localhost:3000/v2/;
    # GET /api/users → backend: GET /v2/users
}

proxy_http_version: Must Be Changed to 1.1 #

Nginx uses HTTP/1.0 for backend connections by default. HTTP/1.0 doesn’t support keepalive or chunked transfer encoding. Always change to 1.1 for modern applications:

location / {
    proxy_pass http://backend;

    # Required for:
    # - Keepalive connections to the backend
    # - Chunked transfer encoding
    # - WebSocket
    proxy_http_version 1.1;

    # Remove the Connection header from the client so it doesn't interfere with keepalive
    proxy_set_header Connection "";
}

Proxying to Unix Sockets #

For better performance on the same machine, use a Unix socket instead of TCP localhost. Unix sockets avoid TCP stack overhead entirely — no handshake, no network stack, direct communication through the kernel.

# ─── Application listening on a Unix socket ─────────────────────────────────────
location / {
    proxy_pass http://unix:/run/myapp/myapp.sock;
    # Format: http://unix:/path/to/socket
}

# ─── Gunicorn (Python) via a Unix socket ───────────────────────────────────────
location / {
    proxy_pass http://unix:/run/gunicorn/gunicorn.sock;
    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;
}

# ─── PHP-FPM via a Unix socket (uses fastcgi, not proxy) ──────────────
location ~ \.php$ {
    fastcgi_pass unix:/run/php/php8.2-fpm.sock;
    fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
    include fastcgi_params;
}

Common benchmark: Unix sockets can be 10-30% faster than TCP localhost for many small requests (like API calls). For large files, the difference isn’t significant.

Making an Application Listen on a Unix Socket #

# Node.js: run on a Unix socket
node server.js --socket /run/myapp/myapp.sock

# Or in code:
const app = express();
app.listen('/run/myapp/myapp.sock');

# Gunicorn:
gunicorn --bind unix:/run/gunicorn/gunicorn.sock myapp:app

# Make sure the Nginx worker has read/write permission to the socket
chown nginx:nginx /run/myapp/myapp.sock
chmod 660 /run/myapp/myapp.sock

WebSocket: Connection Upgrade #

WebSocket requires upgrading from HTTP to the WebSocket protocol. Nginx needs to be specially configured to forward the Upgrade header:

http {
    # Map to determine the Connection header value
    # If there's an Upgrade header → Connection: upgrade
    # If not → Connection: close
    map $http_upgrade $connection_upgrade {
        default  upgrade;
        ''       close;
    }

    server {
        location /ws/ {
            proxy_pass http://localhost:4000;
            proxy_http_version 1.1;

            # These two headers are required for WebSocket
            proxy_set_header Upgrade    $http_upgrade;
            proxy_set_header Connection $connection_upgrade;

            # Standard headers
            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;

            # Long timeouts for long-lived WebSocket connections
            proxy_read_timeout  3600s;
            proxy_send_timeout  3600s;
        }
    }
}

How the WebSocket handshake works through Nginx:

sequenceDiagram
    participant B as Browser
    participant N as Nginx
    participant WS as WebSocket Server

    B->>N: GET /ws/ HTTP/1.1\nUpgrade: websocket\nConnection: Upgrade
    N->>WS: GET /ws/ HTTP/1.1\nUpgrade: websocket\nConnection: Upgrade
    WS->>N: HTTP/1.1 101 Switching Protocols\nUpgrade: websocket\nConnection: Upgrade
    N->>B: HTTP/1.1 101 Switching Protocols
    Note over B,WS: WebSocket connection open — bidirectional
    B->>N: WebSocket Frame
    N->>WS: WebSocket Frame
    WS->>N: WebSocket Frame
    N->>B: WebSocket Frame

Path Rewriting Before Proxying #

Sometimes you need to change the request path before forwarding it to the backend. There are several ways:

Method 1: Trailing Slash (Simplest) #

# /app/users → backend receives /users (the /app/ prefix is removed)
location /app/ {
    proxy_pass http://localhost:8080/;
}

Method 2: rewrite with break #

# Remove the /api/v1/ prefix before forwarding
location /api/v1/ {
    rewrite ^/api/v1/(.*) /$1 break;
    # break: stop rewrite processing, forward with the new URI
    proxy_pass http://localhost:3000;

    # /api/v1/users → backend: /users
    # /api/v1/products/123 → backend: /products/123
}

Method 3: Regex location with capture groups #

# Change /service/v2/endpoint to /endpoint?version=2
location ~* ^/service/v(\d+)/(.+)$ {
    proxy_pass http://backend/$2?version=$1;
    # /service/v2/users → backend: /users?version=2
}

Method 4: sub_filter for Response Content #

Sometimes the backend returns absolute URLs in its response that need to be changed:

location /app/ {
    proxy_pass http://internal-backend/;

    # Replace all internal URLs with public URLs in the HTML response
    sub_filter 'http://internal-backend' 'https://example.com/app';
    sub_filter_once off;  # Replace all occurrences, not just the first
    sub_filter_types text/html text/javascript application/json;
}

Proxying to an Upstream Group #

In production practice, proxy_pass almost always points to an upstream block rather than a single address. This enables load balancing and automatic failover:

upstream app_backend {
    # Round-robin by default
    server 10.0.0.1:3000;
    server 10.0.0.2:3000;
    server 10.0.0.3:3000;

    # Pool of keepalive connections
    keepalive 32;
}

server {
    location / {
        proxy_pass http://app_backend;
        proxy_http_version 1.1;
        proxy_set_header Connection "";
    }
}

Upstream and load balancing details are covered in depth in Section 06.


Production-Ready Configuration Template #

Here’s a complete reverse proxy configuration template, production-ready for Node.js/Python applications:

# /etc/nginx/conf.d/myapp.com.conf

# ─── HTTP → HTTPS Redirect ───────────────────────────────────────────────────
server {
    listen 80;
    server_name myapp.com www.myapp.com;
    return 301 https://$host$request_uri;
}

# ─── HTTPS + Reverse Proxy ───────────────────────────────────────────────────
server {
    listen 443 ssl;
    http2  on;
    server_name myapp.com www.myapp.com;

    # SSL
    ssl_certificate     /etc/letsencrypt/live/myapp.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/myapp.com/privkey.pem;

    # Security
    server_tokens off;
    add_header X-Content-Type-Options  "nosniff"    always;
    add_header X-Frame-Options         "SAMEORIGIN" always;
    add_header Referrer-Policy         "strict-origin" always;

    # ─── Static files served directly by Nginx (faster than the backend) ─────────
    location /static/ {
        alias /var/www/myapp/static/;
        expires 1y;
        add_header Cache-Control "public, immutable";
        access_log off;
    }

    # ─── Favicon and robots.txt ───────────────────────────────────────────────
    location = /favicon.ico { alias /var/www/myapp/static/favicon.ico; access_log off; }
    location = /robots.txt  { alias /var/www/myapp/static/robots.txt; access_log off; }

    # ─── Main application → Node.js backend ────────────────────────────────────
    location / {
        proxy_pass         http://localhost:3000;
        proxy_http_version 1.1;

        # Headers so the backend knows the client's real information
        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;

        # Keepalive
        proxy_set_header Connection "";

        # Timeouts
        proxy_connect_timeout  10s;
        proxy_read_timeout     60s;
        proxy_send_timeout     60s;

        # Buffering
        proxy_buffering         on;
        proxy_buffer_size       4k;
        proxy_buffers           16 4k;

        # Error handling
        proxy_intercept_errors  on;
        error_page 502 503 504 /maintenance.html;
    }

    # ─── WebSocket ────────────────────────────────────────────────────────────
    location /ws/ {
        proxy_pass          http://localhost:3001;
        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  3600s;
    }

    # ─── Maintenance page ──────────────────────────────────────────────────
    location = /maintenance.html {
        root /var/www/myapp;
        internal;
    }

    # ─── Block sensitive access ────────────────────────────────────────────────
    location ~ /\. { deny all; }
}

Debugging proxy_pass #

# 1. Make sure the backend is reachable from Nginx
curl -v http://localhost:3000/api/health

# 2. Check whether Nginx receives and forwards correctly
# Add temporary debug headers
location /api/ {
    proxy_pass http://localhost:3000;
    add_header X-Proxy-By "nginx" always;
    add_header X-Upstream-Addr $upstream_addr always;  # selected backend IP
}

# 3. Look at the upstream response time in the logs
log_format proxy_log '$remote_addr - [$time_local] "$request" '
                     '$status $body_bytes_sent '
                     '$upstream_response_time '  # time the backend took to respond
                     '$upstream_addr';           # backend used

# 4. Check the error log for connection messages
tail -f /var/log/nginx/error.log | grep -E "connect|upstream"

# Common errors:
# "connect() failed (111: Connection refused)" → backend isn't running
# "no live upstreams while connecting to upstream" → all backends are down
# "upstream timed out (110: Connection timed out)" → backend is too slow

Proxying to Different Backends Based on Conditions #

In more complex scenarios, you can forward requests to different backends based on certain conditions — like API version, device type, or A/B testing:

http {
    # A/B testing: 20% of traffic to the new version
    split_clients "${remote_addr}${uri}" $app_version {
        20%  "v2";   # 20% of traffic to the new version
        *    "v1";   # 80% of traffic to the old version
    }

    upstream app_v1 { server localhost:3000; }
    upstream app_v2 { server localhost:3001; }

    server {
        location / {
            proxy_pass http://app_$app_version;
            # 20% of requests → http://app_v2 (localhost:3001)
            # 80% of requests → http://app_v1 (localhost:3000)

            add_header X-App-Version $app_version always;
        }
    }
}
# Routing based on a custom header
map $http_x_api_version $upstream_name {
    "v1"    api_v1_backend;
    "v2"    api_v2_backend;
    default api_v2_backend;  # Default to the latest version
}

upstream api_v1_backend { server localhost:3000; }
upstream api_v2_backend { server localhost:3001; }

server {
    location /api/ {
        proxy_pass http://$upstream_name;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        # X-API-Version: v1 → forwarded to localhost:3000
        # X-API-Version: v2 → forwarded to localhost:3001
    }
}

Summary #

  • Without a URI in proxy_pass: the URI is forwarded as-is. With a URI (including /): the location part is replaced — this critical difference often causes bugs.
  • Always use proxy_http_version 1.1 and proxy_set_header Connection "" for keepalive and modern HTTP features.
  • Unix sockets (http://unix:/path/to/sock) are 10-30% faster than TCP localhost for inter-process communication on the same machine.
  • WebSocket requires the Upgrade and Connection headers, plus long timeouts (proxy_read_timeout 3600s).
  • Use map $http_upgrade $connection_upgrade to conditionally determine the Connection header value — needed for servers serving both regular HTTP and WebSocket.
  • Always include X-Real-IP, X-Forwarded-For, and X-Forwarded-Proto so the backend knows the client’s real information.

← Previous: Reverse Proxy Concepts   Next: Proxy Headers →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact