The Redirect That Leaked Port 3000, Twice
We found the same bug on two of our sites in one afternoon, an hour apart, because we had copied one nginx config into a second repository. Every page worked in a browser. To a crawler, a store checker, or anyone who followed an exact link, a slice of each site was unreachable, and the failure message pointed everywhere except the cause. Here is the mechanism, why it hides, and the guard that keeps it from happening a third time.
The setup: a container that listens on a port nobody outside can reach
Both sites are static builds served by nginx inside a container on a k3s cluster, behind a Traefik ingress that terminates TLS. Inside the cluster, nginx listens on port 3000. Outside, the world speaks to https://deplyra.com on 443, and Traefik forwards to the container. A completely ordinary shape; thousands of deployments look like this.
The prerendered pages live on disk as directories: /citeright/privacy/index.htmland so on. Which means a request for /citeright/privacy, without the trailing slash, does not name a file. nginx notices the directory exists and helpfully issues a 301 to the slashed form. That helpfulness is the bug.
The mechanism: Location is built from the host and the listen port
By default, nginx builds an absolute redirect URL. It takes the Host header the client sent, which is the public hostname, and the port it is listening on, which is the internal container port. It has no idea TLS was terminated upstream, so the scheme is http. The response to GET /citeright/privacy was therefore:
HTTP/1.1 301 Moved Permanently
Location: http://deplyra.com:3000/citeright/privacy/Read that Location line slowly, because all three components are wrong in a different way. The scheme has been downgraded from https to http. The port is the internal one, 3000, which no firewall on earth exposes. And the hostname is the real public one, which is what makes the URL look plausible enough to follow. Anything that follows it dials deplyra.com on port 3000 and gets a refused connection.
Why it hid: browsers and crawlers take different paths
A human clicking through the site never saw a problem, for a dull reason: internal links on the site carry the trailing slash, so the redirect never fired. Type the URL by hand with a slash, fine. Click a nav link, fine. The bug only fired on the exact slashless form, and the population that requests exact URLs is precisely the population you cannot watch: search crawlers re-fetching a URL they indexed, an app store's automated checker verifying the privacy-policy link a developer pasted, a link-preview bot resolving a URL shared in chat.
For those clients the observed behaviour is a connection refused after a redirect, which gets reported upstream as the page being down or not found. On deplyra.com the URLs in question were the legal pages that app stores fetch to verify a listing, so the symptom surfaced as a store checker calling a live page unreachable. Nothing in that report says redirect, port, or nginx. We found it by fetching the exact URL with curl -sI and reading the Location header, which took thirty seconds and could have happened weeks earlier if anything had made us look.
An hour later, the same bug, in the copy
The fix went into deplyra.com's nginx config. An hour later the same investigation on scan.deplyra.com, our browser-side document tools site, found the identical redirect:/guides/x answering 301 to http://scan.deplyra.com:3000/guides/x/. Same config file, copied into a second repository when that site was set up, same defect travelling with it. The commit that fixed the second site says it plainly: this is the same mistake in the second copy of the same config.
That is the quiet lesson of the afternoon. Configuration you copy is code you fork, and a bug fixed in the original stays alive in every copy until something makes each one fail visibly. If a config file is worth copying between repositories, its regression checks are worth copying with it.
The fix: two directives and a serving order
# Never emit an absolute redirect.
absolute_redirect off; # Location becomes /path/, relative, scheme-safe
port_in_redirect off; # and no internal port can ever leak into it
# Serve the page AT the slashless URL instead of redirecting to it:
# $uri/index.html before $uri/ means the exact URL that was linked
# answers 200 with the content, no hop at all.
try_files $uri $uri/index.html $uri/ =404;The directives make any remaining redirect relative and portless. The try_files order goes one better: for the URL a store checker or crawler holds, there is now no redirect to follow at all, the content is served at the exact URL that was given out. A checker gets its 200 on the first request instead of being trusted to follow a hop correctly.
The guard: assert it on every deploy
A fix without a guard is a fix until someone regenerates the config. scan.deplyra.com's deploy now runs a live check that fetches representative slashless URLs on the real domain and fails the deploy if any response contains an absolute Location, a port, or an http downgrade. The commit message states the intent: so it cannot come back a third time. The check costs one HTTP request per URL and has already outlived the memory of the incident, which is the entire point of encoding it.
What to take from it
- If nginx runs behind any proxy, set
absolute_redirect offandport_in_redirect off. The defaults assume nginx is the edge. In a container it almost never is. - Test the exact URLs you publish, not the ones your own site links. The slashless form, the http form, the www form.
curl -sIon each, and read the Location header rather than trusting the status code. - Crawlers experience your site through a different code path than users do. A site can be simultaneously fine for every human and broken for every machine, and machine reports (store checkers, search consoles) are the only place the breakage shows.
- When you fix a copied config, go find the copies. Ours was one grep away, and the second site had the bug live at that moment.
Need this done, not just read about?
Deplyra builds, ships and runs exactly this in production — as code, with GitOps, handed over documented.
Start a project →