Fix · Sign-in works locally but fails in production: how to fix it

Login redirect loop behind nginx reverse proxy in Docker

Sign-in appears to succeed, then the browser bounces between the login page and the protected page until it gives up, or the server component cannot read the session cookie on staging. The usual cause is that the app inside the container sees a different scheme or host than the browser used, so its cookies or redirects do not match the public URL.

Likely causes, most common first

Cause 1 · in 1 of 2 matching cases

A rate-limit rule covers the login path

A limit_req rule on a location that also serves the login or callback path rejects excess requests with an error. The default status is 503, and the default burst size is zero. A redirect loop sends repeated requests to that path, so the loop and the rate-limit errors can appear together.

How to tell: nginx logs refusals from limit_req for the login or callback path, and the browser shows 503 responses mixed in with the redirects.

Cause 2

nginx does not pass the public scheme and host to the app

By default nginx does not forward the original Host header to the proxied server. The client's protocol can be passed in a header such as X-Forwarded-Proto, which the app has to be told to trust. Without both, the app builds redirects and cookie settings from the internal scheme and host.

How to tell: Logging the scheme and host inside the container shows http or an internal name instead of the public https host.

Cause 3

Cookie attributes do not fit the public host

A browser sends a cookie back when the request matches its attributes. A Secure cookie needs an https: request, a Domain must be the sender's domain or a parent of it, and a __Host- prefix forbids Domain and requires Path=/. A cookie set with an internal Domain or from an http: view of the request can be dropped or not returned.

How to tell: The login response has a Set-Cookie header, but the next request to the app carries no Cookie header, or the browser does not store the cookie.

Cause 4

Redirects leave the app with an internal address

nginx rewrites Location headers from the proxied server using a default derived from the location and proxy_pass directives. A Location built from another name, port or scheme is outside that default, so the browser is sent to an address that restarts the login.

How to tell: The Location header of the redirect response contains the container name, an internal port or http:// instead of the public URL.

Check and fix it, step by step

  1. Compare the login response with the next request

    In the browser's network tab, open the response that ends the login and look at its Set-Cookie header. Then check whether the following request to the app sends that cookie, and compare Secure, Domain, Path and any __Host- prefix with the public URL.

    Docs: developer.mozilla.org →

  2. Log the scheme and host the app sees

    Add this to a handler on the failing route and request it through nginx. If protocol is http or hostname is a container name, the app is not receiving the public values.

    console.log(req.protocol, req.hostname);

    Docs: expressjs.com →

  3. Search the nginx configuration and logs for limit_req

    Find any limit_req rule that applies to the login or callback location, and check the nginx logs for refusals from it. If the rule applies, scope it away from those paths or give it a burst size, then retest the loop on its own.

    Docs: nginx.org →

  4. Forward the public host and scheme from nginx

    Set the headers in the location that has proxy_pass. A proxy_set_header at that level replaces the ones inherited from the server block, so repeat every header you need there. Use your own public host name.

    proxy_set_header Host app.example.com;
    proxy_set_header X-Forwarded-Proto https;
    proxy_set_header X-Forwarded-Host app.example.com;

    Docs: nginx.org →

  5. Tell the app to trust the proxy

    In Express, the trust proxy setting exposes the information the reverse proxy provides, such as the forwarded protocol and host. The docs say the setting has to match how your proxy operates, and they accept an IP address, a subnet or a list; this example names the address nginx connects from.

    app.set('trust proxy', '172.18.0.2'); // the address nginx connects from

    Docs: expressjs.com →

  6. Set the trust-host variable if the app uses Auth.js

    Auth.js behind a reverse proxy needs to be told to trust the X-Forwarded-Host header. Set the variable in the container's environment and restart it.

    AUTH_TRUST_HOST=true

    Docs: authjs.dev →

  7. Rewrite internal names in cookies and redirects

    If the app still emits an internal Domain or Location that you cannot change in its code, rewrite them in nginx. proxy_cookie_domain changes the domain attribute of Set-Cookie, and proxy_redirect changes Location.

    proxy_cookie_domain localhost example.org;
    proxy_redirect http://localhost:8000/ https://example.org/;

    Docs: nginx.org →

Quick check: Log req.protocol and req.hostname in the app; http or an internal host name means nginx is not forwarding the public scheme and host.

How often this shows up in our data

2 of the 701 verified cases from the last 12 months in our data match this symptom (0.3%). The most common cause was “A rate-limit rule covers the login path” (1 of 2); 1 didn't show which cause. How we collect and verify cases.

Questions

Why does the login work on localhost but loop in production?

Locally there is no proxy changing the scheme and host, and browsers relax the Secure requirement for localhost. Behind nginx on a public https URL, the cookie attributes and redirect URLs have to match what the browser used.


If I forward the Host header, do I still need X-Forwarded-Proto?

Host carries the name, while X-Forwarded-Proto identifies whether the client connected to the proxy with http or https. Forward both so the app can build correct URLs.


Why do I see 503 responses during the loop?

If a limit_req rule covers the login path, nginx rejects excess requests with the status set by limit_req_status, which is 503 by default. A loop sends repeated requests to that path.


Should I set trust proxy to true?

The Express docs warn that when it is set to true, the last trusted reverse proxy has to remove or overwrite X-Forwarded-For, X-Forwarded-Host and X-Forwarded-Proto, otherwise a client may be able to provide any value. They also say the setting must match how your reverse proxy operates.


Sources

Every cause and step above was checked against these pages on 13 Oct 2026.

The broader pattern

This is one symptom of a wider failure pattern: Sign-in works locally but fails in production: how to fix it. The guide covers every cause we see for it, on any stack.

Get a heads-up when Docker or VPS / Docker breaks something

DockerVPS / Docker

Leave your email and we'll let you know when something big changes for Docker and VPS / Docker. Unsubscribe any time by replying. Gemmein Limited. Research terms · Privacy

← All fixes