Playbook: nginx-proxy client_max_body_size — fix 413 on Pinbox uploads (bms-1)

Status: ACTIVE Server: bms-1 (94.23.26.113) — Pinbox24 production (P0 — handle with care) Container: nginx-proxy (nginxproxy/nginx-proxy:latest) First applied: 2026-07-12 (issue #3986)

Symptom

Uploads to a Pinbox vhost fronted by nginx-proxy fail with HTTP 413 Request Entity Too Large for bodies larger than 1 MB. The 413 is emitted by nginx before the request reaches the backend app. Typical trigger: the n8n W4 Direct API Ingestion workflow (MUjApqruo6H88ebw) POSTing invoice PDFs (~2–3 MB) to POST https://api.w4.pinbox24.com/api/offices/files/upload.

nginx-proxy error log shows:

client intended to send too large body: 3035792 bytes, server: api.w4.pinbox24.com

Root cause

nginx-proxy sets no client_max_body_size directive, so nginx uses its built-in default of 1 MB (1m = 1,048,576 bytes). Any upload over 1 MB is rejected at the proxy layer.

Confirm the default is in effect (should print nothing):

# on bms-1 as root
docker exec nginx-proxy grep -rns client_max_body_size /etc/nginx/

Why a global conf.d snippet (not a per-vhost vhost.d/<host> file)

nginx-proxy’s config layout on bms-1 uses host bind mounts under /docker/vol/etc/nginx/ (certs, conf.d, vhost.d), so anything written there persists across container recreation. Two candidate mechanisms exist:

ApproachPersistsNeeds reload onlyCompanion-safeNotes
conf.d/*.conf global snippet (chosen)http-context; applies to all vhosts (a ceiling, harmless)
vhost.d/<host> per-vhost file❌ needs docker-gen regenerationsee caveats below

The per-vhost route has two traps on this host:

  1. Either/or include. nginx-proxy emits either include /etc/nginx/vhost.d/<host>; or include /etc/nginx/vhost.d/default; — never both. The generated conf.d/default.conf currently references default. Creating a new vhost.d/api.w4.pinbox24.com file only takes effect after docker-gen regenerates default.conf (a Docker container event), which is more invasive on a P0 host. You would also have to copy vhost.d/default’s letsencrypt ACME block into the new file to avoid dropping it.
  2. Companion clobber. The jrcs/letsencrypt-nginx-proxy-companion sidecar owns vhost.d/default and may rewrite it, discarding any directive appended there.

The conf.d/*.conf snippet avoids both: nginx.conf has include /etc/nginx/conf.d/*.conf; at http context; docker-gen only rewrites default.conf (never other .conf files); the companion never touches conf.d. A single graceful reload applies it.

A single 50m ceiling for all vhosts is acceptable — it is a maximum, not a reservation, and it also pre-empts the same latent 413 on w3.pinbox24.com.

Fix

# 1. Write the snippet into the conf.d bind mount (persists across recreation)
CONF=/docker/vol/etc/nginx/conf.d/client_max_body_size.conf
cat > "${CONF}.tmp" <<'EOF'
# Issue #3986 — raise upload body limit (nginx default 1m rejected ~3MB W4 invoice PDFs with 413).
# Global (http context); applies to all nginx-proxy vhosts. Persisted via /docker/vol bind mount.
# Survives docker-gen regen (only rewrites default.conf) and letsencrypt-companion writes (own vhost.d/default).
client_max_body_size 50m;
EOF
mv "${CONF}.tmp" "$CONF" && chmod 600 "$CONF"
 
# 2. Validate BEFORE reloading (ssl_stapling OCSP warnings are pre-existing / harmless)
docker exec nginx-proxy nginx -t
 
# 3. Graceful reload (no restart, no downtime)
docker exec nginx-proxy nginx -s reload
 
# 4. Confirm the directive is live in the running config
docker exec nginx-proxy nginx -T 2>/dev/null | grep client_max_body_size

Tune 50m to the largest legitimate upload with margin.

Verify (proxy-layer, no credentials)

# on bms-1 — send bodies of known size straight at the local nginx for the vhost
UP="https://api.w4.pinbox24.com/api/offices/files/upload"
RES="--resolve api.w4.pinbox24.com:443:127.0.0.1"
 
head -c 2000000  /dev/zero | tr '\0' 'A' > /tmp/blob2m.txt   # 2 MB
curl -sk $RES -o /dev/null -w '%{http_code}\n' -X POST -F "file=@/tmp/blob2m.txt" "$UP"
# Expect: NOT 413 (401/400/404 from backend = body accepted by nginx). Pre-fix: 413.
 
head -c 60000000 /dev/zero | tr '\0' 'A' > /tmp/blob60m.txt  # 60 MB
curl -sk $RES -o /dev/null -w '%{http_code}\n' -X POST -F "file=@/tmp/blob60m.txt" "$UP"
# Expect: 413 (above the 50m ceiling — proves the directive governs this vhost).
 
rm -f /tmp/blob2m.txt /tmp/blob60m.txt

Then re-run the real workflow (MUjApqruo6H88ebw) or wait for the :20 trigger and confirm the upload_* nodes return 200 and no new too large body lines appear in docker logs nginx-proxy.

Note — upstream app body limit

An unauthenticated proxy-layer probe returns 401 (auth middleware runs before the body parser), so it cannot confirm whether the W4 Node app imposes its own smaller express/multer limits.fileSize. Issue #3986’s evidence attributes the 413 to nginx-proxy specifically (server: api.w4.pinbox24.com in the nginx error log), so the nginx fix resolves the reported failure. If a legitimately-authenticated upload still fails on size after this fix, inspect the W4 app’s multer/body-parser limits next.

Rollback

rm /docker/vol/etc/nginx/conf.d/client_max_body_size.conf
docker exec nginx-proxy nginx -t && docker exec nginx-proxy nginx -s reload
# nginx reverts to the built-in 1m default.
  • docs/playbooks/nginx-proxy-upgrade-jwilder-to-nginxproxy.md — nginx-proxy layout, bind mounts, image swap
  • Issue #3986 — original 413 report + n8n workflow context