All posts
6 min read

Where Should User Uploads Go? Object Storage Explained

Profile photos that vanish after a deploy, a database bloated with images, a public bucket full of private documents. Where uploaded files should live, how object storage works, and the three decisions — public or private, who uploads, and how files are served — that keep uploads fast and safe.

infrastructuredeploymentsecuritygetting started

Sooner or later your app lets people upload something: a profile photo, a PDF, a spreadsheet, a video. Where that file ends up decides whether it's still there next week, whether your app stays fast, and whether strangers can read other people's documents.

AI tools often pick the most convenient place, which is rarely the right one. Here's how to choose.

Three places files shouldn't go

1. The app server's disk. It works in development. In production, on many hosts, the server's filesystem is temporary: it's wiped on every deploy or restart, and if your app runs on more than one instance, each has its own disk and a file uploaded to one isn't visible from another. Users' photos disappear, or appear only some of the time. (This is one of the classic entries in Why Does My App Work Locally but Not in Production?.)

The exception: a host that mounts a persistent volume — disk storage that survives restarts and redeploys. That's a reasonable home for files in a single-server app, as long as it's backed up.

2. The database. Databases can store files (as binary data or base64 text), and AI tools sometimes do this because it's one less service to set up. It works for a handful of tiny files. Beyond that, it bloats the database, slows backups and restores, and makes every query that touches those rows heavier. Databases are for data about files — name, owner, size, where it's stored — not the files themselves.

3. The frontend's public folder. Anything there is served to everyone and is part of your code. Users' uploads must never end up in it.

Object storage: the usual answer

Object storage is a service built for storing files at any scale: Amazon S3, and a family of S3-compatible services from Cloudflare (R2), Backblaze (B2), Google Cloud, DigitalOcean, and others, plus storage built into platforms like Supabase and Firebase. You put a file in a bucket under a key (effectively a path, like avatars/user-4812.jpg), and get it back later.

Why it's the default choice:

  • It doesn't care about your servers. Deploy, restart, scale to ten instances — the files are elsewhere and unaffected.
  • It's durable. Providers store multiple copies; losing a file to hardware failure is extremely unlikely.
  • It's cheap per gigabyte stored, and it can serve files directly to users, taking load off your app.

Watch one line on the bill: egress — the cost of downloading files. Some providers charge for it, some don't. For apps that serve a lot of images or video, it can matter more than storage.

Decision 1: public or private?

This is the one that causes breaches.

  • Public — anyone with the URL can read the file. Fine for things that are genuinely public: product images, blog post images, public avatars.
  • Private — the file can only be read with permission. Required for anything personal: documents, invoices, ID photos, private messages, anything a user wouldn't want a stranger to see.

Default to private. A private bucket with public exceptions is safe to get slightly wrong. A public bucket full of private documents is a breach waiting for someone to guess a URL — and "hard to guess" URLs are not security. Check your bucket settings; AI tools sometimes make a bucket public to make an image display during development.

Decision 2: how do files get uploaded?

Two common patterns:

Through your server. The browser sends the file to your app, which checks it and forwards it to storage. Simple, and your server sees everything — but large files tie up your server, and some hosts limit request size.

Directly to storage, with a presigned URL. The browser asks your server for permission. Your server checks the user is allowed to upload, then generates a presigned URL: a temporary, single-purpose link that allows uploading one file to one location for a few minutes. The browser uploads straight to storage using it. Your server never touches the file's bytes.

Presigned uploads are the better pattern for anything beyond small images. Either way, your server decides whether the upload is allowed and where it goes. Never let the browser choose the destination path freely — or a user can overwrite someone else's file.

Decision 3: how are private files served?

For private files, the same trick works in reverse. When a user asks for their document, your server checks they're allowed to see it, then returns a short-lived presigned download URL — valid for a few minutes. The link works for them now and is useless if shared or leaked later.

The important part is the check. The server decides access from the logged-in user's session, not from a file ID the browser sent. Otherwise, changing invoice-1001 to invoice-1002 in a request shows someone else's invoice. (How to Add Login to an AI-Built App covers this pattern.)

Validating uploads

Treat every uploaded file as untrusted:

  • Limit the size. Set a maximum per file, enforced on the server or in the presigned URL's conditions.
  • Check the type. Allow only the file types you expect. Don't trust the filename's extension alone.
  • Generate your own filenames. Store files under names you create (a random ID), not the name the user gave, which can contain paths or characters that cause trouble.
  • Serve user-uploaded content carefully. Uploaded HTML or SVG files served from your main domain can run scripts in your users' browsers. Serve uploads from a separate domain, or force them to download rather than display.
  • Process images. Resizing uploaded images to the sizes you actually display makes pages faster and strips hidden metadata like the GPS location in a phone photo.

Backups and deletion

  • Object storage is durable, but not a backup. It protects against hardware failure, not against your own code deleting the wrong files. Turn on versioning for important buckets, so deleted or overwritten files can be recovered for a period.
  • Delete files when their owners delete them. When a user deletes their account, their uploads should go too. Keep the file's storage key in your database so you can find everything that belongs to a user.

The checklist

  • Uploads go to object storage (or a backed-up persistent volume), not the server's temporary disk or the database
  • Buckets private by default; public only for genuinely public files
  • The server authorises every upload and chooses where it's stored
  • Private files served through short-lived presigned URLs, after an access check
  • Size and type limits enforced on the server
  • Your own generated filenames; user-uploaded HTML/SVG never served from your main domain
  • Versioning on for important buckets
  • Files deleted when their owner's data is deleted

EasySpawn workspaces have persistent storage that survives container restarts and redeploys, with managed databases and daily backups alongside — so an app's data doesn't vanish with its container. See how it works or join the waitlist.

Related: Which Database Should an AI-Built App Use? · A Security Checklist for Vibe-Coded Apps · What Is a CDN? · Direct-to-Storage Uploads With Presigned URLs

Keep reading