CoreMesh

Nginx Reverse Proxy Config Example You Can Copy and Test

A copy-paste nginx reverse proxy server block with the right headers, timeouts and WebSocket upgrade, plus where the file lives and how to reload safely.

Nginx Reverse Proxy Config Example You Can Copy and Test

Here's a server block that takes every request for app.example.com on port 80 and hands it to an app listening on port 3000 on the same machine. It forwards the headers the app needs to know who the real client is, sets explicit timeouts, and lets WebSocket connections upgrade. Change the server name and the port, reload nginx, and you have a working reverse proxy.

The config file is nginx.conf, found in /etc/nginx, /usr/local/nginx/conf, or /usr/local/etc/nginx depending on how nginx was installed. The server block goes inside the http block. After saving, nginx -s reload checks the new file for syntax errors and applies it, or rolls back to the old configuration if it's broken. The rest of this page explains each piece and the variations you'll hit in practice.

A working nginx reverse proxy config#

nginx
server {
    listen 80;
    server_name app.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_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";

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

That's the whole thing. The only two values specific to you are server_name and the address in proxy_pass. Everything else is a starting point you can tune per app.

The example in the ngx_http_proxy_module docs is even shorter: proxy_pass, a Host header, and an X-Real-IP header. It works. The extra lines here exist because most real apps care about the full client IP chain, whether the client used HTTPS, and WebSockets. Explicit timeouts are there so you know what they are when something hangs, instead of guessing what the build defaulted to.

What each line does#

Directive Why it's there
proxy_pass http://localhost:3000; Protocol, host and port of the app. nginx connects here for each request.
proxy_http_version 1.1; Needed for the WebSocket upgrade handshake to pass through.
proxy_set_header Host $host; Sends the hostname the client asked for instead of the proxied server's name. Apps that build absolute URLs or route by hostname need this.
proxy_set_header X-Real-IP $remote_addr; The client's IP address as nginx saw it.
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; The incoming X-Forwarded-For header with $remote_addr appended, or just $remote_addr if the client didn't send one. Keeps the chain intact when a CDN or load balancer sits in front.
proxy_set_header X-Forwarded-Proto $scheme; Tells the app whether the client used http or https, so redirects and secure cookies come out right.
proxy_set_header Upgrade $http_upgrade; Passes the client's Upgrade header to the app.
proxy_set_header Connection "upgrade"; Required alongside Upgrade for the handshake.
proxy_connect_timeout, proxy_send_timeout, proxy_read_timeout How long nginx waits to connect to, write to, and read from the app.

The $proxy_add_x_forwarded_for variable is the one people most often replace with $remote_addr, which throws away the upstream chain. This is what it computes:

javascript
function proxyAddXForwardedFor(headers, remoteAddr) {
  const existing = headers['x-forwarded-for'];
  return existing ? `${existing}, ${remoteAddr}` : remoteAddr;
}

// Client connects straight to nginx
console.log(proxyAddXForwardedFor({}, '203.0.113.9'));

// A CDN or load balancer already set the header
console.log(proxyAddXForwardedFor({ 'x-forwarded-for': '198.51.100.4' }, '203.0.113.9'));

When the request arrives directly, the header is just the client address. When something ahead of nginx already set it, nginx appends its own view of the client. Your app reads the first entry as the original client, and should only trust it if every hop in the chain is yours. The X-Forwarded-For reference on MDN covers the format and the spoofing caveat.

Where the config file lives and how to apply it#

nginx reads one main file, nginx.conf. The beginner's guide lists the three default locations: /usr/local/nginx/conf, /etc/nginx, or /usr/local/etc/nginx. On Ubuntu with the packaged nginx you'll find it at /etc/nginx/nginx.conf. Distro packages often split the configuration into several files pulled in from that directory, so look at where the existing server blocks live and put yours alongside them. The only structural rule from nginx's side is that server belongs inside http, and location inside server.

Edits don't take effect until you reload. Run this as the same user that started nginx:

text
nginx -s reload

This doubles as the config test step. When the master process receives the reload signal, it checks the syntax of the new file and tries to apply it. If that succeeds, it starts new worker processes and tells the old ones to finish their current requests and exit. If the new file is invalid, the master rolls back and keeps running the old configuration. A typo won't take a live server down.

Then confirm the proxy actually answers. From the server itself:

text
curl -i -H "Host: app.example.com" http://127.0.0.1/

You should see your app's response, not the nginx default page. If you don't, check error.log and access.log in /var/log/nginx or /usr/local/nginx/logs. Connection refused errors in error.log almost always mean the app isn't listening on the port you put in proxy_pass.

WebSocket upgrade through the proxy#

A WebSocket connection starts as an ordinary HTTP request carrying an Upgrade: websocket header and a Connection: Upgrade header. For the handshake to reach your app, nginx has to talk HTTP/1.1 to the backend and forward those two headers. That's the job of these three lines, already in the block above:

nginx
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";

Two things to know. First, the block above sends Connection: upgrade on every request, including normal page loads where Upgrade is empty. Most backends ignore it. If yours complains, move the two header lines into a dedicated location for your WebSocket path and leave them out of location /.

Second, proxy_read_timeout applies to WebSocket connections too. If a socket sits idle longer than that value, nginx closes it, and the client sees a disconnect. Either have the app send periodic pings or give the WebSocket location a longer read timeout:

nginx
location /ws/ {
    proxy_pass http://localhost:3000;
    proxy_http_version 1.1;
    proxy_set_header Host $host;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_read_timeout 3600s;
}

nginx picks the location with the longest matching prefix, so requests under /ws/ land here and everything else still goes to location /. The Upgrade header reference on MDN explains the handshake from the HTTP side.

Timeouts that make sense#

The three timeout directives cover three different phases:

  • proxy_connect_timeout is how long nginx waits to establish a connection to the app. On localhost this should be near-instant, so a short value fails fast when the app is down instead of leaving the browser spinning.
  • proxy_send_timeout is the wait between successive writes to the app. It matters for large request bodies such as uploads.
  • proxy_read_timeout is the wait between successive reads from the app. This is the one that bites on slow endpoints like report generation and on idle WebSockets.

I'd start with 10s, 60s and 60s as in the main block, then raise proxy_read_timeout only in the locations that need it. A long read timeout everywhere just means a stuck backend ties up clients for longer before they get an error.

One server block per subdomain#

The usual layout is one app per subdomain: app.example.com on port 3000, api.example.com on port 8080, and so on. nginx chooses a server block by the port it listens on and by server_name, then matches location inside it. So each subdomain gets its own block with the same proxy lines and a different proxy_pass.

Writing those blocks by hand gets repetitive. This generates them from a list, with the WebSocket lines added only where you ask for them:

python
apps = [
    ("app.example.com", 3000, True),
    ("api.example.com", 8080, False),
]

def server_block(name, port, websocket):
    lines = [
        "server {",
        "    listen 80;",
        f"    server_name {name};",
        "",
        "    location / {",
        f"        proxy_pass http://localhost:{port};",
        "        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 10s;",
        "        proxy_send_timeout 60s;",
        "        proxy_read_timeout 60s;",
    ]
    if websocket:
        lines += [
            "        proxy_set_header Upgrade $http_upgrade;",
            '        proxy_set_header Connection "upgrade";',
        ]
    lines += ["    }", "}"]
    return "\n".join(lines)

print("\n\n".join(server_block(*app) for app in apps))

Paste the output into your config inside the http block, reload, and test each hostname with the curl -H "Host: ..." command from earlier.

Nginx reverse proxy config with Docker#

The config doesn't change with Docker. The address does. Inside a container, localhost means that container, so an nginx container with proxy_pass http://localhost:3000; is trying to reach itself. Point proxy_pass at the app container's name instead, for example proxy_pass http://app:3000;, and make sure both containers are on the same Docker network. Compose puts services from one file on a shared network, so the service name resolves as a hostname.

If nginx runs on the host and only the app is in a container, publish the app's port to the host and keep proxy_pass http://localhost:3000; pointed at the published port. Everything else in the block, headers and timeouts and the WebSocket lines, stays the same. The headers matter even more here, because without X-Real-IP and X-Forwarded-For your app only ever sees Docker's internal gateway address.

Common mistakes#

  • No Host header. The app sees localhost:3000 as its hostname and generates redirects and links to it. Browsers then try to open localhost and fail. Always pass Host $host.
  • X-Forwarded-For set to $remote_addr. Works until you put a CDN in front, then every visitor appears to come from the CDN. Use $proxy_add_x_forwarded_for.
  • WebSocket handshake fails with a 400 or closes immediately. Nearly always a missing proxy_http_version 1.1 or missing Upgrade and Connection headers. All three are needed.
  • WebSockets drop after about a minute. That's proxy_read_timeout closing an idle connection. Raise it on the WebSocket location or send pings.
  • Edited the file, nothing changed. Changes only apply after nginx -s reload. If the reload ran but behavior didn't change, check that the file you edited is actually part of the loaded configuration, which is a common trap with split distro layouts.
  • Trailing slash on proxy_pass. The beginner's guide uses both http://localhost:8080 and http://localhost:8080/ with location /, and with that location they behave the same. With a prefix location like /api/, the trailing slash changes the path the app receives. After reloading, look at the app's request log to confirm the path is what you expect.
  • Reload run as the wrong user. The -s commands need to run under the same user that started nginx. On most servers that means sudo.
  • App not listening. A 502 from nginx with connection refused in error.log means the port in proxy_pass has nothing behind it. Fix the app, not nginx.

Quick reference#

  • File: nginx.conf in /etc/nginx, /usr/local/nginx/conf, or /usr/local/etc/nginx. server goes inside http.
  • Minimum that works: proxy_pass plus proxy_set_header Host $host;.
  • Client identity: X-Real-IP $remote_addr, X-Forwarded-For $proxy_add_x_forwarded_for, X-Forwarded-Proto $scheme.
  • WebSockets: proxy_http_version 1.1, Upgrade $http_upgrade, Connection "upgrade", and a longer proxy_read_timeout on that location.
  • Timeouts: proxy_connect_timeout short, proxy_send_timeout and proxy_read_timeout raised only where a path needs it.
  • Subdomains: one server block each, chosen by listen port and server_name.
  • Docker: proxy_pass to the container or service name on a shared network, not localhost.
  • Apply and test: nginx -s reload validates and rolls back on error. Then curl -i -H "Host: app.example.com" http://127.0.0.1/ and read error.log if it's not what you expected.

Quick answers

Where is the nginx reverse proxy config file located?

The main file is nginx.conf, in /etc/nginx, /usr/local/nginx/conf, or /usr/local/etc/nginx depending on the install. On Ubuntu with the distro package it's /etc/nginx/nginx.conf, and the config is often split into included files under that directory.

How do I configure an nginx reverse proxy for a subdomain?

Add one server block per subdomain, each with its own server_name and a proxy_pass pointing at that app's port. nginx picks the block by listen port and server_name, so the proxy lines inside can be identical.

How do I configure an nginx reverse proxy with Docker?

Use the same server block, but set proxy_pass to the app container's name on a shared Docker network instead of localhost, since localhost inside the nginx container is nginx itself. If nginx runs on the host, publish the app's port and proxy to localhost on that port.

How do I test an nginx reverse proxy config before it goes live?

Run nginx -s reload. The master process checks the new file's syntax, applies it if valid, and rolls back to the old configuration if not. Then request the site with curl and check error.log in /var/log/nginx if the response isn't from your app.

Why do WebSockets fail behind my nginx reverse proxy?

The handshake needs proxy_http_version 1.1 plus the Upgrade and Connection headers forwarded with proxy_set_header. If connections open but drop after a quiet period, proxy_read_timeout is closing the idle socket, so raise it on the WebSocket location.

Did this solve it?

One tap. No account.

Saved you an hour? Send a tip.

USDC or ETH on Base, Ethereum, Arbitrum and more. Keeps the notes free and the examples running.

Base

Network

0xc783bc5c3fa14cEC3f09A22284bC91C5AF0d59e1
Open in wallet · 5 USDC

On a computer? Scan the code with your phone wallet, or copy the address.

Pick the network your wallet or exchange uses. USDC works on all six; ETH on Base, Ethereum, Arbitrum and Optimism. Don't send on Tron or Solana: this address doesn't exist there. Tips are optional and unlock nothing; every guide stays free.

Discussion

Start the discussion

`code` and ``` blocks work

No account needed. Comments are read before they show up, usually the same day. Your email is never asked for.

Indra Wijaya

Builds and runs AI agents, bots and content pipelines, and writes down what works and what broke.

About · Email · RSS

Keep reading

New notes, no spam.

One post at a time, only when there's something worth reading. Follow in any feed reader.

Subscribe via RSS