How to handle file uploads in a deployed app
File uploads are the feature that most reliably exposes what kind of platform you are on. The tutorial version writes to a local directory and works flawlessly in development. Then you deploy, and the files are gone — not corrupted, not permission-denied, simply absent, because the container you uploaded to is not the container serving the next request.
There are four separate problems hiding in "handle file uploads", and they are usually solved in the wrong order. This is the order that works.
Step 1: decide where files live, before writing any code
Three options, and the right one is usually obvious once stated plainly.
- Object storage — S3, R2, GCS, or a compatible service. The default answer, and the only one that is indifferent to how many instances you run or how often you deploy. Files outlive the app entirely.
- A persistent volume — A disk that survives redeploys, attached to your app. Simpler than object storage for small volumes, and it keeps files on a filesystem your code can treat normally. The constraint is that a volume attaches to one machine, so it does not fan out across instances.
- The container's local disk — Correct only for genuinely temporary scratch: a file you receive, process, and discard within one request. Never for anything a user expects to find later.
Step 2: do not proxy large uploads through your app
The instinctive design has the browser POST the file to your server, which forwards it to storage. It works, and it makes your app the bottleneck for every byte. A hundred people uploading video means a hundred concurrent request handlers each streaming a large body, holding memory, and occupying a worker for minutes.
Presigned URLs remove your app from the data path. Your server authorises the upload and returns a short-lived URL; the browser sends the bytes directly to storage. Your app handles two small JSON requests instead of a large binary stream.
// Server: authorise, constrain, and hand back a short-lived URL.
app.post("/api/uploads", requireAuth, async (req, res) => {
const { filename, contentType, size } = req.body;
if (size > 50 * 1024 * 1024) return res.status(413).json({ error: "too large" });
if (!ALLOWED_TYPES.has(contentType)) return res.status(415).json({ error: "type" });
// Never trust the client's filename as a key. Generate your own.
const key = `u/${req.user.id}/${crypto.randomUUID()}`;
const url = await getSignedUrl(s3, new PutObjectCommand({
Bucket: BUCKET,
Key: key,
ContentType: contentType,
ContentLength: size, // binds the signature to the declared size
}), { expiresIn: 300 });
await db.uploads.create({ key, userId: req.user.id, status: "pending" });
res.json({ url, key });
});
// The browser PUTs the bytes straight to storage. Your app never sees them.
// Then it calls back to confirm, and you flip status to "ready" only after
// verifying the object exists and its size matches what was declared.Two details there matter more than they look. Generating your own key means a user cannot supply a filename like ../../config and cannot overwrite someone else's object. And binding ContentLength into the signature means the declared size is enforced by storage rather than trusted from the client.
Step 3: set limits in every layer, not just yours
A size limit in your application code is the last of at least three, and it is the one that fires last. There is usually a limit at the CDN or proxy, another at the platform's ingress, and then yours. If they disagree, users get a confusing error from a layer you do not control, with no useful message.
Find out what the outer limits are before you pick yours, and set yours below them so the error your users see is the one you wrote. This is another reason presigned uploads are pleasant: the bytes never traverse those layers at all, so their limits stop applying.
Step 4: processing an uploaded file is executing untrusted input
This is the step that gets skipped, and it is the one with teeth. Resizing an image, extracting a zip, parsing a PDF, transcoding a video — each of these runs a large C library over bytes a stranger chose. Image and media decoders have a long history of memory-safety bugs, and the attacker gets to pick the input.
Three concrete hazards, none of them exotic.
- Decompression bombs. A zip a few kilobytes long that expands to many gigabytes, or an image whose declared dimensions allocate enormous buffers. Both are trivial to construct and will take down a process that decompresses before checking.
- Resource exhaustion. A crafted media file that makes a decoder spin for minutes. Not a compromise, just an outage, and one that scales with however many uploads someone submits.
- Actual code execution. Rarer, and the reason the first two are not the whole story. A vulnerability in the decoding library turns a processing job into arbitrary code running with your app's credentials.
The structural fix is to move processing out of the process that serves requests. A crash then costs one job rather than every in-flight request, resource limits are enforced by something other than hope, and a decoder exploit lands somewhere disposable that holds no credentials.
import { Sandbox } from "@pandastack/sdk";
async function makeThumbnail(key: string) {
await using sb = await Sandbox.create({
template: "base",
ttlSeconds: 300, // a hung decoder cannot run forever
});
// Only this object's bytes go in. No credentials, no bucket access.
await sb.filesystem.write("/tmp/in", await downloadObject(key));
const out = await sb.exec(
"convert -limit memory 256MiB -limit time 30 " +
"/tmp/in -resize 400x400\\> /tmp/out.webp && echo ok",
{ timeoutSeconds: 60 },
);
if (out.exitCode !== 0) throw new UploadError(out.stderr);
const bytes = await sb.filesystem.read("/tmp/out.webp");
await uploadObject(`${key}-thumb.webp`, bytes);
}
// The sandbox is destroyed when the block exits. A zip bomb, a decoder
// exploit, or a file that pins the CPU costs one throwaway machine.Note what is not passed in: no bucket credentials, no database connection, no environment. The sandbox receives one file and returns one file. That constraint is most of the security value, and it costs nothing to maintain.
Step 5: serving files back
Two more decisions, both easy to get subtly wrong.
For private files, use presigned download URLs with a short expiry rather than proxying bytes through your app, for the same reason as uploads. If you must proxy — because you need per-request authorisation logic — stream rather than buffering the whole object into memory.
For user-uploaded content served in a browser, serve it from a different origin than your application. Otherwise an uploaded HTML or SVG file executes with your app's cookies available to it, which turns an upload feature into stored cross-site scripting. Set Content-Disposition and an explicit Content-Type rather than letting the browser sniff.
The checklist
- Files go to object storage or a persistent volume, never the container's disk.
- Uploads are presigned and go directly to storage, not through your request handlers.
- Object keys are generated server-side; the client's filename is metadata, not a path.
- Size limits are set below the outermost proxy limit, and the size is bound into the signature.
- An upload is not real until a server-side confirm verifies the object exists.
- Processing runs in a disposable environment with memory, CPU, and time limits — and no credentials.
- User content is served from a separate origin with explicit Content-Type and Content-Disposition.
- Orphaned pending uploads are swept on a schedule, because clients disappear mid-upload.
Wrapping up
Two ideas cover almost all of this. Bytes should not travel through your application if they do not have to — presigned URLs both ways, and your app handles small JSON requests. And processing a file someone sent you is running untrusted input through a complex parser, so it belongs somewhere disposable.
The tutorial version fails at the first deploy. This version fails at nothing worse than one throwaway machine.
Frequently asked questions
Why do my uploaded files disappear after a deploy?
Because the filesystem you wrote to belongs to a container or VM that no longer exists. Nearly every modern platform replaces the running instance on deploy rather than updating it in place, and anything written to local disk goes with the old one — no error, no warning, just files that are not there any more. The same thing happens on a restart, a crash, or a move to a different host, and if you run more than one instance the files were never visible to the others in the first place, which produces the confusing version where uploads work intermittently. The fix is not to find a directory that survives; it is to stop treating the local disk as storage. Put files in object storage, or attach a persistent volume that is explicitly documented to survive deploys, and use the local disk only for scratch within a single request.
Should uploads go through my server or directly to storage?
Directly to storage for anything that is not small. When bytes flow through your application, every upload occupies a request handler for its full duration, consumes memory proportional to how you buffer it, and counts against every request limit between the browser and you — so a handful of concurrent video uploads can starve an app that handles thousands of ordinary requests a second. Presigned URLs invert this: your server authorises the upload and returns a short-lived URL, the browser sends the bytes straight to storage, and your app handles two small JSON requests. You keep full control because you decide whether to issue the URL, what key it writes to, what content type it permits, and how long it lives. Proxying is defensible only for small files where the simplicity genuinely matters, and even then the limit should be low and enforced.
How do I safely process user-uploaded images or documents?
Assume the file is hostile and run the processing somewhere disposable. Image decoders, PDF parsers, archive extractors, and media transcoders are large C libraries with long vulnerability histories, and an uploaded file is input an attacker chose completely. The three practical hazards are decompression bombs that expand to gigabytes, crafted files that make a decoder spin for minutes, and outright memory-safety exploits in the decoding library. All three are contained by the same measure: process in a separate environment with hard memory, CPU, and wall-clock limits, holding no credentials and no network access it does not need, destroyed when the job finishes. Pass in one file and take out one file. Validate the real content type by inspecting the bytes rather than trusting the extension or the declared header, and set explicit limits on decoded dimensions rather than only on file size.
What is the right maximum upload size?
Whatever your product genuinely needs, chosen deliberately and then enforced at the outermost layer you control. The mistake is not picking a number that is too big or too small but having several layers disagree — a CDN limit, a platform ingress limit, and your application limit, each different — so users hit a wall from a layer that returns an unhelpful error you cannot customise. Find out what the outer limits are, set yours comfortably below them, and return a clear message from your own code. With presigned uploads this gets easier, because the bytes never pass through those intermediate layers at all and the enforcement moves to the signature itself: bind the declared content length into the presigned URL so storage rejects anything larger, rather than trusting a size the client reported.
Where should I serve user-uploaded files from?
A different origin from your application, and this is a security requirement rather than an optimisation. If uploaded content is served from the same origin as your app, an uploaded HTML file, SVG, or anything the browser interprets executes with access to your app's cookies and same-origin privileges — an upload feature quietly becomes stored cross-site scripting. Serving from a separate domain, or from your storage provider's domain, means that even if a user uploads an active document, it cannot reach your session. Alongside that, set an explicit Content-Type rather than letting the browser sniff the bytes, use Content-Disposition attachment for anything users are meant to download rather than view, and issue short-lived presigned URLs for private files instead of proxying the bytes through your own handlers.
Keep reading
49ms p50 cold start. Fork, snapshot, and scale to zero.