In short. Five separate components can reject an oversized upload, each with its own limit and its own default. Raising the one you found first usually moves the failure to the next layer rather than fixing it. Worse, one of the five does not return 413 at all — it returns an empty form and looks like a bug in your code.
The reason "just raise the limit" keeps not working
A request travels through a stack, and every stage in that stack has an opinion about how large a body it will accept. A CDN, a reverse proxy, the web server, the language runtime and the application framework each enforce a separate ceiling, configured in a separate place, with defaults that were chosen independently.
So the usual experience is a sequence: raise the proxy limit, hit the runtime limit; raise the runtime limit, hit the framework limit. Each step feels like progress and none of them is the fix, because the fix is knowing which ceiling is lowest before touching anything.
The layer that rejects is the layer with the smallest limit, not the layer closest to your code. That is why the diagnosis has to start with identifying who answered rather than with editing configuration.

Identify who rejected it before changing anything
The response itself names the responder. Two things distinguish the layers: the Server header and whether the body looks like a default error page or like your application.
# Send a body of a known size and read the full response headers
head -c 5M /dev/urandom > /tmp/blob.bin
curl -sS -o /dev/null -D - -F "file=@/tmp/blob.bin" https://example.com/upload \
| grep -iE '^(HTTP/|server|cf-ray|content-type|connection):'
# Walk the size up and find where the answer changes
for mb in 1 2 5 10 20 50; do
head -c "${mb}M" /dev/urandom > /tmp/blob.bin
printf '%-5s ' "${mb}M"
curl -sS -o /dev/null -w '%{http_code}\n' --max-time 60 \
-F "file=@/tmp/blob.bin" https://example.com/upload
done
The size at which the status changes is the limit you are hitting, and the Server header on that response tells you whose limit it is. If a cf-ray header is present, an edge answered and your origin may never have seen the request at all.
The five layers, their directives and their defaults
| Layer | Setting | Typical default | How the failure looks |
|---|---|---|---|
| CDN / edge | Plan-dependent upload cap | Varies by provider and plan | 413 with an edge header such as cf-ray |
| nginx | client_max_body_size | 1 MB | 413 with an nginx error page; connection often closed |
| Apache | LimitRequestBody | Unlimited unless set | 413 with an Apache error page |
| PHP | upload_max_filesize | 2 MB | Upload rejected; the file simply is not there |
| PHP | post_max_size | 8 MB | Not a 413 — the form arrives empty |
| Node / Express | body parser limit | 100 KB for JSON | 413 generated by the framework |
Read the row for post_max_size twice. It is the reason this error consumes afternoons.
The silent one: PHP's post_max_size
When a request body exceeds post_max_size, PHP does not return 413. It discards the body and continues, so $_POST and $_FILES arrive empty and the script runs normally against nothing.
From the application's point of view the user submitted a blank form. From the user's point of view they attached a file and the site said a field was required. Nothing in the logs says "too large", because as far as PHP is concerned nothing went wrong.
<?php
// Detect the condition explicitly — otherwise it is indistinguishable
// from a user submitting an empty form.
$declared = (int) ($_SERVER['CONTENT_LENGTH'] ?? 0);
$limit = (int) ini_get('post_max_size'); // note: may carry a K/M/G suffix
if ($_SERVER['REQUEST_METHOD'] === 'POST' && $declared > 0 && empty($_POST) && empty($_FILES)) {
http_response_code(413);
exit('Upload exceeded the server limit.');
}
Two rules keep this from recurring. post_max_size must be larger than upload_max_filesize, because the post body contains the file plus the rest of the form. And both must be smaller than whatever the web server in front permits, or the web server rejects first and PHP never runs.
# What is actually in effect, as opposed to what the file says
php -i | grep -E '^(upload_max_filesize|post_max_size|max_file_uploads|memory_limit|max_execution_time)'
# Under PHP-FPM the effective values can differ from the CLI — ask through the web
<?php phpinfo(); // on a temporary, non-public page
Checking the limit with the command-line PHP binary is a common false result. Web requests run under a different SAPI and frequently a different configuration, so the CLI can report a generous limit while the pool enforces a small one. Read the value through a web request.

nginx: the limit everyone finds first
Nginx defaults to a one-megabyte body, which is why it is the most frequently blamed layer — it is usually the narrowest gate and therefore the first one hit.
# Set it at the level that matches the scope you intend
http { client_max_body_size 64m; } # everywhere
server { client_max_body_size 64m; } # one virtual host
location /upload { client_max_body_size 512m; } # one endpoint — preferred
# Verify what is actually loaded, rather than what you edited
nginx -T 2>/dev/null | grep -n client_max_body_size
nginx -t && systemctl reload nginx
Scope it to the endpoint that accepts uploads. A global limit large enough for your video upload path is also the limit an attacker gets against every other endpoint on the server, and a body limit is one of the cheapest denial-of-service protections you have.
Setting client_max_body_size 0 disables the check entirely. It is occasionally suggested and it is rarely a good idea, because it removes the ceiling rather than raising it.
When a proxy is in front of your proxy
If nginx forwards to an application server, both sides have limits and the request must satisfy each in turn. The same applies when a CDN sits in front — its cap applies before your configuration is consulted at all.
# Compare proxied against direct: if direct succeeds and proxied fails,
# the limit belongs to whatever sits in front.
curl -sS -o /dev/null -w 'via edge: %{http_code}\n' -F "file=@/tmp/blob.bin" https://example.com/upload
curl -sS -o /dev/null -w 'origin: %{http_code}\n' -F "file=@/tmp/blob.bin" \
--resolve example.com:443:203.0.113.10 https://example.com/upload
An edge limit cannot be raised from your server, so if that is where the rejection happens the options are a different plan, a different upload path that bypasses the edge, or an architecture that does not send large bodies through it at all. The header checker shows which headers the edge attaches, which is the quickest way to confirm it is involved.
Raising the limit is not the whole fix
Every layer configured to accept a large body now has to hold and process one, and three other limits become relevant the moment uploads get big:
- Timeouts. A large upload on a slow connection takes minutes. If a read timeout is shorter than the upload takes, you trade 413 for a timeout — and the timeout ladder across layers has to be ordered correctly, which is covered in the 499 guide.
- Memory. Frameworks that buffer an upload in memory need headroom proportional to size and concurrency. Ten simultaneous 500 MB uploads is five gigabytes if nothing streams to disk.
- Disk. Temporary upload directories fill, and a full disk fails in far less obvious ways than a rejected request.
Above a certain size the right answer stops being a larger limit. Chunked or resumable uploads split the file into pieces small enough that no layer needs raising, survive an interrupted connection, and let you show real progress. Presigned uploads sent directly to object storage remove your web stack from the path entirely, which is why most applications handling genuinely large files end up there.

A diagnosis order that stops at the right layer
- Find the threshold by walking the request size up until the status changes.
- Read the
Serverheader on the rejecting response — it names the layer. - Check for edge headers. If an edge answered, your origin configuration is irrelevant.
- Compare proxied against direct to confirm which side rejects.
- If there is no 413 at all but the form arrives empty, you have the PHP case — check
post_max_sizebefore anything else. - Raise limits outermost first, then inward, so each change is verified rather than assumed.
Step five is the one worth remembering, because the absence of an error is what makes it hard. A missing 413 is not evidence that the size is acceptable.
How to check your site right now
Use the HTTP header checker to see exactly which server answers on your upload endpoint and whether an edge sits in front — the two facts that decide where the limit lives. The redirect tracer matters when the upload endpoint is reached through a hop, since the limit that applies is the one at the final destination.
Upload failures are also easy to miss, because they affect one endpoint while the rest of the site is healthy. Scheduled monitoring against the specific endpoint records status over time, which is what turns "some users cannot upload" into a timestamp you can line up against a deploy or a configuration change.

Frequently asked questions
I raised client_max_body_size and it still fails.
Then nginx was not the narrowest gate, or the change was not loaded. Confirm with nginx -T that the directive is actually in the running configuration and at the right scope, then look at the next layer — most often the language runtime.
Why do I get an empty form instead of an error?
That is PHP's post_max_size being exceeded. The body is discarded and the script runs against nothing, so there is no error to log. Compare CONTENT_LENGTH against an empty $_POST to detect it explicitly.
Should upload_max_filesize and post_max_size be equal?
No — post_max_size must be larger, because the request body holds the file plus every other form field and the multipart overhead. Equal values fail on any upload at exactly the maximum size.
Can I just set the limit to unlimited?
You can, and it converts a bounded rejection into an unbounded resource commitment. A body size limit is one of the cheapest protections against a trivially cheap attack. Raise it to what you need and scope it to the endpoint that needs it.
Why does it work locally but not in production?
Local environments usually have no proxy and no edge, so only the runtime limit applies. Production adds two or three more gates, each with its own default. Reproduce by testing against the production URL rather than the application directly.
What is the right approach for very large files?
Do not send them through the web stack. Chunked or resumable uploads keep every individual request small, and presigned uploads to object storage remove the size question from your servers entirely — along with the memory, disk and timeout problems that come with it.
Checklist
- Find the exact size at which the response changes before editing anything.
- Read the
Serverheader on the rejecting response to identify the layer. - Check for edge headers — an edge limit cannot be raised from your server.
- Remember that PHP's
post_max_sizeproduces an empty form, not a 413. - Keep
post_max_sizelarger thanupload_max_filesize. - Read effective PHP values through a web request, not the CLI binary.
- Scope
client_max_body_sizeto the upload endpoint, not globally. - Never disable the limit entirely.
- Raise timeouts and check memory once bodies get large.
- Move genuinely large files to chunked or presigned uploads.