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 #
| Configuration | Request | What the Backend Receives |
|---|---|---|
proxy_pass http://backend at location /api/ | GET /api/users | GET /api/users |
proxy_pass http://backend/ at location /api/ | GET /api/users | GET /users |
proxy_pass http://backend/v2/ at location /api/ | GET /api/users | GET /v2/users |
proxy_pass http://backend/service at location /api/ | GET /api/users | GET /serviceusers ← BUG! |
proxy_pass http://backend/service/ at location /api/ | GET /api/users | GET /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 FramePath 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.1andproxy_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
UpgradeandConnectionheaders, plus long timeouts (proxy_read_timeout 3600s).- Use
map $http_upgrade $connection_upgradeto conditionally determine the Connection header value — needed for servers serving both regular HTTP and WebSocket.- Always include
X-Real-IP,X-Forwarded-For, andX-Forwarded-Protoso the backend knows the client’s real information.