~blogevery-layer-gets-a-veto

413 on upload: every layer in the request path has its own limit

2025-10-07 · 5 min read · debugging · nginx · kubernetes · wordpress · nextjs

A request from a browser to your application code passes through a CDN, an ingress controller, a reverse proxy, and a runtime, and every one of those enforces its own limits and its own routing rules. Any of them can reject or misdirect the request, and the one that does is usually not the one you configured.

Two failures make this concrete. Both look like application bugs. Neither is.

Case 1: the upload limit you did not set

Someone cannot upload a 40 MB file. You raise the limit in the application’s settings. It still fails. You raise it further. It still fails, in exactly the same way, because the application was never the thing refusing.

Here is the actual chain for a PHP application on Kubernetes, and what each link enforces:

  browser

     v
  ingress controller ──── proxy body size annotation       ← rejects at 1M default

     v
  NGINX ───────────────── client_max_body_size             ← rejects at 1M default

     v
  PHP ────────────────┬── upload_max_filesize              ← rejects at 2M default
                      ├── post_max_size                    ← must exceed the above
                      ├── memory_limit                     ← large files need headroom
                      └── max_execution_time               ← slow uploads die here

     v
  application ────────── its own configured maximum        ← the only one you edited

Five independent limits, four of which ship with defaults smaller than any modern file. The application’s own setting is the last one in the chain, so raising it changes nothing until every gate before it also opens.

The values that work for a 128 MB ceiling:

file_uploads = On
upload_max_filesize = 128M
post_max_size = 128M
memory_limit = 256M
max_execution_time = 300
max_input_time = 300
nginx.ingress.kubernetes.io/proxy-body-size: "128m"

Three things about that config are worth knowing rather than copying.

post_max_size must be at least upload_max_filesize, because the whole POST body includes the file plus the form fields around it. Setting them equal is conventional; setting the file limit higher is a configuration that cannot work.

memory_limit needs headroom above the file size, since the runtime holds more than just the bytes.

And max_execution_time is why this fails intermittently on bad connections and never in your office. A 128 MB upload over a slow uplink takes longer than the default 30 seconds, so the same file succeeds and fails depending on the network. That variability is what sends people looking for a bug in the application.

Check the annotation name against your installed ingress controller version. These change between controllers and between major versions, and a misspelled annotation is silently ignored, which looks identical to a value that did not take effect.

The diagnostic habit: identify every hop and the limit each one enforces, then check them from the outside in. The error message names the layer that refused, and if it is generic, the response status usually distinguishes them. A 413 from a proxy looks different from a runtime that accepted the bytes and then gave up.

Case 2: the MIME error that was a routing error

Different symptom, same class:

Refused to execute script because its MIME type ('text/html') is not executable

The instinct is to go looking for a content-type configuration, because the error says MIME type. That is almost never where the problem is.

Read it as a statement of fact instead: the browser asked for JavaScript and received HTML. Your server did not mislabel a script. It sent a completely different document and labelled it correctly.

The usual culprit is an SPA fallback rule. try_files or an equivalent catch-all is configured to serve index.html for anything that does not match a file, so a request for a missing or misrouted asset returns the application’s HTML shell with a 200 and a content type of text/html. The fallback that makes client-side routing work also converts every asset 404 into a confusing MIME error.

One command settles it:

curl -I https://example.com/_next/static/chunks/main-abc123.js

Four things to read in the response. The status: a 200 with HTML means the fallback ate it, a 404 means the asset is genuinely not being served. The content type: text/html where you expected application/javascript confirms the diagnosis. The cache headers: a stale CDN entry for a path that has since changed is its own version of this. And the body, if you drop the -I, because seeing your own homepage returned for a script request removes all doubt.

Once you know it is routing, the candidates are short: location block precedence sending asset paths to the wrong upstream, the fallback rule applied too broadly, a base-path mismatch between build and serve, static output not copied into the runtime image, or a proxy cache holding a path that no longer exists.

That fourth one is worth its own mention because it is a build problem masquerading as a proxy problem. A multi-stage Docker build that forgets to copy the static directory produces an image that serves HTML perfectly and has no JavaScript to serve. The proxy is behaving correctly. There is nothing there.

The habit

Both cases share a structure worth naming:

The layer that reports a failure is downstream of the layer that caused it, and the error message describes the symptom in the reporter’s vocabulary rather than the cause in the culprit’s.

A browser says “MIME type” because content type is what a browser can observe. It cannot say “your ingress routed this to the wrong upstream” because it knows nothing about your ingress.

So when a setting you changed has no effect, stop changing it harder. Write down every hop the request makes, and ask what each one is allowed to reject or redirect. Then test the hops individually, from the edge inward, with curl against each layer you can address directly.

It takes a few minutes and it turns “the app is broken” into “the third of five gates is closed”, which is a fixable statement.