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.

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#
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:
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:
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:
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:
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:
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_timeoutis 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_timeoutis the wait between successive writes to the app. It matters for large request bodies such as uploads.proxy_read_timeoutis 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:
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
Hostheader. The app seeslocalhost:3000as its hostname and generates redirects and links to it. Browsers then try to openlocalhostand fail. Always passHost $host. X-Forwarded-Forset 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.1or missingUpgradeandConnectionheaders. All three are needed. - WebSockets drop after about a minute. That's
proxy_read_timeoutclosing 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 bothhttp://localhost:8080andhttp://localhost:8080/withlocation /, 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
-scommands need to run under the same user that started nginx. On most servers that meanssudo. - App not listening. A
502from nginx with connection refused inerror.logmeans the port inproxy_passhas nothing behind it. Fix the app, not nginx.
Quick reference#
- File:
nginx.confin/etc/nginx,/usr/local/nginx/conf, or/usr/local/etc/nginx.servergoes insidehttp. - Minimum that works:
proxy_passplusproxy_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 longerproxy_read_timeouton that location. - Timeouts:
proxy_connect_timeoutshort,proxy_send_timeoutandproxy_read_timeoutraised only where a path needs it. - Subdomains: one
serverblock each, chosen bylistenport andserver_name. - Docker:
proxy_passto the container or service name on a shared network, notlocalhost. - Apply and test:
nginx -s reloadvalidates and rolls back on error. Thencurl -i -H "Host: app.example.com" http://127.0.0.1/and readerror.logif 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.
Sorry about that. What was missing?
from 0 readers
Saved you an hour? Send a tip.
USDC or ETH on Base, Ethereum, Arbitrum and more. Keeps the notes free and the examples running.
Network
0xc783bc5c3fa14cEC3f09A22284bC91C5AF0d59e1
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
Next note