Home › DevOps › 413 Request Entity Too Large — Fix Nginx Upload Limits
Intermediate 6 min · September 23, 2026

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.

N
Naren Founder & Principal Engineer

20+ years shipping production infrastructure and CI/CD at scale. Everything here is grounded in real deployments.

Follow
✓ Production
production tested
September 23, 2026
last updated
1,942
articles · all by Naren
Before you start⏱ 16 min
  • ✓Basic Nginx reverse-proxy configuration
  • ✓Familiarity with HTTP requests and headers
  • ✓Access to server logs for debugging
 ● Production Incident 🔎 Debug Guide
⚡Quick Answer
  • 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
✦ Definition~90s read
What is HTTP 413 Request Entity Too Large Fix?

HTTP 413 (renamed to 413 Content Too Large in newer specs, though every server and log still says Request Entity Too Large) means the server refused to process the request because the body is bigger than what it's configured to accept. It's a server-side policy rejection, not a malformed request — the bytes were fine, they were just too many.

★
Picture a nightclub with a strict door policy: no bags bigger than a backpack.

Nginx enforces it with the client_max_body_size directive, which defaults to 1m (1 megabyte). When a request arrives, Nginx reads the Content-Length header first; if it exceeds the limit, Nginx immediately returns 413 without reading the body or proxying anything upstream.

Your app logs stay silent because your app was never involved.

This is not an authentication problem, not a CORS problem, and not a bug in your upload handler. Those return 401, 403, or 500-series errors after your code runs. A 413 fires before routing, before auth middleware, before any of your code executes. That's why debugging starts in the web server's error.log, not your application logs.

The mechanism is a header comparison: Content-Length versus the configured cap, evaluated per location block, with server-level and http-level values as fallbacks.

What 413 is NOT: it's not a timeout (that's 408 or 504), not a proxy connection failure (502/504), and not a client-side validation message you can style away with JavaScript. Client-side file-size checks improve UX but enforce nothing — any curl command can bypass them.

The real enforcement always lives server-side, on every hop between the user and your app: CDN edge, load balancer, ingress controller, reverse proxy, and finally the framework's own body parser. Miss one hop and uploads still fail, just with a different server's error page.

Plain-English First

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.

/etc/nginx/sites-available/app.confNGINX
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# Global default stays strict — internal endpoints keep 1m protection
http {
    client_max_body_size 1m;
}

server {
    listen 443 ssl;
    server_name app.example.com;

    # Public upload endpoint: match this to the app framework cap
    location /api/uploads {
        client_max_body_size 60m;
        client_body_timeout 60s;
        proxy_pass http://app_upstream;
    }

    # Everything else keeps the tight default
    location /api/ {
        proxy_pass http://app_upstream;
    }
}

# After editing, verify the effective value per block:
# nginx -t && systemctl reload nginx
# nginx -T 2>/dev/null | grep -B 5 'client_max_body_size'
⚠ The 1 MB Default Applies Until You Override It
A fresh Nginx install rejects every body over 1 MB. If your team never explicitly set client_max_body_size, your production upload limit is 1 MB right now regardless of what the app allows.
📊 Production Insight
Set the generous limit only on the upload location, never globally. A global 500m lets attackers force your proxy to buffer half-gigabyte bodies on every endpoint, which is a cheap denial-of-service vector.
🎯 Key Takeaway
Nginx compares Content-Length to client_max_body_size before proxying anything. The most specific block wins, so verify the effective value for your exact upload path.

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.

BASH
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# 1. Find Nginx-side rejections with exact byte sizes
sudo grep 'client intended to send too large body' /var/log/nginx/error.log | tail -20

# 2. Correlate with 413s in the access log (path + status + size)
sudo awk '$9 == 413 {print $4, $7, $9, $10}' /var/log/nginx/access.log | tail -20

# 3. Show the effective limit for every block
sudo nginx -T 2>/dev/null | grep -B 3 'client_max_body_size'

# 4. Reproduce with a known-size file and watch the status code
dd if=/dev/urandom of=/tmp/test-2m.bin bs=1M count=2
curl -v -X POST https://app.example.com/api/uploads \
  -H 'Content-Type: application/octet-stream' \
  --data-binary @/tmp/test-2m.bin 2>&1 | grep -iE '< HTTP|content-length'

# 5. Bracket the limit: binary-search the failing size
for mb in 1 2 5 10 60; do
  dd if=/dev/urandom of=/tmp/t.bin bs=1M count=$mb 2>/dev/null
  code=$(curl -s -o /dev/null -w '%{http_code}' -X POST https://app.example.com/api/uploads --data-binary @/tmp/t.bin)
  echo "${mb}MB -> $code"
done
📊 Production Insight
The byte count in error.log is the single most useful number in a 413 investigation. A seller's 'small video' that error.log reports as 23,068,672 bytes ends every debate about whether the file was really 5 MB.
🎯 Key Takeaway
Grep error.log for the rejection line, reproduce with curl using known file sizes, and bracket the effective limit before touching any config.

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.

BASH
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# Peel the chain from the inside out — first hop that succeeds is below the blocker
# 1. Direct to pod (bypasses CDN, LB, ingress, sidecar Nginx)
kubectl port-forward deploy/app 8080:8080 &
curl -s -o /dev/null -w 'direct-to-pod: %{http_code}\n' -X POST http://localhost:8080/api/uploads --data-binary @/tmp/test-20m.bin

# 2. Through ingress Nginx only (bypasses CDN + cloud LB)
curl -s -o /dev/null -w 'via-ingress: %{http_code}\n' -X POST https://internal.example.com/api/uploads --data-binary @/tmp/test-20m.bin

# 3. Full public path (every hop)
curl -s -o /dev/null -w 'public-url: %{http_code}\n' -X POST https://app.example.com/api/uploads --data-binary @/tmp/test-20m.bin

# 4. Check the ingress annotation that most teams forget
kubectl get ingress app -o jsonpath='{.metadata.annotations}' | tr ',' '\n' | grep -i body

# 5. Set it per-Ingress when the global default is too strict
kubectl annotate ingress app nginx.ingress.kubernetes.io/proxy-body-size=60m --overwrite
📊 Production Insight
Ingress annotations don't inherit your Nginx Deployment's config. The NGINX ingress controller defaults proxy-body-size to 1m independently, so a perfectly tuned sidecar still 413s behind a default ingress.
🎯 Key Takeaway
Test each hop in isolation by peeling layers inward. The effective upload limit is the minimum across CDN, load balancer, ingress, proxy, and app.

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.

app.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
const express = require('express');
const multer = require('multer');

const app = express();
// Keep in sync with nginx `client_max_body_size 60m` on /api/uploads.
// Nginx stays slightly larger so the app returns structured errors.
const JSON_LIMIT = '50mb';
const FILE_LIMIT = 50 * 1024 * 1024;

app.use('/api', express.json({ limit: JSON_LIMIT }));
app.use('/api', express.urlencoded({ limit: JSON_LIMIT, extended: true }));

const upload = multer({
  dest: 'uploads/',
  limits: { fileSize: FILE_LIMIT, files: 5 },
});

app.post('/api/uploads', upload.array('files', 5), (req, res) => {
  res.json({ ok: true, count: req.files.length });
});

// Structured 413s instead of a hung connection or bare Nginx page
app.use((err, req, res, next) => {
  if (err.type === 'entity.too.large' || err.code === 'LIMIT_FILE_SIZE') {
    return res.status(413).json({ error: 'File too large — max 50 MB per file' });
  }
  next(err);
});

app.listen(3000, () => console.log('limits: json=%s file=%d', JSON_LIMIT, FILE_LIMIT));
Try it live
🔥Express JSON Defaults to 100 KB
The 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.
📊 Production Insight
Multer errors that no middleware catches leave the client hanging until timeout, which pages on-call for a 'slow endpoint' that's actually a size rejection. Always register the error-handling middleware shown above.
🎯 Key Takeaway
Express parses bodies with its own limits after Nginx passes. Keep the app cap just under the Nginx cap so clients get your structured error, not a bare proxy page.

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.

src/main/resources/application.ymlYAML
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# Spring Boot: keep both values just under nginx client_max_body_size 60m
spring:
  servlet:
    multipart:
      enabled: true
      max-file-size: 50MB        # per-file cap
      max-request-size: 55MB     # whole-request cap (files + form fields)
      file-size-threshold: 1MB   # spill to disk above this, protects heap

---
# Django equivalent in settings.py (shown here as YAML for comparison):
# DATA_UPLOAD_MAX_MEMORY_SIZE = 50 * 1024 * 1024
# FILE_UPLOAD_MAX_MEMORY_SIZE = 50 * 1024 * 1024
# DATA_UPLOAD_MAX_NUMBER_OF_FIELDS = 2000

# Spring: turn the rejection into a clean 413 with the limit in the message
# @ExceptionHandler(MaxUploadSizeExceededException.class)
# public ResponseEntity<?> tooLarge() {
#   return ResponseEntity.status(413).body(Map.of("error", "File too large - max 50 MB"));
# }
📊 Production Insight
Spring's max-request-size covers the whole multipart envelope, not just files. Three 4 MB attachments plus metadata exceed a 10 MB default request cap even when each file is under its individual limit.
🎯 Key Takeaway
Spring pairs per-file and per-request caps; Django pairs memory-size and field-count caps. Set both just under the proxy limit and map rejections to clean 413 responses.

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.

uploads.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import boto3
from flask import Flask, request, jsonify

app = Flask(__name__)
s3 = boto3.client('s3')
BUCKET = 'uploads-prod'
# Small form posts still go through Nginx (60m cap); big files skip it entirely.
MAX_DIRECT_MB = 10

@app.route('/api/uploads/presign', methods=['POST'])
def presign():
    name = request.json['filename']
    size_mb = request.json['size_mb']
    if size_mb <= MAX_DIRECT_MB:
        return jsonify({'mode': 'direct-post', 'url': '/api/uploads'}), 200
    url = s3.generate_presigned_url(
        'put_object',
        Params={'Bucket': BUCKET, 'Key': f'raw/{name}',
                'ContentType': request.json.get('content_type', 'application/octet-stream')},
        ExpiresIn=900,  # 15-minute window to start the upload
    )
    return jsonify({'mode': 's3-direct', 'url': url, 'max_mb': 5000}), 200

@app.route('/api/uploads/complete', methods=['POST'])
def complete():
    # Client calls this after the S3 PUT succeeds; validate size + virus-scan here.
    key = request.json['key']
    head = s3.head_object(Bucket=BUCKET, Key=key)
    return jsonify({'ok': True, 'bytes': head['ContentLength']}), 200
📊 Production Insight
Presigned-URL uploads remove your app tier from the data path, so a viral launch day with 10,000 concurrent video uploads scales S3-side while your Nginx fleet sees only tiny JSON authorize calls.
🎯 Key Takeaway
Above ~10 MB per file, switch to chunked or presigned-URL uploads. Your proxy limits then cover only small posts, and the 413 class shrinks to a non-event.
● Production incidentPOST-MORTEMseverity: high

The 22 MB Product Video That Blocked Seller Uploads for 6 Hours

Symptom
Starting at 10:04 AM on launch day, 100% of product video uploads failed with a bare 413 page. Photo uploads under 1 MB worked fine, which made it look like a video-transcoding bug. The app's error tracker recorded zero exceptions across 340 failed attempts because no request ever reached the app servers. Support received 58 tickets in the first 3 hours, and seller onboarding stalled at 12% of the day's target.
Assumption
The team assumed the bug was in the new video pipeline — the transcoder, the S3 presigned-URL flow, or the 50 MB validation they'd added to the React form. Two engineers spent 4 hours adding logging to the upload handler and redeployed it twice. Nobody checked Nginx because 'we raised the limit last sprint' — but that change had gone to the staging ingress only, and the production deploy used a base config that still carried the 1m default.
Root cause
Production Nginx had client_max_body_size 1m in the base http block. The React form allowed 50 MB, the CDN allowed 100 MB, and the Express API allowed 60 MB — but Nginx rejected anything over 1 MB at the edge with a 413 before proxying. Grep of error.log later showed 340 lines of 'client intended to send too large body: 23068672 bytes' in the 6-hour window. The staging fix had never been promoted because the ingress annotation lived in an overlay file that the production deploy didn't apply.
Fix
Three changes shipped together. First, client_max_body_size 60m was set in the production server block (not the http block, so internal endpoints kept the strict default). Second, the same 60 MB value was codified in the CDN config, the Express json limit, and Multer's fileSize as a single documented constant with a comment pointing at the Nginx line. Third, uploads over 10 MB were moved to S3 presigned-URL direct upload so large files bypass Nginx entirely — the 60 MB cap now only covers small form posts, and the incident class can't recur at scale.
Key lesson
  • 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.
Production debug guideFive checks that pinpoint exactly which hop rejected the upload — run them in order before changing any limit.5 entries
Symptom · 01
Uploads fail with 413 but your app logs show nothing at all
→
Fix
Confirm Nginx rejected it at the edge by searching the error log for the exact rejection line: grep 'client intended to send too large body' /var/log/nginx/error.log | tail -20. Each line includes the byte size Nginx saw, e.g. '23068672 bytes'. If these lines exist with timestamps matching the failures, your app is innocent — stop debugging application code and inspect Nginx limits with nginx -T | grep -i client_max_body.
Symptom · 02
You need to know what size the client actually sent versus your limit
→
Fix
Reproduce with curl and compare Content-Length to the configured cap: curl -v -X POST https://your-app.example.com/upload -H 'Content-Type: application/octet-stream' --data-binary @testfile.bin 2>&1 | grep -iE 'content-length|< HTTP'. If Content-Length exceeds client_max_body_size, you've found the mismatch. Verify the effective limit with nginx -T 2>/dev/null | grep -B 3 -A 1 'client_max_body_size' to see which block (http, server, or location) actually applies to the upload path.
Symptom · 03
Raising the Nginx limit didn't help — a different hop is still rejecting
→
Fix
Walk the proxy chain hop by hop, bypassing each layer. Test the app directly (kubectl port-forward or curl to the upstream IP), then through Nginx, then through the CDN/LB: curl -s -o /dev/null -w '%{http_code} %{size_upload}\n' -X POST $DIRECT_UPSTREAM --data-binary @bigfile.bin versus the public URL. The first hop in the chain that returns 413 is the one whose limit you missed — commonly the ingress annotation, the ALB (30 MB fixed for some paths), or Cloudflare's 100 MB free-plan cap.
Symptom · 04
Small JSON payloads pass but multipart file uploads fail at the same size
→
Fix
Check the framework-level body parser, which enforces its own cap after Nginx passes the request. For Express, log the configured limits at startup: node -e "console.log(require('./app').limits)" or grep your code for 'limit:' and 'fileSize'. For Spring, run grep -r 'max-file-size\|max-request-size' src/main/resources/ and for Django grep -r 'DATA_UPLOAD_MAX' . — a 413-style failure from the framework (often a 400/413 from Multer or MultipartException) means Nginx is fine and the app cap is the blocker.
Symptom · 05
You need to prove the fix works and catch regressions before users do
→
Fix
After raising limits, verify end-to-end with a file just under the new cap and monitor the 413 rate: curl -s -o /dev/null -w '%{http_code}\n' -X POST https://your-app.example.com/upload --data-binary @59mb-testfile.bin should return 2xx, and grep -c 'client intended to send too large body' /var/log/nginx/error.log tracked in your monitoring (a Prometheus log-exporter or a simple cron counting into CloudWatch) alerts you the moment real users start hitting the new ceiling.
413 Root Causes — How to Confirm and Fix Each One
Root CauseHow to ConfirmFixPrevention
Nginx client_max_body_size default (1m)error.log shows 'client intended to send too large body' with byte countSet client_max_body_size 60m on the upload location block and reloadCodify the value in config management and assert it in deploy checks
Ingress or LB hop with its own capPeel layers with curl — direct-to-pod succeeds while public URL 413sSet proxy-body-size annotation and align CDN/LB limits to the same valueDocument every hop's limit in one runbook table reviewed quarterly
Express 100 KB JSON default or Multer fileSize capentity.too.large or LIMIT_FILE_SIZE errors in Node logs after Nginx passesPass explicit limit options and add 413 error-handling middlewareLog configured limits at startup and alert when requests approach them
Spring max-file-size/max-request-size or Django DATA_UPLOAD_MAX defaultsMultipartException or SuspiciousOperation in app logs for mid-size filesRaise both caps just under the proxy value and map to clean 413 responsesLoad-test uploads at 2x the advertised max before every launch
Architecture mismatch — huge files through a proxyUploads near the cap are slow, retry badly, and saturate proxy memoryMove files over 10 MB to chunked or S3 presigned-URL direct uploadMake presigned URLs the default path for all new file features
⚙ Quick Reference
6 commands from this guide
FileCommand / CodePurpose
etcnginxsites-availableapp.confhttp {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.jsconst express = require('express');Express and Multer Caps That Reject After Nginx Passes
srcmainresourcesapplication.ymlspring:Spring Multipart and Django Caps in JVM and Python Stacks
uploads.pyfrom flask import Flask, request, jsonifyStop Raising Limits

Key takeaways

1
Nginx's 1 MB default rejects bodies before your app runs
error.log names the exact byte size.
2
The effective limit is the minimum across CDN, load balancer, ingress, proxy, and framework.
3
Confirm with curl and known file sizes before changing config; bracket the failing size.
4
Keep app-framework caps just under the proxy cap so clients get structured errors.
5
Spring and Django both pair per-file and per-request caps
raise them together.
6
Files over ~10 MB belong on chunked or presigned-URL uploads, not bigger proxy limits.

Common mistakes to avoid

5 patterns
×

Raising only the Nginx limit while the ingress still enforces 1m

Symptom
Uploads keep 413ing after the Nginx fix deploys, with an identical-looking error page served by a different layer.
Fix
Set the NGINX ingress proxy-body-size annotation to the same value and verify with kubectl get ingress -o jsonpath, then re-test the full public path.
×

Debugging application code for a rejection that happened upstream

Symptom
Hours spent adding logging to an upload handler that shows zero invocations, while error.log already names the cause.
Fix
Always grep error.log for 'client intended to send too large body' first — a match ends the app-code investigation in under a minute.
×

Forgetting Express's 100 KB JSON default on API payloads

Symptom
File uploads work but a 2 MB JSON POST fails with PayloadTooLargeError even though Nginx allows 60 MB.
Fix
Pass an explicit limit to express.json() and express.urlencoded(), sized just under the Nginx location value.
×

Setting only Spring's max-file-size and ignoring max-request-size

Symptom
Single files pass but multi-attachment forms fail with MultipartException at the same per-file size.
Fix
Raise max-request-size to cover all files plus fields together, and handle MaxUploadSizeExceededException as a 413.
×

Relying on client-side file-size checks as enforcement

Symptom
The form blocks big files but API clients and curl still trigger 413s or, worse, bypass validation entirely.
Fix
Treat client checks as UX only; enforce every limit server-side on each hop and return the real cap in 413 messages.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01JUNIOR
A file upload returns 413 but your application logs show nothing. Where ...
Q02SENIOR
You raised Nginx's client_max_body_size but uploads still fail with 413....
Q03SENIOR
Why should the app framework's body limit sit just under the proxy's lim...
Q04SENIOR
Explain how Spring Boot's max-file-size and max-request-size interact fo...
Q05SENIOR
At what point is raising the upload limit the wrong fix, and what archit...
Q01 of 05JUNIOR

A file upload returns 413 but your application logs show nothing. Where do you look first and why?

ANSWER
The web server's error.log, because a 413 with zero app logs means the rejection happened upstream of the code — typically Nginx's client_max_body_size firing before proxying. Grep for 'client intended to send too large body' to get the exact byte size, compare it to the configured limit, and only then check downstream hops. Starting in app code wastes hours on a handler that never ran.
FAQ · 6 QUESTIONS

Frequently Asked Questions

01
Why does my app log nothing when uploads fail with 413?
02
What's the difference between 413 and 400 for a bad upload?
03
I raised client_max_body_size globally and uploads still fail. Why?
04
Should the Nginx limit equal the app framework limit?
05
How do I support 500 MB video uploads without destabilizing Nginx?
06
Does client-side file-size validation prevent 413 errors?
N
Naren Founder & Principal Engineer

20+ years shipping production infrastructure and CI/CD at scale. Everything here is grounded in real deployments.

Follow
✓ Verified
production tested
September 23, 2026
last updated
1,942
articles · all by Naren
🔥

That's Networking. Mark it forged?

6 min read · try the examples if you haven't

←
Previous
Kubernetes Unbound PVC Fix
2 / 5 · Networking
Next
x509 Signed by Unknown Authority Fix
→