Nginx
Configure Nginx as a reverse proxy, TLS terminator and static file server, understand which server and location handle a request, and read the logs when it returns 502.
On this page
Cheatsheet#
| Task | Command |
|---|---|
| Check syntax before touching the running process | nginx -t |
| Dump the full effective configuration, includes resolved | nginx -T |
| Reload without dropping connections | nginx -s reload or systemctl reload nginx |
| Version and compiled-in modules | nginx -V |
| Which config file is in use | nginx -V 2>&1 | grep -o -- '--conf-path=[^ ]*' |
| Follow the error log | tail -f /var/log/nginx/error.log |
| Find the server that handles a name | nginx -T | grep -n 'server_name' |
| Is it listening where I think | ss -ltnp | grep nginx |
| Test a name without DNS | curl -sv --resolve example.com:443:127.0.0.1 https://example.com/ |
| Test the upstream directly | curl -sv http://127.0.0.1:8080/ |
| Requests per status code today | awk '{print $9}' /var/log/nginx/access.log | sort | uniq -c | sort -rn |
Slowest requests (with rt=$request_time logged) | grep -oE 'rt=[0-9.]+ .*' access.log | sort -t= -k2 -rn | head |
| Live connection counts | curl -s http://127.0.0.1/nginx_status (needs stub_status) |
| Worker processes and their state | ps -o pid,user,stat,cmd -C nginx |
| Open files of a worker | ls /proc/$(pgrep -f 'nginx: worker' | head -1)/fd | wc -l |
| Generate a basic-auth file | htpasswd -B -c /etc/nginx/.htpasswd alice |
| Increase the upload limit | client_max_body_size 100m; |
| Increase the upstream timeout | proxy_read_timeout 300s; |
| Debug one client only | debug_connection 192.0.2.10; inside events {} |
| Redirect everything to HTTPS | return 301 https://$host$request_uri; |
Directives below are for Nginx 1.28 (stable) and 1.29 (mainline); http2 on needs 1.25.1 and HTTP/3 needs 1.25.0 built with a QUIC-capable TLS library. Reference: the Nginx directive index.
How a request is handled#
A master process reads the configuration and forks workers. Workers accept connections, pick the server block that matches the listen socket and Host, pick the location that matches the URI, then run the phases for that location: rewrite, access (auth, allow/deny, limit_req), content (proxy_pass, try_files, root), log. Configuration is a tree of contexts (main, events, http, server, location) and most directives are inherited downward unless a child sets the same directive, in which case the child’s value replaces the parent’s completely.
/etc/nginx/nginx.conf # main, events, http; includes the rest
/etc/nginx/conf.d/*.conf # one server block per file (Debian also uses sites-available and sites-enabled)
/etc/nginx/snippets/*.conf # reusable fragments pulled in with include
/etc/nginx/mime.types
/var/log/nginx/{access,error}.log
/var/cache/nginx/ # proxy_cache_path and temp filesuser nginx;
worker_processes auto; # one per CPU
error_log /var/log/nginx/error.log warn;
pid /run/nginx.pid;
events {
worker_connections 4096; # per worker; each proxied request uses two
}
http {
include mime.types;
default_type application/octet-stream;
sendfile on;
tcp_nopush on;
keepalive_timeout 65;
server_tokens off; # drop the version from headers and error pages
include /etc/nginx/conf.d/*.conf;
}nginx -t parses the whole tree and opens the files it references, so it also catches unreadable certificates and missing log directories. nginx -s reload sends SIGHUP to the master, which tests the configuration, starts new workers with it and asks the old workers to exit once their connections finish. A reload that fails validation leaves the old workers running and writes the error to the error log; the exit status of systemctl reload nginx reflects it.
Server selection#
Nginx first narrows to the server blocks whose listen matches the address and port the connection arrived on. Among those it compares Host (or the TLS SNI name, then Host) against server_name in this order:
- Exact name (
example.com). - Longest wildcard starting with
*(*.example.com). - Longest wildcard ending with
*(api.*). - Regular expressions (
~^(?<tenant>.+)\.example\.com$), in the order they appear; first match wins. - The
default_serverfor that listen socket, or the first server block defined for it when none is marked.
server { # catch-all: refuse unknown names instead of serving the first vhost
listen 80 default_server;
listen 443 ssl default_server;
ssl_reject_handshake on; # 1.19.4+: abort TLS for names you do not serve
return 444; # close the connection without a response
}
server {
listen 443 ssl;
http2 on;
server_name example.com www.example.com;
...
}A missing default_server is the usual reason an IP-address scan hits your first site with the wrong certificate. listen 443 ssl and listen 443 cannot coexist for the same address without a ssl mismatch error; put HTTP and HTTPS in separate server blocks.
Location matching#
| Modifier | Meaning | Precedence |
|---|---|---|
= /path | Exact match | Wins immediately |
^~ /path/ | Prefix match that stops regex evaluation | Taken if it is the longest prefix |
~ regex | Case-sensitive regex | First matching regex in file order |
~* regex | Case-insensitive regex | Same as ~ |
/path/ | Prefix match | Longest prefix, used only if no regex matches |
The algorithm: check exact matches, then find the longest prefix match and remember it; if that prefix has ^~, use it; otherwise test every regex location in order and use the first that matches; if none matches, use the remembered prefix. A regex therefore beats a longer plain prefix, which surprises people who expect location /images/ to win over location ~ \.png$.
location = / { ... } # only the root URI
location / { ... } # everything not matched more specifically
location ^~ /static/ { ... } # static files, and no regex gets a look-in
location ~* \.(png|jpg|css|js)$ { ... } # any other path with these extensions
location /api/ { ... } # would lose to the regex above for /api/logo.pngnginx -T prints the effective order, and add_header X-Location "$uri -> static" always; inside a candidate block is the fastest way to confirm which one fired. Nested locations are allowed inside prefix locations, and named locations (@fallback) are reachable only from try_files and error_page.
Reverse proxy#
proxy_pass needs the upstream address, the headers the application relies on and, for anything long-lived, the WebSocket upgrade handling. Nginx speaks HTTP/1.0 to upstreams unless proxy_http_version 1.1 is set, and it does not forward hop-by-hop headers such as Upgrade.
map $http_upgrade $connection_upgrade { # in the http context
default upgrade;
'' close;
}
upstream my-app {
server 127.0.0.1:8080;
keepalive 32; # idle connections to keep per worker
}
server {
listen 443 ssl;
http2 on;
server_name app.example.com;
location / {
proxy_pass http://my-app;
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; # appends to an existing header
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header Upgrade $http_upgrade; # WebSocket
proxy_set_header Connection $connection_upgrade; # "upgrade" for WS, "" (keepalive) otherwise
proxy_read_timeout 300s; # idle WebSocket connections are closed after this
proxy_send_timeout 300s;
proxy_connect_timeout 5s;
}
location /events/ { # server-sent events or streaming responses
proxy_pass http://my-app;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_buffering off; # deliver each chunk as it arrives
proxy_read_timeout 1h;
}
}Setting proxy_set_header in a location discards every proxy_set_header inherited from the server block, so keep the full set together in a snippet and include it. The map with '' mapped to close matters: it keeps Connection: upgrade for WebSocket and an empty header (keepalive) for normal requests, which is what keepalive in the upstream needs.
The URI handed to the upstream depends on whether proxy_pass carries a path:
location /api/ { proxy_pass http://my-app; } # /api/users -> /api/users
location /api/ { proxy_pass http://my-app/; } # /api/users -> /users (the matched prefix is replaced)
location /api/ { proxy_pass http://my-app/v2/; } # /api/users -> /v2/users
location ~ ^/api/ { proxy_pass http://my-app/; } # error: a URI part is not allowed with a regex locationUpstream names are resolved once at startup unless a resolver is set and the name is held in a variable. In containers, where the backend IP changes on restart, use the variable form:
resolver 127.0.0.11 valid=30s ipv6=off; # Docker's embedded DNS; use the host resolver elsewhere
set $backend http://my-app:8080;
proxy_pass $backend$request_uri; # with a variable, Nginx no longer rewrites the URI for youTLS, HTTP/2 and HTTP/3#
server {
listen 443 ssl;
listen [::]:443 ssl;
listen 443 quic reuseport; # HTTP/3; reuseport is required with several workers
listen [::]:443 quic reuseport;
http2 on;
http3 on;
server_name example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem; # leaf plus intermediates
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305;
ssl_prefer_server_ciphers off; # let modern clients choose; the list above has no weak entries
ssl_ecdh_curve X25519:prime256v1:secp384r1;
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:10m; # about 40k sessions
ssl_session_tickets off;
add_header Alt-Svc 'h3=":443"; ma=86400'; # tell browsers HTTP/3 is available
add_header Strict-Transport-Security "max-age=63072000" always;
location / { proxy_pass http://my-app; ... }
}
server {
listen 80;
listen [::]:80;
server_name example.com;
location /.well-known/acme-challenge/ { root /var/www/acme; } # keep HTTP-01 working
location / { return 301 https://$host$request_uri; }
}ssl_ciphers only affects TLS 1.2; TLS 1.3 cipher suites are fixed by the library. reuseport on the QUIC listener is mandatory when worker_processes is more than one, and UDP 443 must be open in the firewall. Check nginx -V for --with-http_v3_module before enabling http3. OCSP stapling (ssl_stapling on) is harmless but Let’s Encrypt stopped serving OCSP responses in 2025, so it adds nothing for those certificates. Use openssl s_client -connect example.com:443 -servername example.com and the TLS page to verify the chain the server presents.
Static files and caching#
server {
listen 443 ssl;
http2 on;
server_name www.example.com;
root /srv/www/example.com; # request /docs/a.html -> /srv/www/example.com/docs/a.html
index index.html;
location / {
try_files $uri $uri/ /index.html; # single-page app: unknown paths get the shell
}
location /downloads/ {
alias /srv/files/; # request /downloads/a.zip -> /srv/files/a.zip; trailing slashes must match
}
location ~* \.(css|js|woff2|png|svg)$ {
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
}
gzip on;
gzip_types text/plain text/css application/json application/javascript image/svg+xml;
gzip_min_length 1024;
}root appends the full URI to the path; alias replaces the matched prefix. Using alias in a regex location requires captures in the target, and alias with try_files has long-standing bugs; prefer root wherever the directory layout allows it.
A proxy cache stores upstream responses on disk keyed by URI:
proxy_cache_path /var/cache/nginx/app levels=1:2 keys_zone=app:10m max_size=2g inactive=60m use_temp_path=off;
location /api/catalogue/ {
proxy_pass http://my-app;
proxy_cache app;
proxy_cache_key $scheme$host$request_uri;
proxy_cache_valid 200 301 10m; # only when the upstream sends no Cache-Control
proxy_cache_valid 404 1m;
proxy_cache_use_stale error timeout updating http_502 http_503; # serve stale while the backend is down
proxy_cache_lock on; # one request fills the cache; others wait
proxy_cache_bypass $http_cache_control;
add_header X-Cache-Status $upstream_cache_status; # HIT, MISS, EXPIRED, STALE, BYPASS
}Responses with Set-Cookie, Cache-Control: private or no-store are not cached. There is no purge in open-source Nginx; delete files under the cache path or change proxy_cache_key.
Rate limiting, access control and basic auth#
limit_req_zone $binary_remote_addr zone=per_ip:10m rate=10r/s; # 10m holds about 160k addresses
limit_conn_zone $binary_remote_addr zone=conn_per_ip:10m;
server {
location /api/ {
limit_req zone=per_ip burst=20 nodelay; # allow bursts of 20, reject beyond that immediately
limit_req_status 429; # default is 503
limit_conn conn_per_ip 20;
proxy_pass http://my-app;
}
location /admin/ {
allow 192.0.2.0/24;
deny all; # evaluated in order; first match wins
auth_basic "Restricted";
auth_basic_user_file /etc/nginx/.htpasswd; # htpasswd -B for bcrypt; readable by the worker user
proxy_pass http://my-app;
}
location /internal/ { internal; } # only reachable via rewrite, error_page or X-Accel-Redirect
}Without nodelay, requests within the burst are queued and released at rate, which looks like latency rather than errors. Behind a load balancer, key on the client address from X-Forwarded-For instead, using set_real_ip_from and real_ip_header from the realip module; otherwise every client shares the balancer’s address. satisfy any; lets either the address allow-list or the password pass a request.
Redirects and rewrites#
return 301 https://$host$request_uri; # cheapest redirect; no regex engine
return 302 /maintenance.html;
rewrite ^/old/(.*)$ /new/$1 permanent; # regex redirect (301); "redirect" gives 302
rewrite ^/blog/(\d+)$ /posts?id=$1 break; # internal rewrite; stop processing, stay in this location
rewrite ^/blog/(\d+)$ /posts?id=$1 last; # internal rewrite; re-run location matching
location = /health { return 200 'ok'; default_type text/plain; }
absolute_redirect off; # emit relative Location headers behind a proxyreturn inside a server block runs before location matching; inside if it is the one safe use of if. Redirects that Nginx generates itself (adding a trailing slash to a directory) use $host and the listen port, so behind a TLS-terminating balancer set port_in_redirect off or absolute_redirect off to avoid Location: http://example.com:8080/.
Upstreams and load balancing#
upstream my-app {
least_conn; # default is weighted round-robin; also ip_hash, hash $key [consistent], random
zone my-app 64k; # shared state across workers so failure counts are cluster-wide
server 10.0.0.11:8080 weight=2 max_fails=3 fail_timeout=30s;
server 10.0.0.12:8080 max_fails=3 fail_timeout=30s;
server 10.0.0.13:8080 backup; # used only when every primary is marked failed
server 10.0.0.14:8080 down; # kept for ip_hash stability, never used
keepalive 64;
}
location / {
proxy_pass http://my-app;
proxy_next_upstream error timeout http_502 http_503; # retry another server on these; not for POST unless idempotent
proxy_next_upstream_tries 2;
proxy_next_upstream_timeout 10s;
}Health checking in open-source Nginx is passive: a server that fails max_fails times within fail_timeout is skipped for fail_timeout, then tried again with a single request. health_check, slow_start, resolve on server lines and the /api module are NGINX Plus features and produce unknown directive on the open-source build. For active checks use Caddy, Traefik or the load balancer in front.
$upstream_addr, $upstream_status and $upstream_response_time in the access log show which server answered and whether a retry happened (comma-separated values mean several attempts).
Logs#
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" "$http_user_agent" '
'rt=$request_time urt=$upstream_response_time ua=$upstream_addr us=$upstream_status '
'cs=$upstream_cache_status h=$host';
log_format json escape=json '{"ts":"$time_iso8601","host":"$host","remote":"$remote_addr","method":"$request_method","uri":"$request_uri","status":$status,"bytes":$body_bytes_sent,"rt":$request_time,"urt":"$upstream_response_time","ua":"$upstream_addr","ref":"$http_referer","agent":"$http_user_agent"}';
access_log /var/log/nginx/access.log json buffer=64k flush=5s; # buffered: lines appear up to 5s late
error_log /var/log/nginx/error.log warn; # debug needs --with-debug; info is enough to see upstream retriesThe default combined format has no timing, so add $request_time (whole request, client included) and $upstream_response_time (backend only). A large gap between them is a slow client or slow network, not a slow application. Error-log lines name the client, the request, the upstream and the reason, for example upstream prematurely closed connection while reading response header from upstream or connect() failed (111: Connection refused) while connecting to upstream, which point directly at the backend. Log $http3 or $server_protocol while rolling out HTTP/3 to see who negotiates it.
journalctl -u nginx --since -1h # startup and reload failures land here, not in error.log
tail -f /var/log/nginx/error.log | grep -v 'No such file'
awk '$9 >= 500' /var/log/nginx/access.log | tail # combined format; column 9 is $statusOneliners#
# Validate and reload only if valid
nginx -t && systemctl reload nginx
# Effective configuration with includes expanded, comments stripped
nginx -T 2>/dev/null | grep -Ev '^\s*(#|$)'
# Every server_name and its listen ports
nginx -T 2>/dev/null | grep -E '^\s*(server_name|listen)' | sed 's/^\s*//'
# Which config file defines a location
grep -rn 'location /api/' /etc/nginx/
# Modules compiled in
nginx -V 2>&1 | tr ' ' '\n' | grep -- '--with'
# Confirm HTTP/2 and HTTP/3 negotiate
curl -sI --http2 https://example.com/ | head -1; curl -sI --http3 https://example.com/ | head -1
# Requests per minute over the last hour (combined format)
awk -v d="$(date -d '-1 hour' '+%d/%b/%Y:%H')" '$4 ~ d {print substr($4,2,17)}' /var/log/nginx/access.log | sort | uniq -c
# Top 20 client addresses
awk '{print $1}' /var/log/nginx/access.log | sort | uniq -c | sort -rn | head -20
# Top URIs returning 5xx
awk '$9 ~ /^5/ {print $7}' /var/log/nginx/access.log | sort | uniq -c | sort -rn | head
# Status code distribution for one path
grep '"GET /api/' /var/log/nginx/access.log | awk '{print $9}' | sort | uniq -c
# Upstream errors by reason, from the error log
grep -oE '(upstream [a-z ]+|connect\(\) failed[^,]*|no live upstreams)' /var/log/nginx/error.log | sort | uniq -c | sort -rn
# Bytes served per host today (json format with jq)
jq -r 'select(.ts | startswith("'"$(date +%F)"'")) | "\(.host) \(.bytes)"' /var/log/nginx/access.log | awk '{s[$1]+=$2} END {for (h in s) print s[h], h}' | sort -rn
# Live worker connections (stub_status on 127.0.0.1)
curl -s http://127.0.0.1/nginx_status
# Established connections per upstream
ss -tn state established '( dport = :8080 )' | tail -n +2 | wc -l
# Certificate expiry of every ssl_certificate in the config
nginx -T 2>/dev/null | awk '$1=="ssl_certificate" {gsub(";","",$2); print $2}' | sort -u | xargs -I{} sh -c 'printf "%s " {}; openssl x509 -enddate -noout -in {}'
# Hash a password for auth_basic without the apache2-utils package
openssl passwd -apr1 >> /etc/nginx/.htpasswd # prompts; prepend "alice:" to the line afterwards
# Rotate logs without a restart (reopen after moving the files)
mv /var/log/nginx/access.log /var/log/nginx/access.log.1 && nginx -s reopen
# Trace what a worker is doing right now
strace -p "$(pgrep -f 'nginx: worker' | head -1)" -f -e trace=network -s 80
# Purge one URL from the proxy cache: the file name is the MD5 of proxy_cache_key ($scheme$host$request_uri above)
find /var/cache/nginx/app -type f -name "$(printf 'httpsexample.com/api/catalogue/' | md5sum | cut -c1-32)" -deleteScripts#
Reload only when the certificate on disk is newer than the one the running server presents, for use after a renewal hook.
#!/usr/bin/env bash
set -euo pipefail
host=${1:?hostname}
cert=/etc/letsencrypt/live/$host/fullchain.pem
served=$(openssl s_client -connect 127.0.0.1:443 -servername "$host" </dev/null 2>/dev/null \
| openssl x509 -noout -fingerprint -sha256)
disk=$(openssl x509 -noout -fingerprint -sha256 -in "$cert")
if [[ "$served" == "$disk" ]]; then
printf '%s: certificate already live\n' "$host"
exit 0
fi
nginx -t
systemctl reload nginx
printf '%s: reloaded\n' "$host"Summarise the last N lines of a JSON access log by status class and p95 request time per URI prefix.
#!/usr/bin/env python3
import json, sys, collections, statistics
n = int(sys.argv[1]) if len(sys.argv) > 1 else 10000
lines = collections.deque(open("/var/log/nginx/access.log"), maxlen=n)
by_prefix = collections.defaultdict(list)
status = collections.Counter()
for line in lines:
try:
r = json.loads(line)
except ValueError:
continue
prefix = "/" + r["uri"].split("/")[1].split("?")[0]
by_prefix[prefix].append(float(r["rt"]))
status[f"{prefix} {str(r['status'])[0]}xx"] += 1
for prefix, times in sorted(by_prefix.items(), key=lambda kv: -len(kv[1])):
times.sort()
p95 = times[int(len(times) * 0.95) - 1] if len(times) > 1 else times[0]
errs = status.get(f"{prefix} 5xx", 0)
print(f"{prefix:30} n={len(times):6} p50={statistics.median(times):.3f}s p95={p95:.3f}s 5xx={errs}")Check every upstream server in the configuration with a direct HTTP request, for a cron job or a pre-deploy gate.
#!/usr/bin/env bash
set -euo pipefail
path=${1:-/healthz}
rc=0
while read -r addr; do
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 3 "http://$addr$path" || echo 000)
if [[ $code == 2* ]]; then
printf 'ok %s %s\n' "$addr" "$code"
else
printf 'FAIL %s %s\n' "$addr" "$code" >&2
rc=1
fi
done < <(nginx -T 2>/dev/null | awk '/^upstream/,/}/ {if ($1=="server") {gsub(";","",$2); print $2}}' | grep -v ':unix' | sort -u)
exit "$rc"Troubleshooting#
| Symptom | Cause | Fix |
|---|---|---|
502 Bad Gateway, error log says connect() failed (111) | Nothing listening at the upstream address, or wrong port | curl -v http://127.0.0.1:8080/ from the Nginx host; check the app’s bind address is not 127.0.0.1 inside a container |
502, error log says (13: Permission denied) while connecting to upstream | SELinux blocks the worker from making network connections | setsebool -P httpd_can_network_connect 1; for a Unix socket, fix its file mode and the user directive |
502, upstream sent too big header | Application sets large cookies or headers | proxy_buffer_size 16k; proxy_buffers 4 32k; in the location |
502, upstream prematurely closed connection | Backend crashed or closed mid-response, often a worker timeout in the app | Read the application log; raise its request timeout |
504 Gateway Time-out | Backend took longer than proxy_read_timeout (60s default) | Raise proxy_read_timeout for that location; fix the slow endpoint |
413 Request Entity Too Large | Body exceeds client_max_body_size (1m default) | client_max_body_size 100m; in http, server or location; the value must be in the block that matches |
| Redirect loop between HTTP and HTTPS | return 301 https:// in a server that also receives plain HTTP from a TLS-terminating balancer, or the app redirects based on a missing X-Forwarded-Proto | Redirect on $http_x_forwarded_proto != https instead; pass X-Forwarded-Proto and configure the app to trust it |
| Wrong location handles the request | A regex location matched before the intended prefix | nginx -T, add ^~ to the prefix or = for an exact URI, or move the regex |
| Wrong server handles the name | No default_server, or Host not in any server_name | Add a catch-all default_server that returns 444; check server_name spelling |
nginx -t passes but reload does nothing | Reload sent to the wrong master, or a second Nginx instance | ps -o pid,cmd -C nginx; compare --conf-path from nginx -V with the file edited |
bind() to 0.0.0.0:80 failed (98: Address already in use) | Another process (or old Nginx) holds the port | ss -ltnp 'sport = :80' |
bind() ... (13: Permission denied) on a high port | SELinux http_port_t does not include the port | semanage port -a -t http_port_t -p tcp 8443 |
Headers set in server vanish in a location | add_header or proxy_set_header in the child replaces the parent’s whole set | Repeat the headers, or include a snippet in each location |
| Static file returns 403 | Worker user cannot traverse the directory, or SELinux label is not httpd_sys_content_t | sudo -u nginx stat /srv/www/...; restorecon -Rv /srv/www |
Too many open files in the error log | worker_connections exceeds the file descriptor limit | worker_rlimit_nofile 65536; in main |
| WebSocket closes after 60 seconds | proxy_read_timeout on an idle socket | Raise it on that location or send pings from the application |
Client sees Location: http://host:8080/ | Nginx built an absolute redirect from its own listen port | absolute_redirect off; or port_in_redirect off; |