File Uploads in a Container — `./uploads` Is Not a Place
Ask any AI tool for a file upload feature and it will write the bytes to ./uploads — a directory beside your code. That works locally and fails in a container, quietly: anything outside a volume is discarded when the container is recreated, which happens on every deploy and every configuration change. There's no object storage service here to fall back on, so you have exactly two real options. Put uploads on a volume, which is simple and counts against a disk limit with an unusually harsh failure mode. Or send them to an external bucket, which keeps bytes out of your container entirely and is the better answer as soon as uploads matter.
Why ./uploads fails without an error
Same shape as the SQLite-beside-the-code problem: the directory is created, writes succeed, reads succeed, everything looks right. Then you deploy, the container is replaced, and the directory is empty again.
Your app doesn't crash. It just serves 404s for files that were there yesterday, and you find out from a user.
Option A: a volume
The straightforward choice for a small app. Attach a volume at /data, write uploads under it, and make the path configurable so the same code runs locally:
UPLOAD_DIR = os.environ.get("UPLOAD_DIR", "./uploads")
Three things to understand before you rely on it.
Uploads count against your disk limit, and the limit is account-wide. This is the one to take seriously. Disk usage is the sum of your volume sizes, and if the total exceeds your account's limit, every app in the account is stopped and can't restart until it drops back under. User uploads are the most likely thing to grow unpredictably, which makes them the most likely way to take down apps unrelated to the one receiving files.
So cap what you accept, prune what you don't need, and watch the number rather than discovering it.
You serve the files yourself. There's no static file server in front of your app, so every download is your process, your CPU time budget, and your bandwidth. Fine for occasional retrieval; not what you want if files are a core feature.
Volumes belong to one app. A second app can't read the same volume, so an upload service and a worker that processes uploads can't share storage directly. That constraint decides more architectures than people expect.
Option B: an external bucket
S3, Cloudflare R2, Backblaze B2 — whichever you like. The reason it's worth the extra service isn't durability, it's this:
Use presigned URLs so the bytes never pass through your container. Your app generates a short-lived upload URL, the browser uploads directly to the bucket, and your app only records that it happened. Downloads work the same way in reverse.
On a fraction of a CPU and a small disk, that's a large win. A 50 MB upload costs your app one signature instead of 50 MB of transfer, buffering, and disk. It also sidesteps the disk limit entirely, and the files outlive the app.
The trade is one more credential to manage and a bucket to configure. For anything user-facing, it's the option I'd pick.
Limits you have to set yourself
Nothing in front of your app rejects a large request — the edge terminates TLS and routes, nothing more. Your framework's defaults are the only thing standing between a stranger and your container.
- Set a maximum upload size explicitly. Some frameworks default to unbounded.
- Stream to disk; don't buffer in memory. A request buffered in RAM on a 0.25 GB container is an out-of-memory kill, which looks like an unexplained restart rather than a rejected upload. Exit code 137 is the confirmation.
- Reject early. Check the declared content length before reading the body, so an oversized upload costs you a header rather than a transfer.
- Require authentication. An anonymous upload endpoint on a public URL is free storage for whoever finds it — and their content becomes your disk usage, with the account-wide consequence above.
Filenames are user input
Generated code often uses the client-supplied filename as the storage path. That's a path traversal waiting to happen — a name like ../../etc/something is a request to write outside your directory.
Store files under a generated identifier and keep the original name as metadata:
file_id = uuid.uuid4().hex
path = os.path.join(UPLOAD_DIR, file_id)
# keep original_name in your database, not on disk
That fixes traversal, duplicate names, and awkward characters in one change. Serve downloads with the original name in a Content-Disposition header rather than in the path.
While you're there: don't trust the declared content type, and don't infer safety from the extension. If you serve user files back on your own domain, an HTML file uploaded as an "image" is a scripting problem — another reason bucket-hosted downloads on a separate domain are easier to reason about.
Don't process images synchronously
The natural next feature is thumbnails, and it's the one that doesn't fit. Image processing is exactly the CPU-and-memory burst that a fractional CPU handles badly — your allocation is a time budget per window, so a resize blocks everything else while it runs.
If you need derived images, do it out of the request path: accept the upload, return, and process from a queue slowly. Or use a bucket provider that offers image transformation and skip the work entirely.
Back it up, because nothing does
Volume data isn't backed up for you. If uploads matter, ship a copy off the platform on a schedule. This is another quiet argument for a bucket: durability is the provider's problem rather than a cron job you keep meaning to write.
FAQ
Where do uploaded files go in a container?
Onto a volume, or into an external bucket. Anywhere else is discarded when the container is recreated on the next deploy.
Is there object storage built in?
No. Use a volume, or an external provider like S3, R2, or B2.
Do uploads count against my disk limit?
Yes, if they're on a volume. Exceeding the account's total disk stops every app you have until you free space.
How do I avoid uploads going through my app?
Presigned URLs. Your app signs a short-lived URL and the browser uploads straight to the bucket, so no bytes touch your container.
Why does my app restart when someone uploads a large file?
Probably buffering the request in memory and hitting the RAM limit. Stream to disk and set a maximum size.
Can two apps share an upload directory?
No. A volume belongs to one application. Use a bucket if more than one app needs the files.
Wrapping up
Uploads are where a prototype meets the container model most directly: the code is correct, the storage assumption isn't. A volume is the right first step for a small app, provided you cap sizes and remember that filling it takes down everything on the account, not just the app doing the uploading. Once files are a real feature rather than a nice-to-have, move them to a bucket with presigned URLs — your container stops being in the data path, and the storage problem stops being yours.