BlueByte
413Fixed

nginx: 413 Request Entity Too Large on upload

By Haneul SeoUpdated September 16, 20266 min

Hi, it's BlueByte. A user uploads a 12 MB PDF, the browser pauses, and back comes 413 Request Entity Too Large — from nginx, not from your app, which never saw the file. Nothing is broken: nginx enforces a body-size ceiling before it proxies anything, and the default is small. We'll walk through the message variants, how to find which hop refused the upload, the fix for each place the limit can live, and how to verify a real file lands.

What nginx sends and logs when it rejects an upload

The response body is nginx's built-in error page, which is why it looks nothing like your app:

<html>
<head><title>413 Request Entity Too Large</title></head>
<body>
<center><h1>413 Request Entity Too Large</h1></center>

The matching line in error.log names the size the client tried to send:

2026/09/16 09:41:02 [error] 2314#2314: *118 client intended to send too large body: 12582912 bytes, client: 10.0.0.8, server: app.example.com, request: "POST /api/upload HTTP/1.1", host: "app.example.com"

A chunked upload logs the sibling message client intended to send too large chunked body: 0+12582912 bytes. Both are [error] level and mean the same thing. One naming note: nginx still emits the older reason phrase, while RFC 9110 renamed status 413 to Content Too Large, so the same response can be labelled either way.

Why the default stops you at one megabyte

client_max_body_size defaults to 1m. nginx checks it against the request's Content-Length before reading the body, and if the request is larger it answers 413 and discards the upload — the backend is never contacted, which is why your application logs are empty. The directive is valid in the http, server, and location contexts, and that inheritance is what bites: a value set in http applies everywhere until a server or location sets its own, and then the inner value wins for that block alone. The docs also note browsers "cannot correctly display this error," which is why users report a blank page rather than the 413.

Find which hop is actually refusing the upload

Reproduce it with curl and read only the status line:

curl -s -o /dev/null -w '%{http_code}\n' \
  -F 'file=@invoice.pdf' https://app.example.com/api/upload
413

Then ask nginx what it actually loaded, rather than reading the file you believe is in effect. nginx -T runs the same checks as -t and dumps the merged configuration:

sudo nginx -T 2>/dev/null | grep -n 'client_max_body_size'
42:    client_max_body_size 1m;

No output means the directive appears nowhere and you are on the 1 MB default. Several lines mean context decides which applies — find the block each belongs to. If the limit here is already generous and you still get 413, something else in the chain refused: a CDN, a load balancer, an ingress controller, or the app. The error.log line is the tiebreaker — if it names your server and a byte count, this nginx did it.

Raise the limit in the right context, then test before you reload

Set it in the narrowest block that covers the upload path, so a large ceiling doesn't apply to every endpoint:

server {
    server_name app.example.com;
 
    location /api/upload {
        client_max_body_size 50m;
        proxy_pass http://app_backend;
    }
}

Check the syntax first, then reload — a reload re-reads the config without dropping live connections:

sudo nginx -t && sudo systemctl reload nginx
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful

To lift it everywhere, put client_max_body_size 50m; in the http block — any server or location with its own value still overrides it.

On Kubernetes the limit is an annotation, not a config file

With ingress-nginx, editing nginx.conf inside the controller pod is pointless — it is regenerated on the next sync. The limit belongs on the Ingress:

metadata:
  annotations:
    nginx.ingress.kubernetes.io/proxy-body-size: "50m"

Apply it, then confirm the rendered value:

kubectl annotate ingress app \
  nginx.ingress.kubernetes.io/proxy-body-size=50m --overwrite
kubectl exec -n ingress-nginx deploy/ingress-nginx-controller -- \
  grep -m1 client_max_body_size /etc/nginx/nginx.conf

The controller's default for proxy-body-size is 1m, matching upstream nginx; the fleet-wide equivalent is the proxy-body-size key in its ConfigMap.

When nginx is innocent and the app behind it has the smaller cap

If nginx -T shows a generous limit and the 413 persists, the backend is refusing. PHP-FPM is the classic case: upload_max_filesize defaults to 2M and post_max_size to 8M, and the PHP docs require post_max_size to be larger than upload_max_filesize, so raise both together:

upload_max_filesize = 50M
post_max_size = 52M

The tell is what the error looks like: an app-level rejection renders your framework's error page or a JSON body, while nginx's is the bare page above. Check the app's log alongside error.log first.

A real case: a 12 MB PDF that never reached the backend

Finance reports invoice uploads failing "randomly" — small files work, scanned ones don't. A curl with a 12 MB file returns 413 in well under a second, too fast to have crossed the network. error.log on the edge host has client intended to send too large body: 12582912 bytes, and nginx -T | grep client_max_body_size prints nothing, so the 1 MB default had been in force since the host was built. Adding client_max_body_size 50m; to the /api/upload location, running nginx -t, and reloading makes the same curl return 201; the app's limit was already 64 MB, so nothing else needed touching.

Confirm the upload lands, and keep the limit from drifting back

Don't stop at the status code — check that the bytes actually went:

curl -s -o /dev/null -w '%{http_code} %{size_upload}\n' \
  -F 'file=@invoice.pdf' https://app.example.com/api/upload
201 12583424

A size_upload close to the file size means the body crossed the wire instead of being cut off early. To keep it that way: ship the limit in a config fragment with your deploy rather than setting it by hand on one host, pick a number a little above the largest file you accept and validate size in the app too, and match the limit at every hop — CDN, load balancer, ingress, nginx, app — because the smallest wins. After an nginx upgrade or a config refactor, nginx -T | grep client_max_body_size is a two-second regression check.

How 413 differs from a 502 or a reset partway through

A 413 is a clean, immediate refusal: nginx decided before contacting the backend, so the response is near-instant and the upstream logs stay empty. A 502 Bad Gateway during an upload means nginx did proxy the request and the backend died or closed the connection — that fix lives in the backend or in buffering, not in client_max_body_size. A 504 Gateway Time-out means the body was accepted but the backend took longer than proxy_read_timeout to answer; size matters only because bigger files take longer. An upload that dies partway with a browser connection reset and no status code points further out, at a CDN or load balancer cutting the stream.

Related questions

I set client_max_body_size but still get 413.

Either the value sits in a block that doesn't cover the request, or a different hop is refusing. Run nginx -T | grep client_max_body_size to see what is really loaded and in which context; if that looks right, check your CDN, load balancer, ingress, and the app itself.

Does client_max_body_size 0 mean unlimited?

Yes — the nginx docs say setting the size to 0 disables checking of the client request body size. It also removes a useful guard against a client streaming unlimited bytes at your disk, so prefer an explicit ceiling above your largest legitimate file.

Why do users see a blank page instead of the error?

The nginx docs note that browsers cannot correctly display this error, and nginx closes the connection after answering. Catch the 413 status in your frontend upload code and show your own message with the size limit in it.

Do I need to restart nginx after changing the limit?

No. nginx -t && systemctl reload nginx is enough — a reload re-reads the configuration without dropping live connections. A full restart also works but interrupts in-flight requests for no benefit.

Is 413 the same as Content Too Large?

Same status code. RFC 9110 renamed the reason phrase to Content Too Large, while nginx still emits Request Entity Too Large, so monitoring tools may label the identical response either way.

References

Haneul Seo

Infrastructure engineer · 10+ years running Linux fleets

More in this category

detected dubious ownershipFixed

Git: fatal: detected dubious ownership in repository

Since the CVE-2022-24765 fix in Git 2.35.2, Git refuses to read a repository whose working tree or .git directory is owned by a different user than the one running the command. It shows up in containers, CI jobs, sudo sessions and shared drives. Fix the ownership if the repo should be yours, or add the exact path to safe.directory in your global config — never in the repo's own config, which Git ignores for this.

Git
failed calling webhookFixed

Kubernetes: Internal error occurred: failed calling webhook

An admission webhook sits in front of your write, the API server could not get an answer out of it, and failurePolicy: Fail turned that silence into a rejection. The tail of the message is the whole diagnosis: context deadline exceeded means the call went nowhere, no endpoints available means nothing is running, and an x509 line means the API server does not trust the webhook's certificate. Each has a different fix, and none of them is your manifest.

Kubernetes
1205Fixed

MySQL: ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction

A statement waited the full innodb_lock_wait_timeout for a row lock another transaction is still holding, and gave up. sys.innodb_lock_waits names the blocking session and hands you the KILL statement, and a blocking_query of NULL means the blocker is idle on an open transaction. The detail most retry loops get wrong: by default only the timed-out statement is rolled back, so your transaction is still open and still holds every lock it took earlier.

MySQL
MISCONFFixed

Redis: MISCONF Redis is configured to save RDB snapshots, but it's currently unable to persist to disk

Reads keep working and every write is rejected, because the last background save failed and stop-writes-on-bgsave-error defaults to yes. The log names the real cause — no space, a dir the redis user can't write, a read-only mount at rename time, or fork failing with Cannot allocate memory. Fix the cause, run one BGSAVE, and writes come back on their own with no restart: rdb_last_bgsave_status flips from err to ok. Setting stop-writes-on-bgsave-error no restores writes instantly but leaves the snapshot broken, so treat it as a deliberate trade, not the fix.

Redis
FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memoryFixed

Node.js: FATAL ERROR: Reached heap limit — JavaScript heap out of memory (exit 134)

The V8 heap has its own ceiling, derived from system memory and the Node release, and it is often far below the RAM you have; when a build or server reaches it, V8 aborts with FATAL ERROR: Reached heap limit and exit code 134. Read the real limit with v8.getHeapStatistics().heap_size_limit, then raise it with --max-old-space-size (in MiB) or NODE_OPTIONS for a large workload, size it below the cgroup limit inside containers, and use --heapsnapshot-near-heap-limit to catch a leak in a long-running process. Exit 137 with no FATAL ERROR line is a container kill, not this.

Node.js
exec /docker-entrypoint.sh: exec format errorFixed

Docker: "exec format error" when the container starts — wrong-platform image, no emulator, or a script with no shebang

The container exits on its first instruction with exec format error — the kernel's ENOEXEC, meaning the file exists but cannot be executed here. In practice that is an image built on one CPU architecture (an Apple-silicon Mac produces linux/arm64) and run on another (an x86_64 server) with no QEMU handler registered in binfmt_misc, or an entrypoint script whose first line is not a shebang. uname -m, docker image inspect and ls /proc/sys/fs/binfmt_misc tell the causes apart; the fix is an explicit docker buildx build --platform (or a manifest list for both), QEMU registration or --platform when you mean to emulate, and a #!/bin/sh line for the script.

Docker