Raising Nginx and Cloudflare Upload Size Limits
A large upload dies at the first hop whose request-body ceiling is lower than the file, so raise client_max_body_size on the exact nginx location, turn proxy_request_buffering off so nothing is staged on disk, and route anything over 100 MB around Cloudflare’s proxy entirely — because that one is a plan limit, not a config option.
This article belongs to handling large file size limits inside upload fundamentals and browser APIs. It deals only with the infrastructure between the browser and your handler; the client-side strategy for very large files lives in best practices for handling 500MB file uploads.
When to use this approach
- Uploads fail with a 413 that your application never logged, so the rejection happened upstream of your code.
- You proxy file bodies through your own servers rather than sending them to object storage with presigned URLs, and you have accepted that trade-off.
- Your files stay under the edge cap. Above roughly 100 MB on a proxied hostname, no amount of nginx tuning helps and you need the direct-to-storage path instead.
Prerequisites
- Root or
sudoon the nginx host, or edit rights on your ingress manifests. - nginx 1.7.11 or newer —
proxy_request_bufferingdoes not exist before that. - Node 20+ if you are terminating the upload in a JavaScript handler.
- A test file you can regenerate at any size:
head -c 150M /dev/zero > /tmp/150m.bin.
Find the ceiling that is actually firing
Do not guess. Grow the body until something rejects it and read the server response header to learn which hop said no.
#!/usr/bin/env bash
# probe-limits.sh — find the first hop that refuses a body of each size.
URL="https://uploads.example.com/api/upload"
for mb in 1 10 50 90 150; do
hdr=$(mktemp)
code=$(head -c "$((mb * 1024 * 1024))" /dev/zero \
| curl -s -o /dev/null -D "$hdr" -w '%{http_code}' \
-X POST --data-binary @- \
-H 'Content-Type: application/octet-stream' "$URL")
server=$(grep -i '^server:' "$hdr" | tr -d '\r')
printf '%4s MB -> %s %s\n' "$mb" "$code" "$server"
rm -f "$hdr"
done
Before any tuning, that prints something like:
1 MB -> 201 server: nginx/1.24.0
10 MB -> 413 server: nginx/1.24.0
50 MB -> 413 server: nginx/1.24.0
90 MB -> 413 server: nginx/1.24.0
150 MB -> 413 server: cloudflare
The server header is the whole trick: nginx/1.24.0 means your origin rejected it and the fix is in your config; cloudflare (usually with a cf-ray header alongside) means the edge never forwarded the request and no origin change will move it.
Implementation
Raise the limit on the upload route only. A global client_max_body_size 6g; means any handler on the box will happily read six gigabytes into a temp file when someone POSTs junk at /api/login.
# /etc/nginx/conf.d/uploads.conf
server {
listen 443 ssl;
http2 on; # nginx >= 1.25.1 syntax
server_name uploads.example.com;
ssl_certificate /etc/ssl/certs/uploads.pem;
ssl_certificate_key /etc/ssl/private/uploads.key;
# Tight default for the whole vhost. Everything not listed below stays small.
client_max_body_size 1m;
location = /api/upload {
client_max_body_size 6g; # the ceiling for THIS route only
client_body_buffer_size 512k; # anything larger would spill to disk...
proxy_request_buffering off; # ...but this stops the spill entirely
client_body_timeout 300s; # gap allowed BETWEEN reads, not total
proxy_http_version 1.1; # required: chunked body to the upstream
proxy_set_header Connection ""; # keepalive to the upstream pool
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_send_timeout 300s; # writing the body upstream
proxy_read_timeout 300s; # waiting for the upstream response
proxy_pass http://127.0.0.1:3000;
}
location / {
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_pass http://127.0.0.1:3000;
}
}
Reload with sudo nginx -t && sudo systemctl reload nginx. A reload is enough; in-flight uploads on old workers finish before those workers exit.
Line by line
client_max_body_size 6gis compared against theContent-Lengthheader before the body is read, so nginx can answer 413 on the first packet. If the client sendsTransfer-Encoding: chunkedwith no length, nginx enforces the same number as bytes arrive and aborts mid-stream.0disables the check completely — useful behind an authenticated gateway, dangerous on a public route.location = /api/uploaduses the exact-match modifier. A prefixlocation /api/would also cover/api/login, which is exactly the blast radius you are trying to avoid.client_body_buffer_size 512kis the in-memory window. Exceed it with buffering on and nginx writes the body toclient_body_temp_path(default/var/lib/nginx/body), logging a warning per request.proxy_request_buffering offhands bytes to the upstream as they arrive. This is the single change that turns a 6 GB upload from “3 GB of temp files and a 60-second delay before your handler runs” into a straight pipe.client_body_timeout 300sis a per-read timeout, not a wall-clock budget. A client dribbling 1 byte every 299 seconds never trips it — pair it withlimit_reqor an application-level byte-rate check if slow-loris uploads are a concern.proxy_http_version 1.1withConnection ""is mandatory with buffering off. Under the default HTTP/1.0 the upstream request has noContent-Lengthand no chunked framing, and your handler receives an empty body.
On Kubernetes the same two settings are ingress annotations rather than a config file:
metadata:
annotations:
nginx.ingress.kubernetes.io/proxy-body-size: "6g" # "0" disables the check
nginx.ingress.kubernetes.io/proxy-request-buffering: "off"
nginx.ingress.kubernetes.io/client-body-buffer-size: "512k"
nginx.ingress.kubernetes.io/proxy-read-timeout: "300"
nginx.ingress.kubernetes.io/proxy-send-timeout: "300"
Streaming instead of staging on disk
With request buffering on — the default — nginx reads the entire body before it opens a connection to your upstream. For a 4 GB upload on a 20 Mbps link that is 27 minutes of writing to /var/lib/nginx/body while your application sits idle, followed by a fast local replay. Two things break as a result: the disk fills (each concurrent upload holds its own full copy), and your handler cannot report progress or reject a bad file early because it has not seen a single byte.
The honest trade-off: with buffering off, nginx can no longer retry the request against another upstream, because the bytes are already gone. proxy_next_upstream silently stops applying to the body. If your upstream pool relies on nginx retrying a failed backend, you are trading that safety net for streaming — and you should push retries into the client instead, using the pattern in implementing exponential backoff for failed chunks.
The Cloudflare ceiling you cannot configure away
Cloudflare’s proxy caps the request body per plan — 100 MB on Free and Pro, 200 MB on Business, 500 MB on Enterprise by default. This is enforced at the edge, before your origin is contacted, and there is no dashboard toggle on the lower plans. It applies to every proxied (orange-cloud) hostname, to traffic through Cloudflare Tunnel, and to requests that hit a Worker. A Worker cannot raise it either: the request is rejected before your fetch handler runs.
Three ways out, in order of how often they are the right answer:
- Upload straight to object storage. A presigned PUT to
bucket.s3.eu-west-1.amazonaws.comnever touches your zone, so no edge cap applies and S3’s own single-PUT ceiling of 5 GiB is the only one left. The performance case for this is measured in direct S3 uploads vs proxy uploads. - Chunk below the cap. Split the file client-side into parts smaller than the limit and reassemble server-side. Each part is an independent request, so the edge never sees a body over 100 MB.
- Grey-cloud the upload hostname. Set
uploads.example.comto DNS-only. You lose the edge’s DDoS protection and WAF on that hostname, which is usually a worse deal than option 1.
One trap specific to Cloudflare R2: uploads to the S3-compatible endpoint <account>.r2.cloudflarestorage.com are not subject to the plan cap, but the moment you attach a custom domain to that bucket the traffic is proxied through your zone and the 100 MB limit comes back. Keep the upload endpoint on the storage hostname and serve downloads from the custom domain.
Application-layer caps: Node, Express, API Gateway
Node’s http server enforces no body-size limit at all — it will read bytes until you stop it or the disk fills. What it does enforce is server.requestTimeout, which has defaulted to 300 000 ms since Node 18. A 5 GB upload over a slow link crosses that and Node destroys the socket; the browser reports a network error rather than an HTTP status, which is why this one is so often misdiagnosed as a client bug. The same misdiagnosis pattern shows up in fixing XMLHttpRequest timeout errors for large files.
import express from "express";
import { createWriteStream } from "node:fs";
import { Transform } from "node:stream";
import { pipeline } from "node:stream/promises";
const MAX_BYTES = 6 * 1024 * 1024 * 1024; // must match client_max_body_size
const app = express();
// Keep the JSON parser tiny. It only engages on application/json bodies,
// so an octet-stream upload flows past it untouched.
app.use(express.json({ limit: "100kb" }));
/** Fails the pipeline as soon as the byte budget is exceeded. */
function byteLimit(max: number): Transform {
let seen = 0;
return new Transform({
transform(chunk: Buffer, _enc, cb) {
seen += chunk.length;
if (seen > max) {
cb(Object.assign(new Error("request entity too large"), { code: "ETOOLARGE" }));
return;
}
cb(null, chunk);
},
});
}
app.put("/api/upload/:key", async (req, res) => {
const declared = Number(req.headers["content-length"]);
if (Number.isFinite(declared) && declared > MAX_BYTES) {
// Reject on the header, before reading a single byte of the body.
res.status(413).json({ error: "entity.too.large", limit: MAX_BYTES });
return;
}
try {
await pipeline(req, byteLimit(MAX_BYTES), createWriteStream(`/data/${req.params.key}`));
res.status(201).json({ key: req.params.key });
} catch (err) {
const code = (err as { code?: string }).code;
res.status(code === "ETOOLARGE" ? 413 : 500).json({ error: code ?? "upload_failed" });
}
});
const server = app.listen(3000);
server.requestTimeout = 30 * 60 * 1000; // 30 min; Node's default 300 s is too short
server.headersTimeout = 60 * 1000; // keep the header phase short
express.json({ limit: "100kb" }) throws PayloadTooLargeError: request entity too large with err.type === "entity.too.large" and err.status === 413 — but only for JSON bodies. Raising that limit to "6gb" to “fix” an upload is a classic wrong turn: it buffers the whole payload into memory and does nothing for application/octet-stream or multipart/form-data, which body-parser ignores. Multipart bodies are handled by a separate parser, covered in multipart form data explained.
AWS API Gateway is the hard case. Both REST and HTTP APIs cap the payload at 10 MB (10 485 760 bytes) and it is not an adjustable quota. Over the limit you get 413 with the body {"message":"Request Entity Too Large"} and nothing in your Lambda’s logs. An ALB in front of Lambda is stricter still at 1 MB. There is no configuration answer here — issue a presigned URL from the Lambda and let the browser upload to storage directly, the pattern weighed up in presigned URL vs server proxy trade-offs.
Configuration gotchas
The browser uploads the whole file before seeing the 413. curl sends Expect: 100-continue for bodies over 1 kB, so nginx can answer HTTP/1.1 413 Request Entity Too Large immediately. Browsers never send that header, so fetch streams all 3 GB, then reads the rejection — or gets a reset first. Validate file.size client-side against a limit the server also enforces; it is the only way to fail fast in a browser.
net::ERR_CONNECTION_RESET instead of a clean 413. When nginx rejects a body it still has an unread socket full of data. Its lingering_close logic drains for lingering_time (default 30s) and gives up; the browser sees a reset and fetch rejects with TypeError: Failed to fetch, with no status code to branch on. The origin log tells the truth:
2026/07/26 11:04:22 [error] 1183#1183: *42 client intended to send too large
body: 268435456 bytes, client: 203.0.113.7, server: uploads.example.com,
request: "POST /api/upload HTTP/1.1", host: "uploads.example.com"
A [warn] about temp files means buffering is still on. If you see this, proxy_request_buffering off is not applying to the location that handled the request:
2026/07/26 11:07:51 [warn] 1183#1183: *61 a client request body is buffered to a
temporary file /var/lib/nginx/body/0000000003, client: 203.0.113.7, ...
A [crit] variant — open() "/var/lib/nginx/body/0000000001" failed (13: Permission denied) — means the worker user cannot write to client_body_temp_path and every upload returns 500.
The wrong client_max_body_size wins. The directive is inherited, and the most specific matching location block takes precedence — not the largest value. Two location /api/ blocks, or a client_max_body_size inside an if, and you will chase a phantom. sudo nginx -T | grep -n -B4 client_max_body_size prints the fully resolved configuration with the context around each occurrence.
Slow bodies die at client_body_timeout, and it is not a total. nginx measures the gap between two successive reads, defaulting to 60s. Raising it to 300s does not give a client a five-minute upload budget; it gives them five minutes of silence per read. A stalled mobile connection produces client timed out (110: Connection timed out) while reading client request body and a 408, which the browser again may surface as a bare network failure.
Verification
Prove the resolved config, then prove the path end to end:
# 1. Which value actually applies, and in which context?
sudo nginx -T 2>/dev/null | grep -n -B4 'client_max_body_size'
# 2. Push a body just over the old ceiling and read the status + serving hop.
head -c 150M /dev/zero > /tmp/150m.bin
curl -s -o /dev/null -D - -X POST \
--data-binary @/tmp/150m.bin \
-H 'Content-Type: application/octet-stream' \
https://uploads.example.com/api/upload | grep -Ei '^(HTTP/|server:|cf-ray:)'
# Expect on success: HTTP/2 201 server: nginx/1.24.0
# Still capped at the edge: HTTP/2 413 server: cloudflare cf-ray: ...
# 3. Confirm nothing is staged on disk while the upload runs.
watch -n1 'ls -l /var/lib/nginx/body 2>/dev/null | wc -l' # stays at 1
Re-running probe-limits.sh after the change should now show 201 for every size below the edge cap, with the first 413 coming from server: cloudflare rather than nginx. If a size still fails at the origin, step 1 will show you which block claimed the request. For files above the cap, move to the chunked approach described in multipart vs single-PUT for files under 100MB and make sure the storage bucket’s CORS rules are in place first — see fixing CORS preflight errors on S3 uploads.
Frequently Asked Questions
Why does my upload still fail after setting client_max_body_size to 6g?
Either another hop is rejecting it — check whether the server response header says cloudflare rather than nginx — or a more specific location block is overriding your value. Run sudo nginx -T | grep -n -B4 client_max_body_size to see the fully resolved configuration rather than the file you edited.
Can I raise Cloudflare’s 100 MB body limit on the Free plan?
No. It is a plan-level limit enforced at the edge, with no dashboard or API setting to change it, and a Worker in front cannot intercept the request either. Send bodies over the cap directly to object storage, or split them into parts smaller than the limit.
Is client_max_body_size 0 safe?
It disables the size check entirely, which is reasonable on an internal route behind authentication but reckless on a public one — a single client can then fill your disk or your upstream’s memory. Prefer an explicit ceiling on the specific upload location and keep the vhost default small.
Does proxy_request_buffering off break anything?
It stops nginx retrying the request against another upstream, because the body has already been forwarded, and it requires proxy_http_version 1.1 so the body is framed correctly. In exchange your handler sees the first byte immediately and nothing is written to /var/lib/nginx/body.
Why does the browser show a network error instead of a 413?
The browser sends the whole body before the rejection arrives, and nginx often resets the connection while draining the unsent remainder. fetch surfaces that as TypeError: Failed to fetch with no status. Enforce the size limit client-side too so the request is never started.