How to Configure Nginx as a Reverse Proxy

11 min read·Matthieu|

Configure Nginx as a reverse proxy for Node.js, Ollama, and other backends. Covers proxy_pass, header forwarding, WebSocket support, upstream load balancing, and production timeout tuning with verification at every step.

A reverse proxy sits between clients and your backend application. Nginx receives incoming requests, forwards them to a backend server (Node.js, Python, Go, Ollama), and returns the response to the client. This lets you add TLS termination, load balancing, caching, and access control without modifying your application.

This tutorial covers Nginx reverse proxy configuration from a basic proxy_pass to WebSocket proxying, upstream load balancing, and a production-ready Ollama setup.

Prerequisites

Before starting, you need:

Verify Nginx is running:

sudo systemctl status nginx

You should see active (running) in the output.

How do you configure proxy_pass in Nginx?

The proxy_pass directive tells Nginx where to forward requests. Place it inside a location block within a server block. Nginx sends the client's request to the specified backend URL and streams the response back. The directive accepts HTTP and HTTPS URLs, and can point to an IP address, domain name, or Unix socket.

Create a new server block file:

sudo nano /etc/nginx/sites-available/app.conf

Add a minimal reverse proxy configuration:

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

    location / {
        proxy_pass http://127.0.0.1:3000;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }
}

Enable the site and test the configuration:

sudo ln -s /etc/nginx/sites-available/app.conf /etc/nginx/sites-enabled/
sudo nginx -t

If nginx -t returns syntax is ok and test is successful, reload:

sudo systemctl reload nginx

Verify the proxy works:

curl -I http://app.example.com

The response headers should come from your backend application. If you see a 502 Bad Gateway, your backend is not running on port 3000.

What happens with a trailing slash in proxy_pass?

The trailing slash in proxy_pass controls URI rewriting. This is one of the most common sources of misconfiguration.

Configuration Request Forwarded to backend
proxy_pass http://127.0.0.1:3000 /app/users http://127.0.0.1:3000/app/users
proxy_pass http://127.0.0.1:3000/ /app/users http://127.0.0.1:3000/users
proxy_pass http://127.0.0.1:3000/v2/ /app/users http://127.0.0.1:3000/v2/users
proxy_pass http://127.0.0.1:3000/v2 /app/users http://127.0.0.1:3000/v2users

The rule: when proxy_pass includes a URI (anything after the host:port, even just /), Nginx strips the matching location prefix from the request URI and appends the remainder to the proxy_pass URI. When proxy_pass has no URI, the original request path passes through unchanged.

For a location /app/ block, the examples above apply. Notice the fourth row: without a trailing slash on /v2, the path becomes /v2users instead of /v2/users. Always include a trailing slash when you specify a path.

Which headers should you forward to the backend?

Without explicit header configuration, your backend application cannot see the client's real IP address, the original protocol, or the requested hostname. Nginx replaces these by default. Forward them manually.

Header Value Purpose
Host $host Preserves the original Host header so the backend knows which domain was requested
X-Real-IP $remote_addr Passes the client's IP address to the backend
X-Forwarded-For $proxy_add_x_forwarded_for Appends the client IP to the chain of proxies
X-Forwarded-Proto $scheme Tells the backend whether the original request used HTTP or HTTPS

Add these headers to your location block:

location / {
    proxy_pass http://127.0.0.1:3000;
    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;
}

After reloading Nginx, verify that headers reach the backend. If your app logs incoming headers, check them:

sudo systemctl reload nginx
curl -s http://app.example.com/headers

Your backend should see X-Real-IP matching the client's IP, not 127.0.0.1. If it shows 127.0.0.1, the proxy_set_header directives are missing or in the wrong context.

Security note: use $proxy_add_x_forwarded_for instead of $remote_addr for X-Forwarded-For. It appends the client IP to any existing X-Forwarded-For header. If you only set $remote_addr, you lose the proxy chain, which breaks IP tracking behind multiple proxies. If Nginx is your only proxy, the two are equivalent.

How do you reverse proxy a Node.js application?

Here is a complete server block for proxying a Node.js application running on port 3000. This example includes header forwarding, WebSocket support, and version hiding.

Create the server block:

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

    # Hide Nginx version in responses
    server_tokens off;

    location / {
        proxy_pass http://127.0.0.1:3000;
        proxy_http_version 1.1;

        # Header forwarding
        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;

        # WebSocket support
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";

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

Enable, test, and reload:

sudo ln -s /etc/nginx/sites-available/nodeapp.conf /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx

Verify the proxy is working:

curl -I http://nodeapp.example.com

Sharp eyes: the response should not include a Server: nginx/1.x.x line. The server_tokens off directive hides the version number. Version disclosure helps attackers target known vulnerabilities.

For TLS termination with Let's Encrypt, see Nginx SSL/TLS with Let's Encrypt.

How do you proxy WebSocket connections with Nginx?

WebSocket connections start as an HTTP request with an Upgrade header, then switch to a persistent bidirectional connection. Nginx uses HTTP/1.0 by default for upstream connections, which does not support the Upgrade mechanism. You need three directives to make WebSocket work: set proxy_http_version to 1.1, forward the Upgrade header, and set the Connection header to "upgrade".

For locations that serve both regular HTTP and WebSocket traffic, use the map directive to set the Connection header conditionally. Add this to the http block in /etc/nginx/nginx.conf:

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

Then reference $connection_upgrade in your server block:

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

    location / {
        proxy_pass http://127.0.0.1:8080;
        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 idle timeout killing WebSocket connections
        proxy_read_timeout 3600s;
        proxy_send_timeout 3600s;
    }
}

The default proxy_read_timeout is 60 seconds. If no data crosses the connection in that window, Nginx closes it. Set it higher for WebSocket connections. Your application should send WebSocket ping frames at an interval shorter than this timeout.

Test the configuration:

sudo nginx -t && sudo systemctl reload nginx

To verify WebSocket connectivity, install wscat and connect:

npm install -g wscat
wscat -c ws://ws.example.com/

If the connection opens, WebSocket proxying works. If you get unexpected server response (200), the Upgrade headers are not being forwarded. Check that the map directive is inside the http block, not inside a server block.

How do you reverse proxy Ollama for self-hosted AI?

Ollama serves LLM inference on port 11434 by default and binds to 127.0.0.1. Proxying it through Nginx lets you add TLS, authentication, and access control without modifying Ollama's configuration. The key difference from standard proxying: LLM inference can take minutes, and streaming responses need buffering disabled.

Create the server block:

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

    server_tokens off;

    # Restrict access to specific IPs
    allow 192.168.1.0/24;
    allow 10.0.0.0/8;
    deny all;

    location / {
        proxy_pass http://127.0.0.1:11434;
        proxy_http_version 1.1;

        # Header forwarding
        proxy_set_header Host localhost:11434;
        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 Connection '';

        # Disable buffering for streaming responses
        proxy_buffering off;
        proxy_cache off;
        chunked_transfer_encoding on;

        # Extended timeouts for LLM inference
        proxy_connect_timeout 300s;
        proxy_read_timeout 600s;
        proxy_send_timeout 600s;
    }
}

Enable and test:

sudo ln -s /etc/nginx/sites-available/ollama.conf /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx

Verify Ollama responds through the proxy:

curl http://ollama.example.com/api/tags

You should see a JSON response listing available models. If you get a 403 Forbidden, your client IP is not in the allow list. If you get a 502 Bad Gateway, Ollama is not running:

sudo systemctl status ollama

Test a streaming completion:

curl -N http://ollama.example.com/api/generate -d '{
  "model": "llama3.2",
  "prompt": "Hello",
  "stream": true
}'

Sharp eyes: the -N flag disables curl's output buffering. You should see tokens arrive one at a time. If the entire response arrives at once, proxy_buffering off is not set or is being overridden.

Why these settings differ from a standard proxy:

  • proxy_buffering off: Nginx normally buffers backend responses and sends them as a batch. For LLM streaming, you want each token sent to the client immediately.
  • proxy_read_timeout 600s: LLM inference on large models can take several minutes. The default 60s timeout would kill the connection mid-generation.
  • proxy_set_header Host localhost:11434: Ollama checks the Host header and rejects requests that do not match its configured bind address.
  • proxy_set_header Connection '': Clears the Connection header to prevent keep-alive issues with chunked streaming.

For TLS termination to expose Ollama securely over the internet, see Nginx SSL/TLS with Let's Encrypt. Never expose Ollama without access control. Combine IP restrictions with HTTP Basic Auth or API key validation for production use.

To add HTTP Basic Auth on top of IP restrictions:

sudo apt install apache2-utils
sudo htpasswd -c /etc/nginx/.ollama_htpasswd apiuser
sudo chmod 640 /etc/nginx/.ollama_htpasswd
sudo chown root:www-data /etc/nginx/.ollama_htpasswd

Then add to the location block in ollama.conf:

    auth_basic "Ollama API";
    auth_basic_user_file /etc/nginx/.ollama_htpasswd;

Verify the file permissions are correct:

ls -la /etc/nginx/.ollama_htpasswd

The file should show -rw-r----- with root owner and www-data group. Restricting permissions prevents other users on the server from reading the password hashes.

How do you configure upstream load balancing?

The upstream block defines a group of backend servers that Nginx distributes requests across. By default, Nginx uses weighted round-robin. Each server gets a share of requests proportional to its weight (default weight: 1).

Add an upstream block before your server block:

upstream app_backends {
    server 127.0.0.1:3000;
    server 127.0.0.1:3001;
    server 127.0.0.1:3002;
}

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

    location / {
        proxy_pass http://app_backends;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }
}

Load balancing methods

Round-robin (default): requests are distributed evenly across servers. No directive needed.

Least connections: sends requests to the server with the fewest active connections. Better for backends with varying response times:

upstream app_backends {
    least_conn;
    server 127.0.0.1:3000;
    server 127.0.0.1:3001;
}

IP hash: ties each client IP to a specific backend. Useful for session persistence without sticky cookies:

upstream app_backends {
    ip_hash;
    server 127.0.0.1:3000;
    server 127.0.0.1:3001;
}

Health checks with max_fails and fail_timeout

Nginx passively monitors backend health. If a server fails to respond, Nginx marks it as unavailable and stops sending requests for a period.

upstream app_backends {
    server 127.0.0.1:3000 max_fails=3 fail_timeout=30s;
    server 127.0.0.1:3001 max_fails=3 fail_timeout=30s;
    server 127.0.0.1:3002 backup;
}
Parameter Default Description
max_fails 1 Number of failed attempts within fail_timeout before marking the server as unavailable
fail_timeout 10s Window for counting failures, and how long the server stays marked unavailable
backup - Server only receives requests when all primary servers are down
weight 1 Relative share of requests in round-robin

After configuring upstream, test and reload:

sudo nginx -t && sudo systemctl reload nginx

Verify load balancing is working by sending multiple requests and checking which backend responds:

for i in $(seq 1 6); do curl -s http://app.example.com/health; echo; done

If your backends return an identifier in their response, you should see requests distributed across them.

How do you tune proxy buffering and timeouts?

Nginx buffers backend responses by default. It reads the entire response from the backend into memory (or disk if it exceeds the buffer), then sends it to the client. This is efficient for most applications but wrong for streaming, Server-Sent Events (SSE), or long-polling.

Timeout directives

Directive Default Recommended Purpose
proxy_connect_timeout 60s 5-10s Time to establish connection to backend. Keep short to fail fast.
proxy_read_timeout 60s 60-300s Time to wait for backend response. Increase for slow APIs.
proxy_send_timeout 60s 60s Time to send request body to backend. Increase for large uploads.
location / {
    proxy_pass http://127.0.0.1:3000;

    proxy_connect_timeout 10s;
    proxy_read_timeout 120s;
    proxy_send_timeout 60s;
}

These timeouts are between two successive read/write operations, not for the entire request. A response that sends data every 30 seconds never triggers a 60-second proxy_read_timeout.

Buffering controls

Directive Default Description
proxy_buffering on Buffer the full backend response before sending to client
proxy_buffer_size 4k or 8k Buffer for the first part of the response (headers)
proxy_buffers 8 4k or 8 8k Number and size of buffers for the response body
proxy_busy_buffers_size 8k or 16k Maximum size of buffers that can be busy sending to client

When should you disable proxy buffering?

Disable buffering when the backend streams data: LLM inference (Ollama, vLLM), Server-Sent Events, WebSocket-like long responses, or any API that sends chunked data incrementally.

location /stream/ {
    proxy_pass http://127.0.0.1:8080;
    proxy_buffering off;
}

Keep buffering enabled for standard request/response APIs. Buffering protects your backend from slow clients: Nginx absorbs the response quickly and sends it to the client at the client's pace. Without buffering, a slow client holds a backend connection open.

For advanced performance tuning, see .

How do you troubleshoot 502 and 504 errors?

502 Bad Gateway means Nginx could not connect to the backend or the backend sent an invalid response. 504 Gateway Timeout means the backend did not respond within proxy_read_timeout.

502 Bad Gateway checklist

  1. Is the backend running?
ss -tlnp | grep 3000

If no output, your backend is not listening on port 3000. Start it.

  1. Is the proxy_pass URL correct? Check for typos in the port number or IP address. Common mistake: using https:// for a backend that only speaks HTTP.

  2. Is SELinux blocking the connection? (RHEL/CentOS)

sudo setsebool -P httpd_can_network_connect 1
  1. Check the Nginx error log:
sudo tail -20 /var/log/nginx/error.log

Look for connect() failed (111: Connection refused) or no live upstreams.

504 Gateway Timeout checklist

  1. Increase proxy_read_timeout:
proxy_read_timeout 300s;
  1. Check if the backend is slow:
time curl http://127.0.0.1:3000/slow-endpoint

If this takes longer than your proxy_read_timeout, increase the timeout or optimize the backend.

  1. Check upstream health: if using upstream blocks, all servers might be marked as failed. Check the error log for no live upstreams while connecting to upstream.

Reading Nginx logs

Nginx writes error details to /var/log/nginx/error.log. For real-time monitoring:

sudo journalctl -u nginx -f

Or watch the error log directly:

sudo tail -f /var/log/nginx/error.log

For per-site logs, add access_log and error_log directives to your server block:

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

    access_log /var/log/nginx/app-access.log;
    error_log /var/log/nginx/app-error.log;

    location / {
        proxy_pass http://127.0.0.1:3000;
    }
}

This separates logs by application, making it easier to debug issues with a specific backend.

Common header issues

If your backend receives 127.0.0.1 as the client IP instead of the real address, the proxy_set_header X-Real-IP $remote_addr directive is missing. If your application generates HTTP links using http:// when it should use https://, the X-Forwarded-Proto header is not being forwarded. Some frameworks (Express, Django, Rails) need explicit configuration to trust proxy headers. In Express:

app.set('trust proxy', 1);

In Django, set SECURE_PROXY_SSL_HEADER:

SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')

Without these settings, your application ignores the forwarded headers even when Nginx sends them correctly.

Complete reference: proxy directives

For quick reference, here is the minimal set of directives for different proxy scenarios:

# Standard HTTP proxy
location / {
    proxy_pass http://127.0.0.1:3000;
    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;
}

# WebSocket proxy
location /ws/ {
    proxy_pass http://127.0.0.1:8080;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection $connection_upgrade;
    proxy_read_timeout 3600s;
}

# Streaming proxy (SSE, LLM)
location /stream/ {
    proxy_pass http://127.0.0.1:11434;
    proxy_http_version 1.1;
    proxy_buffering off;
    proxy_cache off;
    proxy_read_timeout 600s;
}

For server blocks managing multiple domains, see Nginx server blocks. For security hardening including rate limiting and access control, see .


Copyright 2026 Virtua.Cloud. All rights reserved. This content is original work by the Virtua.Cloud team. Reproduction, republication, or redistribution without written permission is prohibited.

Ready to try it yourself?

Deploy your own server in seconds. Linux, Windows, or FreeBSD.

See VPS Plans
How to Configure Nginx as a Reverse Proxy (2026)