Every side-project-turned-real-product has a moment where an early shortcut starts sending you invoices. Ours was storing uploaded files as blobs in PostgreSQL.
It worked. It was simple. And then it started getting in the way.
This is the story of why we moved Andika's file storage out of the database and into dedicated object storage, and what changed when we did.
The shortcut that got us here
When you're moving fast, putting file bytes in the database next to everything else is tempting. One system to back up, one connection to manage, no extra infrastructure. PostgreSQL will happily store binary data, and for a while the numbers are small enough that you never think about it.
The problems arrive gradually:
- The database becomes a file server. Every upload and download is a round trip through the same engine that's supposed to be answering queries. Large files hold connections open and chew through memory.
- Backups get heavy. Your careful backup strategy now includes gigabytes of immutable file bytes that never change but get carried along anyway.
- Scaling gets awkward. You can scale a database, but you really don't want to scale it because someone uploaded a 40 MB PDF.
-
The data model lies to you. A row that says
document_id, filename, byteshides the truth: the bytes are not relational data. They're cargo.
None of this is PostgreSQL's fault. It's a brilliant database. It's just not a blob store.
What we moved to
We migrated uploaded files and document assets to dedicated object storage (DigitalOcean Spaces, which speaks the S3 API). The division of labour is now clean:
- Object storage holds the bytes. PDFs, images, Word documents, presentations. Immutable, addressable, cheap to store, straightforward to cache and serve.
- PostgreSQL holds the truth about the bytes. Metadata, provenance, ownership, workspace relationships, permissions, revision history.
Every stored object is organised under unique organisation and workspace identifiers. That naming scheme prevents key collisions between tenants and keeps each customer's objects grouped under their own prefix. One thing worth being precise about: the prefix layout is organisation, not authorization. Who may read or write an object is still decided by the platform's permission checks on every request; the layout just means a bug or a bad guess at a key can't wander into another customer's namespace by accident.
What changed for us
- The database went back to doing what it's good at: structured queries over structured information.
- File delivery no longer competes with query traffic.
- Database backups no longer carry immutable file bytes, and object storage has its own durability model.
- Capacity planning became two independent conversations instead of one panicked one.
The lesson I'd hand to my earlier self
"Boring" architectural decisions compound in both directions. Storing blobs in the database is a reasonable day-one choice and an expensive day-hundred choice. The earlier you separate bytes from metadata, the cheaper the separation is.
It's the same principle behind a lot of good system design: put each kind of data in the place that's shaped like it. Files are shaped like object storage. Relationships are shaped like a relational database. Forcing one to be the other works right up until it really doesn't.
This post was adapted, with AI assistance, from posts on the Andika blog, where we build a collaborative writing and knowledge platform.
Top comments (0)