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:
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
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
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
Switch the upload flow itself to issue presigned URLs, so new uploads never touch the app server's disk from this point forward
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
The XenGrowth resource library — what you'll learn: how the commercial side of this work is run, across search, automation and revenue operations.
XenGrowth on governed AI marketing workflows — what you'll learn: how the teams running AI marketing agents keep them governed and measurable.
XenGrowth on building one SEO and GEO content system — what you'll learn: how search and AI-answer visibility get run as a single content system.
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.
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.











