nginx Reverse Proxy Config, Explained
Build an nginx reverse proxy server block line by line: proxy_pass, the four headers your app needs, websockets, trailing slashes, and uploads.
What an nginx reverse proxy config does
An nginx reverse proxy takes the requests arriving on port 80 and port 443 and hands each one to an application already listening on a local port, then returns that application's answer to the browser. The config is a single server block, and the block is short. Almost all of the difficulty sits in five or six lines that tell your app who the real client was and which protocol that client used.
Everything below is built up from nothing on Ubuntu 24.04, using the nginx package from the distribution. The starting point is an app that already answers on 127.0.0.1:3000. If you have not settled on a proxy yet, how nginx compares with Caddy and Traefik is the comparison to read first. What follows is what the nginx answer looks like, line by line.
Run these configs on your own server. Test every change with sudo nginx -t before you reload, and read what it prints.
Where nginx keeps its config on Ubuntu
sudo apt update
sudo apt install -y nginx
ls -l /etc/nginx/sites-enabled/The main file is /etc/nginx/nginx.conf. It sets global options inside an http { } block and then pulls in two directories: /etc/nginx/conf.d/*.conf and /etc/nginx/sites-enabled/*. On Ubuntu and Debian you write one file per site in /etc/nginx/sites-available/ and switch it on with a symlink into /etc/nginx/sites-enabled/. Deleting the symlink disables the site and keeps the file.
Two directives used later only work in the http context, never inside a server block: map and upstream. Put them in their own file under /etc/nginx/conf.d/, because that directory is included at the http level.
The package ships an enabled site called default. It is marked default_server, which means it answers any request whose Host header matches no server_name anywhere in your config. While it stays enabled, a request that misses your names lands on it instead of on your app. Remove the symlink once your own site works.
sudo rm /etc/nginx/sites-enabled/default
sudo nginx -t
sudo systemctl reload nginxThe smallest server block that proxies one app
server {
listen 80;
listen [::]:80;
server_name app.example.com;
location / {
proxy_pass http://127.0.0.1:3000;
}
}Save that as /etc/nginx/sites-available/app.example.com, then enable it and load it.
sudo ln -s /etc/nginx/sites-available/app.example.com /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
curl -sI -H 'Host: app.example.com' http://127.0.0.1/listen 80; binds IPv4 and listen [::]:80; binds IPv6. Leave the second line out and a visitor whose DNS (domain name system) lookup returns an AAAA record for your server gets a connection refused, while everyone on IPv4 sees a working site. The bug report you receive says "it works for me".
server_name is matched against the Host header the browser sends. Several names can be listed, separated by spaces. If no block matches, nginx uses whichever block is default_server, which is why the packaged site had to go.
location / is a prefix match on the request path, and / matches every path. proxy_pass is the address nginx opens a connection to. Keep the app bound to 127.0.0.1 so the only route in is through nginx. If the app runs in a container, publish it as 127.0.0.1:3000:3000 and not as 3000:3000, because Docker writes its own rules and publishes ports straight past ufw, so a bare published port is reachable from the internet whatever your firewall says.
The curl line sends the correct Host header from the server itself, so you can test the block before DNS points anywhere.
What nginx sends upstream when you write nothing else
proxy_pass on its own hides four things from your application.
nginx speaks HTTP/1.0 to the backend by default and sends Connection: close, so every request opens a fresh upstream connection and no protocol upgrade is possible.
The Host header is rewritten to the value in proxy_pass, which is 127.0.0.1:3000. An app that builds absolute links from Host now produces links nobody outside the server can open.
The connection reaching the app comes from nginx, so the app sees a client address of 127.0.0.1. Every log line and every rate limit inside the app then records the proxy instead of the visitor.
The app cannot tell that the browser used HTTPS, because the connection it received is plain HTTP on a loopback address.
Four lines fix all of that.
The four headers to set, and what each one lets the backend see
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;
}Host carries the name the visitor typed. $host is the name from the request, with the port removed and the letters lowercased. Set it and your app builds correct absolute URLs: the redirect after a login, or the link inside a password reset email. Leave it out and those URLs point at 127.0.0.1:3000, so logging in sends the browser to an address that refuses the connection. If your app needs the port as well, because you serve it on 8080, use $http_host, which is the header exactly as the client sent it.
X-Real-IP carries one value: $remote_addr, the address nginx accepted the connection from. Apps read it for their own access logs and their own rate limiting.
X-Forwarded-For carries a list. $proxy_add_x_forwarded_for appends $remote_addr to whatever the client already put in that header, so the value is comma separated and the entry your nginx added is the last one. That detail decides whether the header can be trusted: a client may send any X-Forwarded-For it likes, so an app that reads the first entry can be told any address at all. When nginx is the edge server, write $remote_addr instead and discard the client's version. When a CDN or another proxy sits in front, use set_real_ip_from and real_ip_header from the realip module, so $remote_addr itself becomes the true client address.
X-Forwarded-Proto carries http or https. Frameworks read it to decide whether to mark cookies Secure and whether to force a redirect to HTTPS. Omit it on a TLS site and an app configured to force HTTPS sees http, answers with a redirect to the HTTPS address, receives the next request through nginx, still sees http, and redirects again. The browser gives up and shows ERR_TOO_MANY_REDIRECTS.
Repeating those four lines in every location is how they drift apart. Put them in one file and include it.
# /etc/nginx/snippets/proxy-headers.conf
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;location / {
include snippets/proxy-headers.conf;
proxy_pass http://127.0.0.1:3000;
}Inheritance here has a trap. A location inherits the proxy_set_header directives from its server block only while that location defines none of its own. Add one proxy_set_header inside the location and every header defined at the server level is dropped for that location. So keep all of them at a single level, or include the snippet in each location that proxies.
Why does my WebSocket app connect and then disconnect?
Because the defaults forbid the upgrade, and the default read timeout closes an idle tunnel after 60 seconds. A WebSocket begins as an HTTP request carrying Upgrade: websocket and Connection: Upgrade. Those are hop-by-hop headers, which means a proxy is expected to consume them rather than pass them on, and HTTP/1.0 has no upgrade mechanism at all. Both have to be put back by hand.
The map goes in the http context, in its own file.
# /etc/nginx/conf.d/websocket.conf
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}Then the location.
location / {
include snippets/proxy-headers.conf;
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}The map exists so that one location can serve both kinds of traffic. On an ordinary request $http_upgrade is empty, so $connection_upgrade becomes close. On an upgrade request it holds websocket, so the header sent upstream is Connection: upgrade. Hard-coding proxy_set_header Connection "upgrade"; sends that header on every plain page request too, and some backends answer such a request with a 400.
proxy_read_timeout is what produces the "it loads, then it stops updating" reports. It defaults to 60 seconds, and it measures the gap between two reads from the backend, not the lifetime of the connection. A WebSocket that stays quiet for 60 seconds is closed by nginx, and the browser console shows the socket closing with code 1006. Apps that send their own heartbeat more often than once a minute never notice. Apps that do not, die on the minute. Live editors and dashboards are where this appears first, a self-hosted n8n instance behind HTTPS being a common example.
Why does a trailing slash in proxy_pass change my URLs?
The rule is one sentence. If proxy_pass ends with a URI (uniform resource identifier), even a bare /, nginx removes the part of the request path that matched the location prefix and puts that URI in its place. If proxy_pass stops at the host and port, the request path is passed through untouched.
location /app/ {
proxy_pass http://127.0.0.1:3000/;
}A request for /app/status reaches the backend as /status.
location /app/ {
proxy_pass http://127.0.0.1:3000;
}A request for /app/status reaches the backend as /app/status.
Which form you want depends on the app. An app with a base-path or sub-folder setting wants the second form, with the setting told about /app. An app that knows nothing about prefixes needs the first. The first form has a cost you see straight away: the HTML that app returns still contains absolute paths such as /static/main.css, the browser asks the site root for them, no location matches, and the page renders with no styling. The browser network tab shows those asset requests coming back as 404. The fix is the app's own base-path setting, or a second location /static/ pointing at the same backend.
A regex location cannot carry a URI in proxy_pass. sudo nginx -t refuses the config and names the reason: "proxy_pass" cannot have URI part in location given by regular expression, or inside named location, or inside "if" statement, or inside "limit_except" block.
This whole class of problem disappears when each app gets its own name, app.example.com, proxied from location /. Sub-paths are worth the trouble only when you cannot add DNS records.
How do I put more than one backend behind one name?
With an upstream block. It belongs to the http context, so write it above the server block in the same file, or in /etc/nginx/conf.d/.
upstream app_backend {
least_conn;
server 127.0.0.1:3000 max_fails=3 fail_timeout=30s;
server 127.0.0.1:3001 max_fails=3 fail_timeout=30s;
keepalive 32;
}The location then names it: proxy_pass http://app_backend;.
The default method is round robin. least_conn sends each request to the backend holding the fewest active connections, which suits requests of uneven length. ip_hash pins one client address to one backend. You need ip_hash when the app keeps sessions in its own memory, because round robin over two such backends logs people out at random as their requests land on the instance that never saw them. Moving sessions into shared storage is the better answer.
max_fails=3 fail_timeout=30s means three failed attempts within 30 seconds take that server out for 30 seconds. When every server in the block is in that state, clients get 502 and the error log says no live upstreams while connecting to upstream.
keepalive 32 holds up to 32 idle connections to the backends open per worker process, which removes a TCP handshake from most requests. It works only with proxy_http_version 1.1 and with no Connection: close going upstream. If the same location also uses the WebSocket map, change the empty case from close to an empty string, so ordinary requests carry no Connection header and the pooled connection is reused.
map $http_upgrade $connection_upgrade {
default upgrade;
'' '';
}Names inside an upstream block are resolved when nginx starts. If your backend is a container that receives a new address when it restarts, nginx keeps using the old address until you reload it. Inside a Docker network you can move the lookup to request time with the embedded resolver.
resolver 127.0.0.11 valid=10s;
set $backend http://app:3000;
proxy_pass $backend;Once containers appear and disappear often enough that you are editing nginx to keep up, a proxy that reads container labels is the better tool. Traefik in front of several Docker Compose apps builds its routes from the containers themselves.
Why do uploads fail with 413 Request Entity Too Large?
client_max_body_size defaults to 1 megabyte. A larger request body is refused by nginx before your app sees any of it, and the error log records client intended to send too large body. Raise it in the server block, or in the location where the uploads happen.
client_max_body_size 512m;A value of 0 turns the check off completely. The app has its own limit as well, so a 413 that survives this change is coming from the backend, and the app's own upload setting is the next place to look.
By default nginx reads the whole request body before it opens the upstream connection, writing anything large to a temporary file on disk first. That shields the app from slow clients, because the backend receives the upload at full local speed. For very large uploads you can stream instead.
proxy_request_buffering off;The backend then receives the body as it arrives and has to be able to handle that. nginx also loses the ability to retry the request against another upstream, because the body is already gone.
client_body_timeout, 60 seconds by default, applies between two successive reads of the body rather than to the whole upload. A slow but steady upload survives it. A stalled one is dropped.
Response buffering, and the setting that breaks live output
proxy_buffering is on by default and it is usually what you want. nginx reads the response from your app as fast as the app can write it, holds it, and feeds a slow client at that client's own pace. The app worker finishes early instead of staying busy for the whole slow download.
It breaks streaming responses. Server-sent events and live log output show the reader nothing until a buffer fills. Turn buffering off in that location only.
proxy_buffering off;If you control the app, the better move is to send the header X-Accel-Buffering: no on the streaming responses alone. nginx reads that header per response and disables buffering just for it, so ordinary pages keep the benefit.
When the error log says upstream sent too big header while reading response header from upstream, the response headers did not fit in one buffer. proxy_buffer_size defaults to a single memory page, 4 or 8 kilobytes depending on the platform, and long cookies or large authentication headers overflow it. Raise both values.
proxy_buffer_size 16k;
proxy_buffers 8 16k;Where does TLS belong in this config?
At nginx, in front of everything above. TLS (transport layer security) terminates at the proxy, and the connection from nginx to the app stays plain HTTP over the loopback address, where nothing else on the network can read it. The app learns that the visitor used HTTPS from X-Forwarded-Proto, the fourth of the four headers.
Do not hand-write certificate paths. Point the DNS record at the server, open the firewall, and let Certbot edit this same server block: it adds the listen 443 ssl line with the ssl_certificate paths, plus a redirect from port 80. Issuing a Let's Encrypt certificate for nginx with Certbot covers the issuance and the renewal timer.
sudo ufw allow 'Nginx Full'
sudo ufw statusNginx Full is an application profile the nginx package installs, and it opens port 80 and port 443 together. Port 80 has to stay open for the HTTP-01 renewal challenge, even after every visitor is redirected to HTTPS.
Test the config, then reload
sudo nginx -t
sudo systemctl reload nginxnginx -t parses every included file and either reports the test as successful or prints the file and the line where it stopped. Read that output before you reload. A reload with a broken config does not apply: nginx carries on serving the previous config, so the site stays up while your change silently does nothing. systemctl restart behaves differently and worse, because a restart tears the running server down first, so a config error leaves you with nginx not running at all. Reload by default, and keep restart for the rare change that requires it.
sudo tail -f /var/log/nginx/error.log
sudo ss -lntp | grep -E ':(80|443|3000)'The ss line shows which process holds each port, so you can confirm the app really is listening where proxy_pass points.
The failures you will actually meet
502 Bad Gateway, with connect() failed (111: Connection refused) while connecting to upstream in the error log. Nothing is listening at the address in proxy_pass. The app is stopped, or bound to another port, or bound to a container-internal address the host cannot reach.
502 with no live upstreams while connecting to upstream. Every server in the upstream block is currently marked as failed by max_fails. Repair the backends. nginx retries them once fail_timeout expires.
504 Gateway Time-out, with upstream timed out (110: Connection timed out) while reading response header from upstream. The backend accepted the connection and then sent nothing for proxy_read_timeout seconds. Raising the timeout is correct for a genuinely slow report and wrong for an app that is stuck.
Every path returns 404 from the app. The trailing slash rule rewrote the path. Compare the path the app logs with the path you requested.
A different site answers. server_name does not match the Host header, so the request fell through to the default_server block.
The page loads, then the interface freezes after about a minute. That is the WebSocket case: the Upgrade handling is missing, or proxy_read_timeout is still 60 seconds.
FAQ
Why does nginx return 502 Bad Gateway after I add proxy_pass?
nginx could not open a connection to the address in proxy_pass. The error log at /var/log/nginx/error.log names the cause: connect() failed (111: Connection refused) while connecting to upstream means nothing is listening there, and no live upstreams means every server in an upstream block has been marked failed. Run sudo ss -lntp | grep 3000 to see which process holds the port and which address it is bound to. An app bound to a container-internal address, or to a port other than the one you wrote, gives this error every time.
Why does my app disconnect after about a minute behind nginx?
The connection is a WebSocket and proxy_read_timeout is still at its default of 60 seconds, which measures the gap between two reads from the backend. A quiet socket is closed by nginx and the browser console reports close code 1006. Set proxy_http_version 1.1, pass Upgrade and Connection through with a map on $http_upgrade, and raise proxy_read_timeout to something like 3600s. Without the Upgrade header the upgrade never happens at all, so the app falls back to polling or shows no live updates.
Does the trailing slash in proxy_pass matter?
Yes, and it changes the path your backend receives. With location /app/ and proxy_pass http://127.0.0.1:3000/, a request for /app/status arrives at the backend as /status, because any URI after the host and port replaces the matched location prefix. Drop that final slash and the same request arrives as /app/status. Stripping the prefix often breaks the app's own asset links, which stay absolute and then 404 at the site root, so an app with a base-path setting is better served by the form that passes the path through.
Why does my application log 127.0.0.1 as every visitor's IP address?
Because the connection the app receives really does come from nginx on the loopback address. The visitor's address only reaches the app in a header you set: proxy_set_header X-Real-IP $remote_addr; for a single value, and proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; for the appended chain. The app then has to be configured to trust those headers. Remember that a client can send its own X-Forwarded-For, so when nginx is the edge server, overwrite it with $remote_addr instead of appending.
Do I need TLS on the connection between nginx and my app?
Not when the app runs on the same server and is bound to 127.0.0.1, because that traffic never leaves the machine. Terminate TLS at nginx, keep proxy_pass on plain HTTP over loopback, and send X-Forwarded-Proto $scheme so the app knows the visitor used HTTPS. If the backend sits on a different host across a network you do not control, that hop needs its own protection, either HTTPS to the backend or a private tunnel between the two machines.