Nginx 413 Request Entity Too Large: fixing uploads across Nginx, PHP and Laravel
Uploading a PDF larger than 1 MB failed with a vague error in our claims portal. Nginx returned 413, then PHP silently dropped files, then Laravel rejected them. Four layers each had their own size limit. Here is how we aligned them and tested every boundary.
Our insurance claims portal lets customers attach scanned documents. After we moved it from a managed PHP host to our own containers, the support team noticed a new pattern: customers saying "the upload button does nothing" or "it says something went wrong". The JavaScript console told a more precise story:
POST https://claims.example.com/api/claims/8812/documents 413 (Request Entity Too Large)
Uncaught (in promise) SyntaxError: Unexpected token '<', "<html>
<h"... is not valid JSON
The frontend expected JSON, got Nginx's HTML error page and crashed in the parsing step, which is why the user saw a generic message. The Nginx error log confirmed the first limit:
2025/03/04 10:21:36 [error] 29#29: *1841 client intended to send too large body: 2481152 bytes, client: 203.0.113.61, server: claims.example.com, request: "POST /api/claims/8812/documents HTTP/2.0", host: "claims.example.com"
Reproducing it
We generated files of specific sizes and posted them with curl:
for size in 900K 1500K 3M 9M 15M; do
head -c "$size" /dev/urandom > "/tmp/test-$size.pdf"
code=$(curl -s -o /dev/null -w '%{http_code}' -H "Authorization: Bearer $TOKEN" \
-F "file=@/tmp/test-$size.pdf;type=application/pdf" \
https://staging-claims.example.com/api/claims/1/documents)
echo "$size -> $code"
done
900K -> 201
1500K -> 413
3M -> 413
9M -> 413
15M -> 413
What we ruled out
- A load balancer limit. The cloud load balancer in front passed bodies of any size in a direct
test against a dummy endpoint, and the 413 page had Nginx's
serverheader. - Laravel validation. Laravel's validation errors come back as 422 with JSON, not 413 HTML.
- Disk space for temporary files. The container had plenty, and the request never reached PHP.
Root cause
The new Nginx config had no client_max_body_size, so it used the default of 1 MB. The old managed
host had raised it for us years ago, which is why nobody remembered it existed. Once we raised it, we
found two more layers with smaller limits than the product spec of 10 MB per file: PHP's defaults
(upload_max_filesize = 2M, post_max_size = 8M) and a Laravel rule with max:5120, written for an
older spec. Each layer failed in a different way.
What is happening under the hood
An upload passes several gates, and each has its own limit and failure mode:
| Layer | Setting | Default | What happens when exceeded |
|---|---|---|---|
| Nginx | client_max_body_size |
1m | 413 HTML page, request never reaches PHP |
| PHP | post_max_size |
8M | $_POST and $_FILES are empty, PHP logs a warning; Laravel throws PostTooLargeException (413) |
| PHP | upload_max_filesize |
2M | File entry exists with error UPLOAD_ERR_INI_SIZE; Laravel's file validation fails with "failed to upload" |
| Laravel | max: rule (kilobytes) |
none | 422 with a validation message |
| PHP | max_file_uploads |
20 | Extra files are silently dropped |
Nginx checks the Content-Length header against client_max_body_size before reading the body, so a
too-large upload is rejected early. That is efficient, but browsers may show a network error instead of
the 413 if the server closes the connection while the client is still sending.
post_max_size applies to the whole multipart body, including all files and fields plus encoding
overhead, so it must be larger than upload_max_filesize multiplied by the number of files you accept
in one request.
Environment
| Component | Version / value |
|---|---|
| Web server | Nginx 1.27.4 (official Docker image) |
| PHP | PHP-FPM 8.3.17 (official php:8.3-fpm image) |
| Framework | Laravel 11.x |
| Frontend | Fetch API, multipart FormData |
| Product requirement | Up to 5 files per request, 10 MB each |
Minimal reproduction
# nginx default.conf: no client_max_body_size, so 1m applies
server {
listen 80;
root /var/www/public;
location ~ \.php$ {
fastcgi_pass app:9000;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}
}
// app/Http/Requests/StoreClaimDocumentRequest.php (old rule)
public function rules(): array
{
return ['file' => ['required', 'file', 'mimes:pdf,jpg,png', 'max:5120']]; // 5 MB in KB
}
Who is affected
Any Nginx plus PHP application that accepts uploads above 1 MB, and the same pattern exists with
other stacks: Node.js body parsers default to 100 KB for JSON, Django has DATA_UPLOAD_MAX_MEMORY_SIZE,
and Kubernetes ingress-nginx needs its own annotation. Moving between hosts is the usual trigger,
because the previous host's limits were never written down.
The fix
We decided on one number per layer, from the outside in, with each outer layer slightly larger than the inner one so the innermost layer, which can return a friendly JSON error, is the one that rejects.
1. Nginx: allow the whole request (5 files of 10 MB plus overhead), only on the upload route.
server {
client_max_body_size 2m; # sane default for everything else
# Upload route: send straight to Laravel's front controller with a larger limit
location ~ ^/api/claims/\d+/documents$ {
client_max_body_size 55m;
fastcgi_pass app:9000;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root/index.php;
}
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
fastcgi_pass app:9000;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}
}
We first tried try_files inside the upload location, and it still returned 413. Nginx re-selects
the location after the internal redirect to /index.php, and the .php location's own 2 MB limit
applied. Passing to FastCGI directly from the upload location keeps the large limit scoped to that
one route.
2. PHP: match the product spec.
; docker/php/conf.d/uploads.ini
upload_max_filesize = 10M
post_max_size = 52M
max_file_uploads = 5
3. Laravel: the authoritative check, with a clear message.
public function rules(): array
{
return [
'files' => ['required', 'array', 'max:5'],
'files.*' => ['file', 'mimes:pdf,jpg,png', 'max:10240'], // 10 MB, in kilobytes
];
}
4. Frontend: check before sending, and handle non-JSON errors.
const MAX_BYTES = 10 * 1024 * 1024;
const tooBig = [...input.files].filter((f) => f.size > MAX_BYTES);
if (tooBig.length) return showError(`Each file must be 10 MB or smaller.`);
const res = await fetch(url, { method: 'POST', body: formData, headers: { Accept: 'application/json' } });
if (res.status === 413) return showError('The upload is too large.');
const data = res.headers.get('content-type')?.includes('application/json') ? await res.json() : null;
Sending Accept: application/json also makes Laravel render its own 413 for PostTooLargeException
as JSON instead of an HTML page.
Alternatives and trade-offs
- Direct-to-storage uploads with presigned URLs. The browser uploads straight to object storage and only sends the key to the API. This removes the body from your web tier entirely and is our plan for files above 25 MB. It needs extra work for validation and malware scanning after upload.
- One global
client_max_body_size 100m. Simpler, but every endpoint can now be sent 100 MB bodies, which makes slow-upload denial of service cheaper. - Chunked or resumable uploads (for example the tus protocol). Best for very large files and bad networks, but more moving parts.
How to test the fix
We turned the curl loop into a boundary test with expected codes:
check() { # size expected_code
head -c "$1" /dev/urandom > /tmp/t.pdf
got=$(curl -s -o /tmp/body -w '%{http_code}' -H 'Accept: application/json' -H "Authorization: Bearer $TOKEN" \
-F "files[]=@/tmp/t.pdf;type=application/pdf" https://staging-claims.example.com/api/claims/1/documents)
[ "$got" = "$2" ] && echo "ok $1 -> $got" || { echo "FAIL $1 -> $got (want $2)"; cat /tmp/body; }
}
check 1500K 201
check 9M 201
check 10M 201
check 11M 422
check 60M 413
The 11 MB case proved the request reached Laravel and was rejected with a readable message. We also
added a Laravel feature test using UploadedFile::fake()->create('scan.pdf', 10241) to assert the
validation rule.
Confirming in production
- Nginx error log lines with "client intended to send too large body" on the upload route went from about 140 per day to zero; the few remaining ones are on other routes and are real abuse.
- Successful document uploads per day rose by 31 percent, which roughly matched the share of scans between 1 MB and 10 MB in our historical data.
- Support tickets tagged "upload" dropped from 9 per week to one.
Regression guard
- The boundary script runs in the staging smoke test after each deploy.
- Upload limits now live in one place,
config/uploads.php, which feeds the Laravel rule, the frontend (via a config endpoint) and a startup check that compares them withini_get('upload_max_filesize')and logs an error if PHP is lower than the app limit. - The Nginx and PHP ini files are in the repo, so a host move can no longer lose them.
Security implications
Raising body limits increases what a single request can consume: memory in buffers, temporary disk
space and worker time. We kept the larger limit only on the upload route, kept client_body_timeout
at 60 seconds to drop slow senders, and rate limited the upload endpoint per user. Bigger uploads also
mean bigger risk files, so validation still checks MIME type from content rather than the extension,
files are stored outside the web root with random names, and they are scanned before an adjuster can
open them.
Prevention checklist
- List every layer that can limit a request body and write the chosen values down.
- Make the innermost layer the strictest, so users get a friendly error.
- Scope large body limits to the routes that need them.
- Make frontend error handling tolerate HTML and empty responses.
- Test sizes just under, at and over each limit in CI.
References
- Nginx:
client_max_body_size— https://nginx.org/en/docs/http/ngx_http_core_module.html#client_max_body_size - PHP: handling file uploads — https://www.php.net/manual/en/features.file-upload.php
- PHP:
post_max_sizeand related ini settings — https://www.php.net/manual/en/ini.core.php#ini.post-max-size - PHP: file upload error codes — https://www.php.net/manual/en/features.file-upload.errors.php
- Laravel 11: validation, file rules — https://laravel.com/docs/11.x/validation#rule-max
- RFC 9110, 413 Content Too Large — https://www.rfc-editor.org/rfc/rfc9110#name-413-content-too-large
Add a label
Embed this post
<iframe src="https://hibug.me/nginx-413-request-entity-too-large-upload" width="700" height="420" frameborder="0" loading="lazy"></iframe>
I have spent most of my career on the side of software that pages people at night: payment integrations, web platforms with real traffic, CI/CD pipelines, Kubernetes clusters and the databases underneath them. Somewhere along the way I started keeping a notebook of every bug that cost more than an hour — the exact error, what we ruled out, the real root cause and how we proved the fix. Hibug is where those notes live. Every post follows the same path: Investigate, Explain, Fix, Verify — with the environment spelled out, the fix tested, and the security angle checked.
Related Stories
Google sign-in fails with Error 400: redirect_uri_mismatch only in production
Comments
0No comments yet. Be the first to comment!
Sign In to post a comment.