Works locally, fails live
File and image uploads broken in production
Uploads are the feature most likely to pass every local test and still fail on launch day. On your machine there is no proxy in front of the app, no execution clock, and no separate storage origin. On a production host every one of those appears at once. So the avatar upload that worked all week now returns a 413, stalls at 90 per cent, or succeeds and serves a broken image. The causes below are specific and mechanical, and every one of them is findable.
Fixed £4,500 · 3 days · pass-or-refund · next slot w/c 3 August
What actually breaks
- The 413 you never saw locallyYour local dev server accepts any request body. Production does not. Vercel functions cap the request body at 4.5MB and return a 413 for anything bigger. Next.js server actions default to 1MB. A default nginx install rejects bodies over 1MB before your code runs at all. The upload dies at the platform edge, your application logs show nothing, and the browser reports a bare 413 or a failed fetch. Anything beyond a small avatar is over at least one of these limits.
- The file goes through your function instead of past itThe generated route reads the whole file into your function, then pushes it to storage, so every limit the function has becomes a limit on uploads: the body cap, the memory ceiling, the execution clock. Fixing one limit just promotes the next one to the front. The correct shape is the reverse: your server grants permission with a presigned URL, the browser sends the file straight to the bucket, and your function never touches the bytes.
- Timeouts are a different failure from the 413A 40MB file on hotel wifi transfers for minutes, and if it travels through a function, the whole transfer runs on the function's clock. Where that clock is short the connection dies mid-file: AWS API Gateway's integration timeout defaults to 29 seconds. On Vercel the default duration is 300 seconds, so a big upload hits the 4.5MB body cap long before any timeout. Learn the symptoms apart: an instant 413 is the cap, a long stall ending in a 504 is the clock.
- The bucket has never heard of your domainA presigned upload goes straight from the browser to the storage bucket, which is a different origin from your app, so the bucket's own CORS rules decide whether it is allowed. If those rules do not list your production origin, the upload never starts and nothing reaches your server logs; the only evidence is a browser console error. A presigned POST form upload fails differently again: the file lands in the bucket, but your code cannot read the response, so the app records a failure against an object that exists.
- Nobody checks what the file actually isThe endpoint trusts the Content-Type the browser sends, which the uploader controls, and enforces no size cap of its own. Anyone can store anything under your domain. The sharpest case is an SVG: an image by extension, a script container by design. If one is served inline from your origin, the script inside it runs as your site, against your logged-in users. Real validation checks the file's magic bytes on the server and serves untrusted uploads as downloads, never as pages.
- Every upload is public to anyone with the linkThe quickest way to make images render without wiring up permissions is to make the bucket public, and generated code takes the quickest way. Now every file any user has uploaded is readable by anyone who obtains its URL: ID documents, invoices, private photos. S3 has blocked public access on new buckets by default since April 2023 precisely because this keeps happening. A private bucket plus signed download URLs with an expiry closes the leak.
The shape production uploads are supposed to have
Production uploads have a standard shape. The browser asks your server for permission to upload. The server checks who is asking, checks the declared file type and size, and returns a short-lived presigned URL scoped to a single object key. The browser sends the file directly to the bucket with that URL; the bytes never pass through your function, so body limits and execution clocks stop mattering. The server records the key once the upload confirms, and downloads go out through signed URLs or a CDN, never the raw bucket. Every failure on this page is either a symptom of skipping this shape or a gap this shape closes.
What “done” looks like
We agree one of these in writing before you pay anything. If it passes, the sprint passed. If it doesn't, you get a full refund and keep the work.
- A file well over your platform's body limit uploads from a slow connection without a 413 or a timeout, because it goes direct to storage.
- In the browser, uploads run only from pages on your production origin, and the bucket itself accepts nothing without a valid, unexpired signature.
- A scripted SVG or a renamed executable is rejected at validation or served as a download; nothing a user uploads can run as your site.
- Opening another user's file URL in a logged-out browser returns access denied, and signed links stop working after they expire.
We agree the acceptance test before any work starts. If it doesn't pass within three working days, you get a full refund and keep every change I've committed. The risk is mine, not yours.