413 Request Entity Too Large — Fix Nginx Upload Limits
Raise client_max_body_size in nginx and match your app's upload cap to fix 413 errors.
20+ years shipping production infrastructure and CI/CD at scale. Everything here is grounded in real deployments.
- ✓Basic Nginx reverse-proxy configuration
- ✓Familiarity with HTTP requests and headers
- ✓Access to server logs for debugging
- Nginx rejects any body larger than client_max_body_size (default 1m) before your app ever sees the request
- Confirm it by comparing the request's Content-Length to your limit and grepping error.log for 'client intended to send too large body'
- Every proxy hop enforces its own cap, so align the CDN, load balancer, ingress, and app framework together
- Mirror the limit in Express, Spring, or Django and add client-side chunking for any file over 10 MB
Picture a nightclub with a strict door policy: no bags bigger than a backpack. Your friend inside said a suitcase was fine, but the bouncer turns you away before you ever get in. That's a 413: your file exceeds what the front-door server (usually Nginx) allows, so your app never sees it. The fix is raising the limit at each door along the way — and making every door agree on the size.
Your users hit 'Upload' and instantly get a 413 Request Entity Too Large error. No progress bar, no partial save, no helpful message — just a flat rejection. The frustrating part is that your application logs show nothing at all, because the request never reached your code. The web server sitting in front of your app killed it at the door.
This error shows up the moment real users do real things: a seller uploads 22 MB of product photos, a patient attaches a scanned PDF to a support ticket, an API client POSTs a 5 MB JSON payload. Everything worked in development because test files are tiny. Production files aren't.
The trap is that there's rarely one limit. Nginx has its default 1 MB cap, your CDN may enforce 30 MB, your Kubernetes ingress has its own annotation, and your app framework adds yet another ceiling on top. Raising just one of them moves the failure to the next hop instead of fixing it.
By the end of this article you'll know how to trace a 413 to the exact hop that rejected it, compare Content-Length against each configured limit, and raise every layer consistently. You'll also learn when raising the limit is wrong — and chunked or direct-to-S3 uploads are the real answer.
How Nginx Enforces client_max_body_size Before Your App Runs
Nginx checks the request body size before it does anything else useful — before proxying, before auth, before your upstream sees a single byte. The directive is client_max_body_size, and its default is 1m. That default was chosen in an era of small form posts, and it silently governs every file upload, every large JSON payload, and every multipart form that passes through the server.
The check happens against the Content-Length header first. If the declared length exceeds the limit, Nginx returns 413 immediately and logs 'client intended to send too large body' to error.log. If the client uses chunked transfer encoding with no Content-Length, Nginx reads the body as it streams and aborts with 413 the moment the accumulated bytes cross the limit. Either way, the upstream gets nothing — not a truncated body, not an error callback, just silence.
Scope matters enormously. The directive is valid in http, server, and location contexts, and the most specific block wins. A 60m in the http block with a 2m in one location means that location enforces 2m. Most production incidents come from exactly this: someone raised the global value while a location block for /api/uploads kept a stricter one, or vice versa. Always verify the effective value with nginx -T and read which block actually matches your upload path. After reloading, re-run the curl bracket test — the size that failed before should now pass, proving that location block (not some other hop) owned the rejection.
Confirm It: Content-Length Versus the Limit in error.log
Don't guess which layer rejected the upload — prove it. The signature of an Nginx-side 413 is unmistakable: a line in error.log reading 'client intended to send too large body' with the exact byte count. That line also tells you the declared size, so you can compare it directly against your configured limit and see the gap in one glance.
Reproduce the failure deliberately with curl and a test file of known size. Use --data-binary with a file just over your suspected limit and watch for HTTP 413 in the response headers. The -v flag shows you the Content-Length curl sent, which removes all doubt about what the client declared. If the app works for a 900 KB file and fails for a 1.1 MB file, you've bracketed a 1m limit without reading a single config file.
Correlate timestamps next. Match the 413 responses in access.log (status 413, bytes sent, request path) against the error.log rejection lines and your support tickets. When all three agree, you've got an airtight diagnosis: which path, what size, and which limit fired. This takes ten minutes and prevents the classic multi-hour detour into debugging application code that never ran. Save the bracket results (pass/fail per megabyte) in the incident ticket — they become the regression test you re-run after every future limit change.
Proxy Chains: Every Hop Has Its Own Limit
Fixing Nginx alone often just moves the 413 one hop closer to the user. A typical production path — browser to CDN, to cloud load balancer, to Kubernetes ingress, to Nginx sidecar, to app — has up to five independent body-size limits, and the effective cap is the smallest of all of them. Each layer returns its own flavor of rejection, which is why the error page changes shape as you fix layers one by one.
Cloud load balancers are the sneakiest hop because their limits aren't always configurable. Some ALB configurations cap certain request paths around 30 MB, and CDN free tiers commonly enforce 100 MB per request. Kubernetes ingress controllers add their own: the NGINX ingress uses the client-max-body-size annotation (default 1m, mirroring Nginx), and each Ingress resource can override it independently. If your team fixed the Nginx Deployment but uploads route through an ingress with the default annotation, nothing changed for real traffic.
Debug the chain by peeling layers. Bypass the CDN by curling the load balancer directly, bypass the LB by hitting the ingress, bypass the ingress with port-forward straight to the pod. The first hop that stops returning 413 as you peel inward is the hop whose limit was blocking you. Document every hop's limit in one runbook table afterward — future-you will thank present-you at 2 AM.
Express and Multer Caps That Reject After Nginx Passes
Once Nginx allows the body through, your Node.js framework gets its own vote. Express's built-in JSON parser defaults to a 100 KB limit — far smaller than most teams expect — so a 2 MB API payload sails through a 60m Nginx config and then dies inside express.json() with a 413 PayloadTooLargeError. Multipart uploads go through Multer instead, which enforces its own fileSize cap per file, and exceeding it produces an error your handler must catch explicitly or the client sees a hung connection.
The failure mode here is confusing because it looks like your route code ran and threw. It didn't — the body-parsing middleware rejected the request before your handler executed. The tell is the error shape: entity.too.large from body-parser, or Multer's LIMIT_FILE_SIZE code. Log these distinctly from business-logic errors so your dashboards separate 'client sent too much' from 'our code broke'.
Match these caps to the Nginx location value deliberately, with Nginx slightly larger. If Nginx allows 60m but Multer allows 50m, Multer's structured error reaches the client with a message you control. If it's reversed, Nginx's bare 413 page wins and your careful error handling never fires. Keep both numbers as named constants with comments referencing each other.
express.json() default limit is 100kb, not unlimited. Any JSON payload over 100 KB fails until you pass an explicit limit option — this surprises nearly every team once.Spring Multipart and Django Caps in JVM and Python Stacks
Spring Boot caps multipart uploads with two properties that work as a pair: max-file-size limits each individual file, while max-request-size caps the entire multipart request including all files plus form fields. The defaults are 1 MB per file and 10 MB per request — generous-looking until a user attaches three 4 MB scans and the 12 MB total blows past max-request-size. Exceeding either throws a MultipartException that Spring translates to a 500 unless you handle MaxUploadSizeExceededException explicitly, which turns an infrastructure limit into a misleading server-error alert.
Django's equivalent is DATA_UPLOAD_MAX_MEMORY_SIZE (2.5 MB default), which controls when uploaded data spills from memory to a temp file, plus DATA_UPLOAD_MAX_NUMBER_OF_FIELDS that rejects forms with too many fields. Django raises SuspiciousOperation for oversized payloads, and large file handling also depends on FILE_UPLOAD_MAX_MEMORY_SIZE. These defaults are tuned for small forms, not for document-heavy workflows, so any app accepting scans or media needs explicit values in settings.py.
The pattern is identical across stacks: find the framework's body cap, set it just under the proxy's cap, and convert the rejection into a clean 413 response with the actual limit in the message. Users who see 'max 50 MB per file' retry with a smaller file; users who see a bare error page file a support ticket.
Stop Raising Limits: Chunked and Direct-to-S3 Uploads
Every limit increase buys headroom and sells reliability. Larger bodies mean longer proxy buffering, more memory per connection, slower client retries, and a bigger denial-of-service surface. Past roughly 10–50 MB per file, the correct fix stops being a bigger number and starts being a different architecture: chunked uploads or direct-to-object-storage uploads that bypass your proxy entirely.
Chunked uploads split the file in JavaScript (Blob.slice) into 5 MB parts, POST each part with a sequence number, and reassemble server-side. Each part stays far under every limit in the chain, failed parts retry individually instead of restarting a 2 GB transfer, and you get a progress bar for free. Libraries like tus and Uppy implement the resumable-upload protocol so you don't hand-roll reassembly and cleanup of orphaned parts.
Direct-to-S3 (or GCS/Azure Blob) goes further: your API returns a presigned URL, the browser PUTs the file straight to object storage, and your servers never touch the bytes. Nginx limits become irrelevant for payload data, bandwidth costs drop, and uploads scale without scaling your app tier. The app's only job is authorizing the URL and validating the result — which is exactly the separation that prevents the next 413 incident before it starts.
The 22 MB Product Video That Blocked Seller Uploads for 6 Hours
- A 413 with zero application logs means the rejection happened upstream of your code — start in error.log, not your exception tracker. The team lost 4 hours instrumenting a handler that was never called.
- Limits must be set as one documented chain, not per-layer folklore. When the CDN allows 100 MB, the form allows 50 MB, and Nginx allows 1 MB, the effective limit is 1 MB and nobody knows it until launch day.
- Staging-only fixes don't exist. If the limit change isn't in the same manifest pipeline that deploys production, it will silently diverge — promote config and code through the identical path.
| File | Command / Code | Purpose |
|---|---|---|
| http { | How Nginx Enforces client_max_body_size Before Your App Runs | |
| sudo grep 'client intended to send too large body' /var/log/nginx/error.log | ta... | Confirm It | |
| kubectl port-forward deploy/app 8080:8080 & | Proxy Chains | |
| app.js | const express = require('express'); | Express and Multer Caps That Reject After Nginx Passes |
| src | spring: | Spring Multipart and Django Caps in JVM and Python Stacks |
| uploads.py | from flask import Flask, request, jsonify | Stop Raising Limits |
Key takeaways
Common mistakes to avoid
5 patternsRaising only the Nginx limit while the ingress still enforces 1m
Debugging application code for a rejection that happened upstream
Forgetting Express's 100 KB JSON default on API payloads
express.json() and express.urlencoded(), sized just under the Nginx location value.Setting only Spring's max-file-size and ignoring max-request-size
Relying on client-side file-size checks as enforcement
Interview Questions on This Topic
A file upload returns 413 but your application logs show nothing. Where do you look first and why?
Frequently Asked Questions
20+ years shipping production infrastructure and CI/CD at scale. Everything here is grounded in real deployments.
That's Networking. Mark it forged?
6 min read · try the examples if you haven't