nginx Reverse Proxy Config: How E Dey Work
Build nginx reverse proxy server block line by line, with proxy_pass, four key headers, websockets, trailing slashes, uploads, and config testing.
nginx reverse proxy config dey do wetin
nginx reverse proxy dey take requests wey dey arrive for port 80 and port 443, hand each one over to application wey already dey listen for local port, then return the application answer go browser. The config na single server block, and the block short. Almost all the hard part dey for five or six lines wey tell your app who the real client be and which protocol that client use.
Everything for here dey build from zero for Ubuntu 24.04, using nginx package wey come from the distribution. The starting point na app wey already dey answer for 127.0.0.1:3000. If you never choose proxy yet, how nginx dey compare with Caddy and Traefik na the comparison to read first. Wetin follow na how the nginx answer look, line by line.
Run these configs for your own server. Test every change with sudo nginx -t before you reload, and read wetin e print.
Where nginx dey keep im config for Ubuntu
sudo apt update
sudo apt install -y nginx
ls -l /etc/nginx/sites-enabled/The main file na /etc/nginx/nginx.conf. E dey set global options inside http { } block, then e dey include two directories: /etc/nginx/conf.d/*.conf and /etc/nginx/sites-enabled/*. For Ubuntu and Debian, you write one file for each site inside /etc/nginx/sites-available/, then switch am on with symlink enter /etc/nginx/sites-enabled/. If you delete the symlink, e go disable the site but keep the file.
Two directives wey we go use later only dey work for http context; dem no dey work inside server block: map and upstream. Put dem for their own file under /etc/nginx/conf.d/, because that directory dey included for http level.
The package come with one enabled site called default. Dem mark am as default_server, meaning say e go answer any request wey Host header no match any server_name for your config. As long as e still enabled, request wey no match your names go land for there instead of your app. Remove the symlink after your own site don work.
sudo rm /etc/nginx/sites-enabled/default
sudo nginx -t
sudo systemctl reload nginxThe smallest server block wey proxy one app
server {
listen 80;
listen [::]:80;
server_name app.example.com;
location / {
proxy_pass http://127.0.0.1:3000;
}
}Save am as /etc/nginx/sites-available/app.example.com, then enable am and load am.
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; dey bind IPv4 and listen [::]:80; dey bind IPv6. If you leave the second line out and visitor DNS (domain name system) lookup return AAAA record for your server, the connection go refuse, while everybody for IPv4 go see working site. The bug report wey you go receive go talk say "e works for me".
server_name dey match against the Host header wey browser send. You fit list several names, separate dem with spaces. If no block match, nginx go use any block wey be default_server, na why the packaged site need to go.
location / na prefix match for request path, while / dey match every path. proxy_pass na the address wey nginx open connection go. Keep the app bound to 127.0.0.1 so the only way enter na through nginx. If the app dey run inside container, publish am as 127.0.0.1:3000:3000 and no be 3000:3000, because Docker dey write e own rules and publish ports straight pass ufw, so bare published port fit reach internet no matter wetin your firewall talk.
The curl line send the correct Host header from the server itself, so you fit test the block before DNS point anywhere.
Wetín nginx dey send upstream when you no write anything else
proxy_pass by itself dey hide four things from your application.
By default, nginx dey use HTTP/1.0 talk to the backend and e dey send Connection: close. So every request dey open fresh upstream connection, and no protocol upgrade fit happen.
nginx dey rewrite the Host header to the value wey dey inside proxy_pass, and that value na 127.0.0.1:3000. Any app wey dey build absolute links from Host go create links wey nobody outside the server fit open.
The connection wey dey reach the app come from nginx, so the app dey see client address as 127.0.0.1. Every log line and every rate limit inside the app go then record the proxy instead of the visitor.
The app no fit know say browser use HTTPS, because the connection wey e receive na plain HTTP for loopback address.
Four lines go fix everything.
Headers four wey you need set, and wetin each one let 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 dey carry the name wey visitor type. $host na the name from the request, after dem remove the port and change all letters to lowercase. Set am so your app fit build correct absolute URLs: the redirect after login, or the link inside password reset email. If you leave am out, those URLs go point to 127.0.0.1:3000, so when person log in, browser go go address wey dey refuse connection. If your app need the port too, because you dey serve am on 8080, use $http_host. Na the header exactly as client send am.
X-Real-IP dey carry one value: $remote_addr, the address wey nginx accept the connection from. Apps dey read am for their own access logs and rate limiting.
X-Forwarded-For dey carry list. $proxy_add_x_forwarded_for dey append $remote_addr to wetin client don already put for that header, so the value dey comma separated and the entry wey your nginx add na the last one. This detail decide whether you fit trust the header: client fit send any X-Forwarded-For wey e like, so app wey read the first entry fit receive any address at all. When nginx na the edge server, write $remote_addr instead and throw away the client's version. When CDN or another proxy dey in front, use set_real_ip_from and real_ip_header from the realip module, so $remote_addr itself go become the real client address.
X-Forwarded-Proto dey carry http or https. Frameworks dey read am to decide whether to mark cookies Secure and whether to force redirect go HTTPS. If you no include am for TLS site and app wey configured to force HTTPS sees http, e go answer with redirect to the HTTPS address, receive the next request through nginx, still see http, then redirect again. Browser go give up and show ERR_TOO_MANY_REDIRECTS.
If you repeat those four lines for every location, dem fit begin differ. Put dem inside one file and include am.
# /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 get one trap here. A location dey inherit proxy_set_header directives from its server block only when that location no define any of its own. Add one proxy_set_header inside the location and every header wey you define for server level go disappear for that location. So keep all of dem for one level, or include the snippet for every location wey dey proxy.
Why my WebSocket app dey connect, then disconnect?
Defaults no allow the upgrade, and default read timeout dey close idle tunnel after 60 seconds. WebSocket dey start as HTTP request wey carry Upgrade: websocket and Connection: Upgrade. Dem be hop-by-hop headers. This mean say proxy suppose consume dem instead of passing dem forward, and HTTP/1.0 no get upgrade mechanism at all. You must put both back by hand.
Put the map for http context, inside 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 dey there so one location fit serve both kinds of traffic. For ordinary request, $http_upgrade dey empty, so $connection_upgrade become close. For upgrade request, e hold websocket, so the header wey proxy send upstream na Connection: upgrade. If you hard-code proxy_set_header Connection "upgrade";, proxy go send that header for every plain page request too, and some backends go answer that kind request with 400.
proxy_read_timeout na wetin dey cause reports say “e load, then e stop updating”. E default to 60 seconds, and e measure the gap between two reads from backend, not how long the connection don live. WebSocket wey quiet for 60 seconds, nginx go close am, and browser console go show say socket close with code 1006. Apps wey send their own heartbeat more often than once every minute no go notice. Apps wey no send am go die exactly after one minute. Live editors and dashboards dey show this problem first, self-hosted n8n instance behind HTTPS being common example.
Why trailing slash for proxy_pass dey change my URLs?
The rule na one sentence. If proxy_pass end with URI (uniform resource identifier), even bare /, nginx go remove the part of request path wey match location prefix and put that URI for there. If proxy_pass stop for host and port, request path go pass through unchanged.
location /app/ {
proxy_pass http://127.0.0.1:3000/;
}Request for /app/status go reach backend as /status.
location /app/ {
proxy_pass http://127.0.0.1:3000;
}Request for /app/status go reach backend as /app/status.
Which form you need depend on the app. App wey get base-path or sub-folder setting need the second form, and you tell the setting about /app. App wey no know anything about prefixes need the first form. The first form get one problem wey you go see immediately: the HTML wey the app return still get absolute paths like /static/main.css, browser go ask site root for dem, no location go match, and page go render without styling. Browser network tab go show say those asset requests return 404. The fix na the app own base-path setting, or another location /static/ wey point to the same backend.
Regex location no fit carry URI inside proxy_pass. sudo nginx -t go reject the config and state 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 kind problem go disappear when every app get its own name, app.example.com, and proxy from location /. Sub-paths worth the wahala only when you no fit add DNS records.
Backend bakwa ni fit I put behind one name?
Use an upstream block. E dey inside http context, so write am before the server block for the same file, or inside /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 go name am: proxy_pass http://app_backend;.
The default method na round robin. least_conn go send each request to the backend wey get the fewest active connections, and this dey work well when request length no dey equal. ip_hash go tie one client address to one backend. You need ip_hash when the app dey keep sessions for its own memory, because round robin across two such backends fit log people out randomly when their requests reach the instance wey never see dem before. Moving sessions go shared storage na the better solution.
max_fails=3 fail_timeout=30s mean say three failed attempts within 30 seconds go remove that server for 30 seconds. When every server inside the block dey that state, clients go receive 502 and the error log go show no live upstreams while connecting to upstream.
keepalive 32 keep up to 32 idle connections to the backends open for each worker process. This remove one TCP handshake from most requests. E work only with proxy_http_version 1.1 and when no Connection: close dey go upstream. If the same location dey use the WebSocket map too, change the empty case from close to an empty string, so ordinary requests no carry Connection header and the pooled connection fit reuse.
map $http_upgrade $connection_upgrade {
default upgrade;
'' '';
}Names inside an upstream block dey resolve when nginx starts. If your backend na container wey dey receive new address whenever e restart, nginx go continue use the old address until you reload am. Inside a Docker network, you fit move the lookup go request time with the embedded resolver.
resolver 127.0.0.11 valid=10s;
set $backend http://app:3000;
proxy_pass $backend;Once containers dey appear and disappear often enough and you dey edit nginx to keep up, proxy wey dey read container labels na the better tool. Traefik before several Docker Compose apps go build its routes from the containers themselves.
Why uploads dey fail with 413 Request Entity Too Large?
client_max_body_size default na 1 megabyte. nginx go refuse request body wey pass this size before your app see any part of am, and error log go record client intended to send too large body. Increase the value for the server block, or for the location wey uploads dey happen.
client_max_body_size 512m;Value of 0 go disable the check completely. The app get im own limit too. So if 413 still happen after this change, na backend dey return am. Check the app own upload setting next.
By default, nginx dey read the complete request body before e open upstream connection. E first write any large body to temporary file for disk. This one protect the app from slow clients because backend go receive the upload at full local speed. For very large uploads, you fit stream the body instead.
proxy_request_buffering off;Backend go then receive the body as e dey arrive, and e must fit handle am. nginx no go fit retry the request with another upstream again because the body don already pass.
client_body_timeout, wey default na 60 seconds, dey apply between two successive reads of the body, no be for the complete upload. Slow but steady upload go survive am. If upload stop completely, nginx go drop am.
Response buffering, and the setting wey dey spoil live output
proxy_buffering dey on by default, and na usually wetin you want. nginx dey read response from your app as fast as the app fit write am, hold am, then send am to slow client according to the client own speed. App worker go finish early instead of staying busy for the whole slow download.
E dey spoil streaming responses. Server-sent events and live log output no go show anything for reader until buffer full. Off buffering for only that location.
proxy_buffering off;If na you dey control the app, better option na to send header X-Accel-Buffering: no only for the streaming responses. nginx dey read that header for each response and disable buffering only for that response, so ordinary pages still get the benefit.
When error log talk say upstream sent too big header while reading response header from upstream, response headers no fit enter one buffer. proxy_buffer_size dey default to one memory page, wey fit be 4 or 8 kilobytes depending on the platform, and long cookies or big authentication headers fit overflow am. Increase both values.
proxy_buffer_size 16k;
proxy_buffers 8 16k;Where TLS belong for this config?
For nginx, e dey in front of everything wey dey above. TLS (transport layer security) dey terminate for the proxy, and the connection from nginx go the app remain plain HTTP through the loopback address. Nothing else for the network fit read am there. The app know say visitor use HTTPS from X-Forwarded-Proto, wey be the fourth of the four headers.
No write certificate paths by hand. Point the DNS record to the server, open the firewall, and allow Certbot edit this same server block. E go add the listen 443 ssl line with the ssl_certificate paths, plus redirect from port 80. How to issue Let's Encrypt certificate for nginx with Certbot explain the issuance and renewal timer.
sudo ufw allow 'Nginx Full'
sudo ufw statusNginx Full na application profile wey nginx package install, and e open port 80 and port 443 together. Port 80 must remain open for the HTTP-01 renewal challenge, even after every visitor don redirect to HTTPS.
Test the config, then reload
sudo nginx -t
sudo systemctl reload nginxnginx -t dey parse every included file and either report say the test successful or print the file and line where e stop. Read that output before you reload. Reload with broken config no go apply: nginx go continue to serve the previous config, so the site go remain up while your change silently do nothing. systemctl restart dey behave differently and worse, because restart first tear down the running server, so config error fit leave nginx no dey run at all. Use reload by default, and keep restart for the rare change wey require am.
sudo tail -f /var/log/nginx/error.log
sudo ss -lntp | grep -E ':(80|443|3000)'The ss line dey show which process hold each port, so you fit confirm say the app really dey listen where proxy_pass point.
Wahala wey you go actually meet
502 Bad Gateway, with connect() failed (111: Connection refused) while connecting to upstream for the error log. Nothing dey listen for the address wey dey inside proxy_pass. The app don stop, or e dey bound to another port, or e dey bound to container-internal address wey the host no fit reach.
502 with no live upstreams while connecting to upstream. Every server for the upstream block dey marked as failed by max_fails now. Repair the backends. nginx go retry dem once fail_timeout expire.
504 Gateway Time-out, with upstream timed out (110: Connection timed out) while reading response header from upstream. The backend accept the connection, then e no send anything for proxy_read_timeout seconds. To increase the timeout correct if na genuinely slow report, but e no correct if app don hang.
Every path dey return 404 from the app. The trailing slash rule rewrite the path. Compare the path wey app logs with the path wey you request.
Another site dey answer. server_name no match the Host header, so the request fall through to the default_server block.
The page load, then the interface freeze after about one minute. Na the WebSocket case be this: Upgrade handling dey missing, or proxy_read_timeout still dey 60 seconds.
FAQ
Why nginx dey return 502 Bad Gateway after I add proxy_pass?
nginx no fit open connection go the address for proxy_pass. The error log for /var/log/nginx/error.log show the cause: connect() failed (111: Connection refused) while connecting to upstream mean say nothing dey listen there, while no live upstreams mean say every server for an upstream block don mark as failed. Run sudo ss -lntp | grep 3000 to see which process dey hold the port and which address e bind to. If app bind to container-internal address, or to port wey no be the one wey you write, this error go happen every time.
Why my app dey disconnect after about one minute behind nginx?
The connection na WebSocket, and proxy_read_timeout still dey use the default value of 60 seconds. This value measure the gap between two reads from the backend. nginx go close quiet socket, and browser console go report close code 1006. Set proxy_http_version 1.1, pass Upgrade and Connection through with a map on $http_upgrade, and increase proxy_read_timeout to something like 3600s. Without the Upgrade header, the upgrade no go happen at all. So the app go fall back to polling or show no live updates.
Trailing slash for proxy_pass matter?
Yes, and e dey change the path wey backend receive. With location /app/ and proxy_pass http://127.0.0.1:3000/, request for /app/status go reach backend as /status, because any URI after the host and port replace the matched location prefix. If you remove that final slash, the same request go reach backend as /app/status. Removing the prefix fit break the app own asset links. Those links dey remain absolute and then return 404 for site root. So if app get base-path setting, use the form wey pass the path through.
Why my application dey log 127.0.0.1 as every visitor IP address?
Because the connection wey app receive really come from nginx through the loopback address. Visitor address fit reach the app only through header wey you set: proxy_set_header X-Real-IP $remote_addr; for one value, and proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; for the appended chain. Then configure the app to trust those headers. Remember say client fit send im own X-Forwarded-For. So when nginx be the edge server, overwrite am with $remote_addr instead of appending.
I need TLS on the connection between nginx and my app?
No, if app dey run for the same server and e bind to 127.0.0.1, because that traffic no dey leave the machine. Terminate TLS for nginx, keep proxy_pass on plain HTTP over loopback, and send X-Forwarded-Proto $scheme so app know say visitor use HTTPS. If backend dey for another host across network wey you no control, that hop need im own protection. You fit use HTTPS to the backend or private tunnel between the two machines.