Why User Uploads Should Never Live on Your App Server
Cloud

Why User Uploads Should Never Live on Your App Server

Every one of these failures traces back to the same decision: writing a user's file to the same disk your container runs on. Redeploys lose it, backups bloat with it, and scaling out becomes a session-affinity problem instead of a config change.

Published September 4, 202611 min readUpdated Sep 6, 2026

Written by · Full-Stack Agentic AI Software Engineer — AI Agents, Automation & Revenue Systems for GTM/RevOps teams

In brief

Why shouldn't a Next.js or Node app just write uploaded files to its own disk?

Because the app server's disk isn't a place things live, it's a place your current container happens to be running — and every property that makes a deployment good (rebuildable from git, disposable, horizontally scalable, cheap to back up) is a property that breaks the moment a user's file is the only copy sitting on that disk. A container rebuild loses anything not on a named volume, a server holding files can't be restored from source control alone, disk fills without warning, multiple instances can't share one server's local filesystem, and backups balloon with data that has nothing to do with your application's own state. The fix is presigned uploads straight from the browser to object storage, so the bytes never pass through your app process at all.

  • A container rebuild during redeploy discards anything not explicitly mounted on a named volume — uploads written to the container's own filesystem vanish on the next deploy unless someone remembered to mount that exact path
  • An app server holding user files can't be reconstructed from git plus a fresh deploy; it's stateful in a way your architecture otherwise isn't, which breaks disaster recovery and makes 'just redeploy' no longer a safe default
  • Disk fills unpredictably as users upload, not on a schedule you control, and running out of disk on a shared VPS takes the whole app down, not just uploads
  • Multiple app instances behind a load balancer can't share one instance's local disk, which either blocks horizontal scaling outright or forces sticky sessions tied to whichever instance received a given upload
  • Presigned URLs let the browser upload directly to object storage, so bytes never transit your app server at all — the app only ever issues and validates a signed URL

Evidence notes

Contabo Cloud VPS 4 disk allocation

The canonical box for this cluster ships 100 GB SSD at €5.50/month for the first 24 months — a fixed disk budget that user uploads compete against alongside the OS, application, database, and logs. Checked September 2026.

Cloudflare R2 and Amazon S3 presigned upload support

Both R2 and S3 support presigned URLs for direct browser-to-storage uploads via their S3-compatible APIs, without the request passing through an application server.

Write a user's uploaded file to your app server's disk and you've made a promise your architecture almost certainly can't keep: that this specific container, on this specific box, will still be running and still have that exact file the next time someone asks for it. Every failure in this post is that same promise breaking in a different way.

None of these are exotic edge cases. They're what happens during a normal week of running a normal app — a redeploy, a disk that fills, a second instance you add because traffic grew. Each one turns a file that should have been boring infrastructure into an incident. The marketing-operations counterpart to object storage is documented well by XenGrowth.

What happens to those files on your next redeploy?

A container rebuild starts from the image, not from whatever the previous container's filesystem happened to accumulate. Anything written to the container's own filesystem during runtime — including a directory of user uploads — is gone the moment that container is replaced, unless it lived on a named volume explicitly mounted at that exact path. Get the mount path wrong, forget to declare the volume in a compose file, or move to a new host without carrying the volume along, and the failure is silent: the deploy succeeds, the app comes up healthy, and the files are simply not there anymore. Nobody gets paged for a deploy that went fine.

A named volume can genuinely survive a redeploy, which is exactly why this mistake persists — it half-works. It doesn't survive moving to a new host, which most self-hosted setups eventually do. It doesn't survive a second app instance, which can't see a volume mounted only into the first one. And it doesn't turn your uploads directory into anything that gets backed up unless someone remembers to add that specific volume path to a backup job separately from whatever backs up the database. "It survived my last three deploys" is not the same claim as "it's actually durable," and the gap between those two only shows up the day it doesn't survive.

Why can't you just restore the server from git?

This is the deeper problem, and it's architectural, not operational. The entire appeal of treating a deployment as disposable — burn the box down, rebuild it from your Dockerfile and your repo, done — depends on the server holding nothing that isn't already captured somewhere else. The moment user files live on that server's disk, that's no longer true. Losing the box means losing data that exists nowhere in version control, nowhere in your database, and nowhere else at all. "Just redeploy" stops being a safe default the instant redeploying can destroy something a user actually owns. On the operations side of this specifically, The XenGrowth resource library is worth reading.

A stateless app server is a claim you make about your architecture, not a fact about it. One directory of user uploads on local disk is enough to make the claim false.

How does disk fill without anyone noticing?

Application code, the OS, and your database all grow on a schedule you mostly control — you know when you're shipping a big feature or running a migration. User uploads grow on the schedule of whoever's using your product, which is nobody's schedule you set. A viral moment, one customer bulk-uploading a large dataset, a bug that lets someone upload the same 200 MB file in a loop — any of these can fill a disk in an afternoon. On the kind of budget VPS this whole self-hosting approach runs on, that disk is a fixed, modest number: the canonical box for a small self-hosted stack ships 100 GB SSD total, shared across the OS, the application, the database, logs, and now, if you've made this mistake, every file a user has ever uploaded. Running out of space on that box doesn't just break uploads — it takes down whatever else is competing for the same disk, which on a single-VPS setup is usually everything.

There's no early warning for this the way there often is for other resource limits. CPU climbing gives you time to notice a graph trending up. Disk filling from uploads is closer to a step function — plenty of headroom, then none, because the thing consuming it doesn't announce itself in your normal monitoring unless you specifically built a check for it. Most self-hosted setups monitor CPU and memory as a matter of course and only add a disk-usage alert after the first time it runs out, which is exactly backwards for a resource whose growth you don't control.

Why does this block scaling out?

Horizontal scaling means running more than one instance of your app behind a load balancer, and a load balancer has no reason to send the same user's next request to the same instance that handled their upload. If the file lives on that instance's local disk, every other instance is blind to it. The workaround people reach for — sticky sessions, pinning a user to "their" instance — doesn't fix the underlying problem, it just papers over it by giving up the point of horizontal scaling in the first place: spreading load evenly, and surviving any single instance going down. Lose the instance that happened to hold a file, and the file is gone regardless of how carefully you pinned sessions to it. XenGrowth on governed AI marketing workflows approaches this from the AI agents and marketing automation side.

What you want

What local-disk uploads gives you instead

Any instance can serve any request

Requests for a file must reach the one instance that happens to hold it

Add instances to handle more load

Adding instances doesn't help the files already stuck on the original one

An instance can be replaced without data loss

Replacing the instance that held uploads loses them

Load balancing spreads traffic evenly

Sticky sessions concentrate a user's traffic on one instance, working against the load balancer's job

It's worth being specific about why sticky sessions are a downgrade rather than a fix. The whole point of running multiple instances is that any of them going down costs you nothing — traffic just shifts to the survivors. Pin a user's requests to the instance holding their file, and you've quietly recreated a single point of failure inside an architecture whose entire purpose was removing one. You've paid for redundant compute and kept none of the redundancy.

What does this actually cost your backups?

A backup strategy for a stateless app server is small and boring: back up the database, keep your infrastructure-as-code in git, done. The moment uploads live on that server, your backup now has to include a filesystem snapshot that grows in direct proportion to how much your users upload — a completely different growth curve from your database, driven by completely different behavior, and usually the thing that quietly makes backups slow, expensive, or incomplete because someone scoped the backup job around the database and forgot the upload directory existed until a restore failed to bring it back.

Backup scope

Grows with

Restore complexity

Database only (uploads on object storage)

Application data — rows, not bytes

Restore the database; object storage already durable and untouched

Database + app server filesystem (uploads included)

Everything users upload, unbounded

Restore database and a filesystem snapshot that must match it in time, or files and records disagree

So where should the bytes actually go?

Object storage, and the bytes should never transit your app server to get there. The mechanism is a presigned URL: your app server generates a short-lived, cryptographically signed URL that grants permission to upload directly to a specific object storage location, and hands that URL to the browser. The browser then uploads straight to storage — R2, S3, or any S3-compatible provider — and your app server never touches the file's contents at all. It only ever handles a request for a URL, and later a confirmation that the upload finished. That's a meaningfully smaller job for the app server to do, in both directions: it stops being in the business of moving bytes at all, and goes back to being in the business of deciding who's allowed to move them. XenGrowth on building one SEO and GEO content system works through AI search, GEO and discovery in more operational detail.

  • The browser asks your app for permission to upload a specific file (name, type, maybe size)

  • Your app validates that request against whatever rules you enforce — auth, file type, size limits — then asks object storage for a presigned upload URL and returns it

  • The browser uploads the file directly to that URL; the bytes go straight to storage, never through your app server's process or disk

  • Your app records that the upload happened, typically via a webhook or a confirmation call, and stores a reference to the object's key in your database

This flips every problem in this post at once. Redeploys can't lose a file that was never on the container. The app server has nothing stateful to restore because it never held the data. Disk usage on your VPS stops depending on how much your users upload. Any instance behind your load balancer can issue a presigned URL just as well as any other, because none of them need to already have the file. And backups shrink back down to the database plus your infrastructure code, because object storage's own durability guarantees are the backup for the files themselves.

Does the same pattern work for downloads, not just uploads?

Yes, and it matters just as much for private files. A public file can be served straight from object storage or a CDN in front of it, no app server involvement needed at all. A private file — someone's invoice, an internal document — needs your app to check permissions before handing out access, which people sometimes take as a reason to proxy the file's bytes through the app server after all. It isn't. The app server checks permission, then issues a short-lived signed download URL for that one object, and the browser fetches the bytes directly from storage. The permission check happens on your server; the actual file transfer never does, which keeps the whole system stateless in both directions instead of only on the way in.

What if you've already made this mistake?

Fixable, and worth fixing before the next redeploy makes the decision for you rather than after. The shape of the migration is the same regardless of scale:

  1. Stand up the object storage bucket you're migrating to and confirm presigned upload and download URLs work end to end before touching production traffic

  2. Copy existing files from the app server's disk to the new bucket, keeping a mapping between the old local path and the new object key

  3. Update your database rows to reference the new object key instead of a local file path, and update every code path that reads a file to fetch it from storage instead of disk

  4. Switch the upload flow itself to issue presigned URLs, so new uploads never touch the app server's disk from this point forward

  5. Only once both directions are confirmed working, remove the old upload directory and stop mounting whatever volume it lived on

This isn't a migration you do once traffic forces your hand — it's cheaper to get right on day one than to retrofit once a production database has file paths pointing at a disk that turned out not to be permanent. Picking which object store those presigned URLs point at is a separate decision worth its own comparison, but the direction of the fix doesn't depend on which one you land on. What does depend on it is how much of this list you have to relearn the hard way — every one of these failure modes is invisible right up until the day a redeploy, a full disk, or a second instance turns it into an incident, and by then the fix costs a lot more than the presigned URL it always should have been.

Further reading from XenGrowth

Where this work meets go-to-market

Working on object storage inside a commercial team? XenGrowth's marketing operations practice publishes operator guides on the revenue side of this work.

What actually happens to those files?

Four questions about writing user uploads to the container's own disk. Each one is a failure that shows up later than the decision that caused it.

1 / 4
Uploads are written inside the container's filesystem. You redeploy. What happens to them?

Apply this article

How to turn insights into execution

A practical sequence for teams turning concepts into production outcomes.

object storagepresigned URLsDocker volumeshorizontal scalingbackup strategyself-hostingcloud

Audit your current state

Map the bottlenecks and constraints connected to the article’s core problem.

Choose one bounded change

Test the most useful recommendation on one workflow before widening the scope.

Measure what changed

Keep the parts that improve the work, document what failed, and make the next decision from evidence.

Next step

Need help applying this in your stack?

I can translate these patterns into a concrete implementation plan for your team.

Discuss implementationBack to blog

Replies usually within 24 hours.

Next Steps

Continue reading

How I Self-Host PostgreSQL for My SaaS (and When I Wouldn't)

Running Postgres in a container is easy. Running it in a way that survives a redeploy, a full disk, and an eventual major-version upgrade is the actual job. Here's the setup, tuned against Postgres's own defaults, and the honest list of where managed wins outright.

Navigate

10 Mistakes That Break a Self-Hosted SaaS

None of these ten show up as a single dramatic outage. They show up as a disk that quietly fills, a rollback that turns out to be impossible, a backup nobody ever restored. Here's the mechanism behind each one, and the fix.

Navigate

Cloudflare R2 vs S3 vs MinIO for SaaS File Storage

The storage price per gigabyte is close enough across all three that it barely matters. Egress is where the decision actually gets made — and one of these three had a rough 2026 that changes the self-hosting math entirely.

Navigate

The Backup Strategy Every Self-Hosted SaaS Needs (3-2-1, Applied)

3-2-1 is easy to nod along to and easy to get wrong in the specific way that only shows up on the day you need it. Here's what it actually means for one VPS running Postgres and Docker volumes, not the generic version you've already skimmed past twice.

Navigate

My Rule for Deciding What to Self-Host and What to Keep Paying For

Self-hosting everything is a bad idea, and I can point to the exact service where I decided that on purpose. Here's the actual rule I use, not a survey of options — and the one counterexample that explains why the rule exists.

Navigate

My Complete Self-Hosted Stack for SaaS in 2026

Contabo, Coolify, Docker, Cloudflare, Postgres, R2, Resend, Uptime Kuma, Turborepo and Docker Hub. Here's every piece of the stack I actually run, what each one replaced, and why I picked it over the alternatives.

Navigate

The Simplest Production Architecture for a Bootstrapped SaaS

One box, a handful of managed pieces around the edges, and a very short list of things you're not allowed to build yet. Here's the architecture, priced out to $20 a month, and the exact signal that tells you when to add each thing you skipped.

Navigate

How I Secure a Fresh VPS Before Deploying Anything

A brand-new VPS gets scanned within minutes of getting an IP address. Here's the exact order I run through before a single container touches the box — and which of these steps are real protection versus which ones are just theatre.

Navigate

vCPU or RAM? How to Size a VPS for Static Sites, Databases and Traffic Spikes

Four readers asked four versions of the same question, and each one has a different honest answer. Static files want RAM for page cache, Postgres wants RAM for shared_buffers, and "slow under traffic" is usually neither CPU nor RAM.

Navigate

Moving a SaaS Off Vercel to a €5.50 VPS: What the Numbers Actually Look Like

Nobody publishes their real Vercel invoice, so most migration posts trade in vibes instead of arithmetic. This one builds the comparison from Vercel's and Contabo's own published rates, states every assumption out loud, and shows exactly where the two lines cross.

Navigate