Reverse proxy

Why do nginx redirects come out with the wrong port or http:// behind a proxy or port mapping?

nginx builds redirects from its own listen port and scheme, not the client's. Tested Location headers for absolute_redirect, port_in_redirect, proxy_redirect.

· 14 min read · How we verify this

Key points

  • When nginx itself issues a redirect (the trailing slash on a directory or a proxy_pass location, return 301 /path, rewrite ... permanent), it builds the absolute URL from the scheme of its own connection, the Host name with the port removed, and the port it is listening on. The port the client typed is never used, so behind Docker -p 8443:8080 or a TLS proxy the client gets http://host:8080/dir/.
  • absolute_redirect off; (nginx 1.11.8 and later) is the fix for every nginx-made redirect: the Location becomes /dir/, and the client resolves it against the URL it actually used, scheme and port included. port_in_redirect off only deletes the port and leaves http://, and server_name_in_redirect on only changes the name.
  • proxy_redirect output goes through the same machinery. proxy_redirect default and proxy_redirect / / turn the backend's redirect into a path, which nginx then makes absolute with its listen port: tested, a harmless relative Location: /target became http://example.com:8080/target. With absolute_redirect off the same line produced /target.
  • A backend that builds absolute URLs needs X-Forwarded-Proto plus the client's port, and X-Forwarded-Port $server_port sends the wrong one: $server_port is nginx's listen port. In Werkzeug 3.1.9 an X-Forwarded-Port also overrode a correct port inside X-Forwarded-Host.

nginx puts the wrong port or scheme in a redirect because, when it turns a path into an absolute URL, it takes the port from the socket it accepted the connection on and the scheme from whether that connection was TLS. Behind a port mapping or a TLS-terminating proxy, both describe nginx's side of the hop, not the client's. The port the client typed, :8443 in Host: example.com:8443, is discarded, and only the name is kept. So a client on https://example.com:8443/dir is sent to http://example.com:8080/dir/. Set absolute_redirect off; and nginx sends Location: /dir/ instead, which the client resolves against the URL it really used.

Redirects the backend makes are a separate problem with separate fixes, and the usual mistake is applying one family of fixes to the other. The tables below put every case side by side, tested on nginx 1.27.5 and 1.30.6 (identical results), with a Python backend that emits each kind of Location and with Flask 3.1.3 on Werkzeug 3.1.9.

Who made the redirect: the first thing to find out#

Check which component wrote the Location header before changing anything. nginx writes it in these cases:

  • The trailing slash on a directory. GET /dir where /dir is a directory under root returns 301 to /dir/.
  • The trailing slash on a proxy_pass location. The core module documentation says that if a prefix location ends in a slash and its requests are handled by proxy_pass (or fastcgi_pass, uwsgi_pass, scgi_pass, memcached_pass, grpc_pass), then "in response to a request with URI equal to this string, but without the trailing slash, a permanent redirect with the code 301 will be returned to the requested URI with the slash appended." The backend never sees that request. The full set of proxy_pass path rules is in nginx proxy_pass and the trailing slash.
  • return with a local URI, such as return 301 /new;. The rewrite module: "the full redirect URL is formed according to the request scheme ($scheme) and the server_name_in_redirect and port_in_redirect directives."
  • rewrite with redirect or permanent and a replacement that does not start with a scheme. Same rule, same wording.
  • A Location from the backend that proxy_redirect rewrote into a path. This one surprises people, and it has its own section below.

Everything else, a login redirect, a redirect after a form POST, an OAuth callback, is the backend's. A quick way to tell: request the same URL from the backend directly, bypassing nginx. If the backend answers with the redirect, it is the backend's; if it answers 200 or 404, nginx made it.

How nginx builds the URL, from the source#

The code that applies the three directives is one block in ngx_http_header_filter_module.c, and it explains every row of the tables below. It runs only when the Location value starts with / and absolute_redirect is on:

  • Host name. If server_name_in_redirect is on, the primary server_name. Otherwise r->headers_in.server, which is the Host value with the port already removed. If the request had no Host, the server's own IP address.
  • Port. ngx_inet_get_port(c->local_sockaddr): the local port of the accepted connection. If port_in_redirect is off, no port. If the port is 80 on a plain connection or 443 on a TLS one, no port either.
  • Scheme. https:// if the connection is TLS, http:// otherwise.

Nothing in that block reads X-Forwarded-Proto, X-Forwarded-Port, or the port inside Host. No directive or header can make nginx use the client's port in a redirect it builds itself. The only choices are to build no absolute URL at all (absolute_redirect off), or to write the full URL yourself in a return.

Redirects nginx makes: the Location under each directive#

Test setup: nginx listening on plain HTTP port 8080 with server_name example.com, receiving Host: example.com:8443 as it would from a TLS proxy that forwards the client's host and port. The client's real URL was https://example.com:8443/....

Redirect sourceDefaultsport_in_redirect offserver_name_in_redirect onabsolute_redirect off
GET /dir (directory, trailing slash)http://example.com:8080/dir/http://example.com/dir/http://example.com:8080/dir//dir/
GET /app (location /app/ with proxy_pass)http://example.com:8080/app/http://example.com/app/http://example.com:8080/app//app/
return 301 /new;http://example.com:8080/newhttp://example.com/newhttp://example.com:8080/new/new
rewrite ^ /new permanent;http://example.com:8080/newhttp://example.com/newhttp://example.com:8080/new/new
return 301 $scheme://$host/new;http://example.com/newsamesamesame
return 301 $scheme://$http_host/new;http://example.com:8443/newsamesamesame

Three things follow from the table.

port_in_redirect off is not a fix for a port mapping. It deletes the wrong port and puts no port in its place, so the client on :8443 is sent to port 80 over plain HTTP. It is the right setting only when the public port is the default one for the public scheme, and even then the scheme is still http:// behind TLS termination. Many of the old forum answers recommend it because the poster's public port happened to be 80.

server_name_in_redirect on only changes the name. With Host: 10.9.9.9:8443 it produced http://example.com:8082/dir/ on the test server listening on 8082, the configured name with the listen port. Useful when clients arrive by IP address and should be moved to the canonical name; no help with the port or scheme.

A URL you write yourself is never touched. The last two rows ignore all three directives, because the value does not start with /. That cuts both ways: $scheme://$host behind a TLS proxy is http:// with no port whatever you set, and $http_host carries the client's port only if every proxy in front forwarded it. The same trap appears in the error_page 497 redirect covered in the plain HTTP request was sent to HTTPS port.

The same thing through a real port mapping#

The table above uses a forged Host header to stand in for the outer hop. The same results came back through real Docker port mappings and a real TLS proxy, with nginx 1.30.6 in each container:

What the client usedWhat nginx listens onLocation for GET /dirURL the client followed
http://localhost:28080/dir-p 28080:80, listen 80http://localhost/dir/port 80: wrong
http://localhost:28081/dir-p 28081:8080, listen 8080http://localhost:8080/dir/port 8080: wrong
http://localhost:28083/dir-p 28083:8083, absolute_redirect off/dir/http://localhost:28083/dir/: right
https://example.com:28443/dir, TLS proxy, then nginx on 8080defaultshttp://example.com:8080/dir/wrong port and scheme
https://example.com:28446/dir, TLS proxy, then nginx on 8080port_in_redirect offhttp://example.com/dir/wrong port and scheme
https://example.com:28445/dir, TLS proxy, then nginx on 8080absolute_redirect off/dir/https://example.com:28445/dir/: right

The first row is the "nginx redirect drops port number" case and the second is the "nginx adds :8080 to my redirect" case. They are the same bug: nginx describing its own socket. Whether the port appears or vanishes depends only on whether nginx happens to listen on 80. How container ports and localhost interact in general is covered in nginx in Docker: proxy_pass to localhost.

Why a relative Location is safe#

Older advice treats a relative Location as non-compliant, because RFC 2616 required an absolute URI. RFC 9110, the current HTTP semantics, defines the field as Location = URI-reference and says: "When it has the form of a relative reference ([URI], Section 4.2), the final value is computed by resolving it against the target URI." The target URI is the one the client requested, so the client's own scheme, host and port fill in the rest. That is exactly the information nginx lacks.

In the tests, curl 7.68.0 (-L), Python 3.13 urllib and Node 24 fetch all followed Location: /dir/ from https://127.0.0.1:28448/dir to https://127.0.0.1:28448/dir/ and got a 200. Werkzeug made the same decision for its own redirects: autocorrect_location_header is documented as "disabled by default, so responses will send relative redirects" since version 2.1.

The fix for nginx-made redirects, then, is one line, valid at http, server or location level:

nginx
http {
    absolute_redirect off;
    ...
}

Redirects the backend makes#

None of the three core directives touches a Location the backend sent, unless proxy_redirect first turns it into a path. Test setup as before (Host: example.com:8443 arriving at nginx on 8080), backend at http://backend:5000:

Backend sentnginx configurationClient received
http://backend:5000/targetlocation /bd/ { proxy_pass http://backend:5000/; } (proxy_redirect default)http://example.com:8080/bd/target
samesame, plus absolute_redirect off/bd/target
http://<Host it received>/targetproxy_set_header Host $host;http://example.com/target (port gone, http)
sameproxy_set_header Host $http_host;http://example.com:8443/target (port right, still http)
/target (relative)any of the above/target, unchanged
/target (relative)proxy_redirect / /;http://example.com:8080/target
sameproxy_redirect / /; plus absolute_redirect off/target
built from X-Forwarded-Proto: https and X-Forwarded-Port: 8443proxy_set_header for bothhttps://example.com:8443/target

proxy_redirect output is a path, so nginx makes it absolute#

The proxy module documentation explains proxy_redirect default with an equivalence: for location /one/ { proxy_pass http://upstream:port/two/; } it equals proxy_redirect http://upstream:port/two/ /one/;. The replacement is the path /one/. The documentation also states what happens to a replacement without a host: "then the primary server's name and port, if different from 80, will be inserted." In practice that is the header filter block above, so the result gets nginx's listen port and connection scheme. The first row of the table is that rule in action: the backend's internal address was correctly replaced, and the result still pointed at port 8080 over HTTP.

The documentation offers proxy_redirect / /; as a way "to add host names to relative redirects issued by a proxied server". Behind a port mapping or TLS termination that turns a correct relative redirect into a wrong absolute one, as the sixth row shows. A relative Location from the backend is already the best possible answer; leave it alone.

This also changes where to put the fix in a two-proxy chain. When an outer TLS proxy rewrites the inner server's redirect with proxy_redirect http://$host:8080/ /;, the result is made absolute by the outer nginx, from the outer nginx's socket. Tested with the outer nginx listening on 28447:

Client usedOuter nginxClient received for GET /dir
https://example.com:28447/dirlisten 28447 ssl, published on the same porthttps://example.com:28447/dir/ (right)
https://example.com:29443/dirsame container, published as -p 29443:28447https://example.com:28447/dir/ (wrong port)
https://example.com:20443/dirlisten 443 ssl, published as -p 20443:443https://example.com/dir/ (port gone)
https://example.com:28448/dirlisten 28448 ssl, absolute_redirect off/dir/ (right)

proxy_redirect plus absolute_redirect off on the outer proxy was correct in every case. Without absolute_redirect off, it is correct only when the outer proxy's listen port is also the public port.

Backends that build absolute URLs: X-Forwarded-Proto and the port#

When the backend writes http:// plus whatever Host it received, nothing nginx does to Location short of a proxy_redirect for each host can repair it. The application has to know the client's scheme, host and port. Which framework setting trusts which header is covered in X-Real-IP, X-Forwarded-Proto, Host and Port; the port is the piece most configurations get wrong.

Tested with Flask 3.1.3 on Werkzeug 3.1.9, wrapped in ProxyFix(app, x_for=1, x_proto=1, x_host=1, x_port=1), behind nginx terminating TLS, the client using a port mapped to a different port inside the container (38443 outside, 8443 inside, and so on):

Headers nginx sentredirect(url_for(...))url_for(..., _external=True)
X-Forwarded-Proto $scheme only, Host $host/dashhttps://example.com/dash (port gone)
X-Forwarded-Host $host, X-Forwarded-Port $server_port/dashhttps://example.com:8444/dash (nginx's listen port)
X-Forwarded-Host $http_host, no X-Forwarded-Port/dashhttps://example.com:38445/dash (right)
X-Forwarded-Host $http_host and X-Forwarded-Port $server_port/dashhttps://example.com:8446/dash (port overridden, wrong)
X-Forwarded-Host $host, X-Forwarded-Port 38447 (published port, hard-coded)/dashhttps://example.com:38447/dash (right)

The left column is the most useful result on this page for Flask users: modern Werkzeug redirects are relative, so the ordinary redirect() after a login was right in every configuration. Only absolute URLs, the kind that go into emails, OAuth redirect_uri values and canonical links, depend on the headers.

$server_port is the port nginx is listening on, the same value nginx itself would put in a redirect, so X-Forwarded-Port $server_port is wrong whenever there is a port mapping. The fourth row is worse than it looks: the Werkzeug documentation says "X-Forwarded-Port sets HTTP_HOST and SERVER_PORT", and in the test it replaced the correct port that X-Forwarded-Host already carried. Send the port in one place. Either forward $http_host as X-Forwarded-Host and send no X-Forwarded-Port, or hard-code the published port. Note also that ProxyFix defaults to x_host=0 and x_port=0, so both headers are ignored until enabled. The RFC 7239 Forwarded header avoids the split by carrying host and port together in its host parameter; see the Forwarded header (RFC 7239).

$http_host is the client's header, unvalidated. Forward it only from a server block whose server_name list actually matched it, and configure the application to trust these headers only from the proxy's address, as described in configuring trusted proxies.

Which fix for which case#

SymptomWho redirectedFix
GET /dir or GET /app redirects to :8080, or drops :8443nginx (trailing slash)absolute_redirect off;
return 301 /x or rewrite ... permanent has the wrong port or http://nginxabsolute_redirect off;
return 301 https://$host/... drops a non-standard portyour own URLWrite the public port in the URL, or use a relative URI
Redirect to the backend's own address (http://127.0.0.1:5000/...)backend, via Host $proxy_hostproxy_redirect default (or explicit), plus absolute_redirect off
Backend redirect has the right name, wrong port and http://backend, from HostX-Forwarded-Proto, plus the client port once, and the framework's trust switch
Backend redirect was relative and nginx made it absoluteproxy_redirect / / or a matching ruleRemove the rule, or add absolute_redirect off
Redirect loses the /app prefixbackend, prefix strippedRun the app under a subpath: X-Forwarded-Prefix or a base path
Redirect loops between http and httpsbackend, ignoring X-Forwarded-ProtoFramework proxy trust, not an nginx directive

absolute_redirect off appears in almost every row because it removes nginx's guess from the chain. Set it at http level in any nginx behind another proxy or a port mapping.

Failure modes#

  • Browser on HTTPS lands on an http:// URL and then bounces back to HTTPS. An HTTP-to-HTTPS redirect or HSTS rescued it, at the cost of a round trip, and a 301 or 302 on that path turns a POST into a bodiless GET, as why a POST becomes a GET after a redirect shows. Same fix; the extra hop disappears.
  • port_in_redirect off was added and the redirect now goes to port 80. It removed the port; it did not supply the client's. Replace it with absolute_redirect off.
  • nginx: [emerg] "proxy_redirect default" cannot be used with "proxy_pass" directive with variables. The default cannot be computed when proxy_pass contains variables. Write the rule explicitly; the documentation states "the default parameter is not permitted if proxy_pass is specified using variables."

Where this stops applying#

The nginx results are open source nginx 1.27.5 and 1.30.6 from the official Alpine images, with the HTTP proxy module; absolute_redirect exists from 1.11.8, and older versions can only use port_in_redirect and server_name_in_redirect. fastcgi_pass and uwsgi_pass share the trailing-slash rule and the header filter, but only proxy_pass was tested here. The Refresh header is rewritten by proxy_redirect too and was not tested. The backend results are a test server that writes Location by hand and Flask 3.1.3 on Werkzeug 3.1.9; Django, Spring and ASP.NET Core each read forwarded ports with their own rules. Clients tested on relative redirects were curl 7.68.0, Python 3.13 urllib and Node 24 fetch; browsers were not part of this run.

Frequently asked questions#

Why does nginx add :8080 to my redirect URL?#

Because nginx is listening on port 8080 and builds absolute redirects from its own listening port, not from the port the client used. Behind Docker -p 80:8080 or a load balancer, that is the internal port. absolute_redirect off; makes nginx send a relative Location that the client resolves against its own URL.

Why does the nginx trailing slash redirect drop the port number?#

Because nginx is listening on port 80 (or 443 with TLS) and leaves the default port out, while the client reached it through a different public port. nginx never reads the port from the Host header. absolute_redirect off; fixes it, since the relative /dir/ keeps the client's port.

What is the difference between absolute_redirect and port_in_redirect?#

absolute_redirect off stops nginx from building an absolute URL at all, so the redirect is a path and the client keeps its own scheme, host and port. port_in_redirect off still builds an absolute URL, with nginx's scheme, and only leaves the port out, which is right only when the public port is the scheme's default.

Why does nginx redirect to http instead of https behind a load balancer?#

Because the load balancer terminates TLS and connects to nginx over plain HTTP, and nginx takes the redirect scheme from its own connection. It does not read X-Forwarded-Proto for its own redirects. Use absolute_redirect off;, or write https:// explicitly in any return URL.

Does proxy_redirect fix the port in my application's redirects?#

It replaces text in the backend's Location, but when the replacement is a path, nginx then turns it into an absolute URL with its own listen port and scheme. Combine proxy_redirect with absolute_redirect off so the rewritten value stays relative.

Should I send X-Forwarded-Port $server_port to my app?#

No, not when there is a port mapping. $server_port is the port nginx listens on, the same wrong value nginx would put in its own redirect. Send the client's port inside X-Forwarded-Host $http_host, or hard-code the published port, and do not send both: in Werkzeug the port header overrides the one in the host.

Primary sources#

Every normative claim on this page is checked against the specification or the vendor documentation listed here. Where behaviour is version dependent, the version is named in the text.

  1. nginx ngx_http_core_module (absolute_redirect, port_in_redirect, server_name_in_redirect, location)
  2. nginx ngx_http_proxy_module (proxy_redirect)
  3. nginx ngx_http_rewrite_module (return, rewrite)
  4. nginx source, ngx_http_header_filter_module.c
  5. RFC 9110: HTTP Semantics, section 10.2.2 Location
  6. Werkzeug documentation, X-Forwarded-For Proxy Fix
  7. Werkzeug documentation, Response.autocorrect_location_header

Found something wrong, or behaviour that differs on your version? Report it with the version number and a primary source. Anything substantive is fixed in the page and logged on the corrections page. See editorial standards for how pages are researched, sourced and reviewed.

More in reverse proxy configuration#