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

  1. Root or sudo on the nginx host, or edit rights on your ingress manifests.
  2. nginx 1.7.11 or newer — proxy_request_buffering does not exist before that.
  3. Node 20+ if you are terminating the upload in a JavaScript handler.
  4. 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.

Default request-body ceilings on the path from browser to handler Six stacked rows listing each hop between browser and application, its default request body ceiling, and the error the client sees when that ceiling is crossed. Every ceiling between the file picker and your handler Hop Default ceiling What the client sees Browser fetch none sends all 6 GB, then fails Cloudflare edge 100 MB (Free/Pro) 413, server: cloudflare nginx 1 MB 413 Request Entity Too Large Node http server none socket cut at requestTimeout express.json() 100 kB PayloadTooLargeError API Gateway 10 MB (hard) 413, not configurable
Only two of these six ceilings are yours to raise; the crimson rows are fixed by the platform and must be routed around.

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 6g is compared against the Content-Length header before the body is read, so nginx can answer 413 on the first packet. If the client sends Transfer-Encoding: chunked with no length, nginx enforces the same number as bytes arrive and aborts mid-stream. 0 disables the check completely — useful behind an authenticated gateway, dangerous on a public route.
  • location = /api/upload uses the exact-match modifier. A prefix location /api/ would also cover /api/login, which is exactly the blast radius you are trying to avoid.
  • client_body_buffer_size 512k is the in-memory window. Exceed it with buffering on and nginx writes the body to client_body_temp_path (default /var/lib/nginx/body), logging a warning per request.
  • proxy_request_buffering off hands 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 300s is a per-read timeout, not a wall-clock budget. A client dribbling 1 byte every 299 seconds never trips it — pair it with limit_req or an application-level byte-rate check if slow-loris uploads are a concern.
  • proxy_http_version 1.1 with Connection "" is mandatory with buffering off. Under the default HTTP/1.0 the upstream request has no Content-Length and 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.

Buffered versus streamed request bodies through nginx With buffering on the body is written to a temporary file first and the application sees its first byte only after the upload completes; with buffering off the application receives bytes immediately. Time to your handler's first byte buffering on client body → nginx temp file replay → app handler wakes at t+27 min buffering off client → nginx → app, no disk handler wakes at t+0 0 min 27 min 45 min
Buffering on costs one full upload duration and one full copy on disk before your code runs.

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:

  1. Upload straight to object storage. A presigned PUT to bucket.s3.eu-west-1.amazonaws.com never 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.
  2. 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.
  3. Grey-cloud the upload hostname. Set uploads.example.com to DNS-only. You lose the edge’s DDoS protection and WAF on that hostname, which is usually a worse deal than option 1.
Proxied upload path versus direct-to-storage upload path The top path sends the file through the Cloudflare edge and the origin, where a 100 megabyte cap applies; the bottom path fetches only a signed URL through the edge and sends the file body straight to object storage. Through your zone — capped Browser Cloudflare nginx origin Object store 413 once the body > 100 MB Direct to storage — uncapped Browser sign (2 kB) API via edge Object store PUT 5 GB — never enters your zone
Only the 2 kB signing request needs the edge; the file body takes a path where the plan cap does not exist.

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.