FilesHub: building the storage backend instead of paying…
A self-hosted Laravel platform that replaced Firebase Storage and every transactional-email vendor across an entire portfolio. What it took, and the…
Firebase Storage needs the paid plan. So does anything that sends email at volume. Multiply either by a portfolio of products and the zero-cost constraint I run everything under stops being achievable — unless one platform serves all of them.
FilesHub is that platform: a Laravel application with a Nova admin panel, exposing object storage, transactional email, image processing, PDF, QR and barcode generation, URL shortening, and a catalogue of developer utilities across a versioned API. One key, with granular per-key permissions, unlocks the parts a given project is allowed to use.
It is the canonical upload backend behind everything I ship. This is what building it actually involved.
Multi-tenancy is the first decision and it constrains everything after
Every object belongs to a project; every project belongs to an account; every key belongs to a project and carries its own permissions. That sounds obvious written down, and it is the decision that is expensive to change later, because it appears in every route, every policy and every query.
The rule I settled on: tenancy is resolved once, at authentication, and everything downstream operates inside it. Not "add a where project_id to each query" — the query cannot see outside the tenant at all. A filter you have to remember is a filter that gets forgotten in the one endpoint nobody reviewed.
Per-key permissions, and the origin restriction I got wrong
A key that can do everything is a key you cannot ship in a browser bundle. So keys carry scopes and, for browser use, an origin allowlist: the key only works when the request comes from a registered origin.
Here is the part I want on record, because I believed the wrong thing for a while. I checked whether a key was restricted by reading it back from the management API, which reported its allowed origins as null — no restriction. That looked like a misconfiguration, so I went to fix it.
Then I probed the key directly instead of reading its record:
- a request with no
Origin header at all — 403
- a request with a junk origin — 403
- a request with its registered origin — 202
The key was restricted, correctly, the whole time. The listing was reporting a field that did not reflect enforcement.
Probe the behaviour; do not read the configuration. A record describing a security control is not the control, and I nearly "fixed" a working restriction into a broken one.
And a second thing worth being blunt about: origin restriction defends against a hostile page, never against a person. One curl with the registered origin in a header goes straight through, because a header is not a proof of anything. So a restricted key is safe to ship in a bundle in the sense that another website cannot use it — and is not a secret. Anything that must actually stay secret is server-side, always.
Why the utilities ended up in the same platform
This was not the plan. It happened because every product kept needing the same small things — a QR code, a PDF, a thumbnail, a hash, a slug — and each one was either a dependency in every project or a service with a bill.
Putting them behind the same authenticated API meant one implementation, one place to fix a bug, and no new dependency in twenty codebases. The cost is that the platform is now large enough to need its own discipline: versioned routes, so a change cannot break a consumer that has not been touched in a year.
A 200 is not a success, and I learned that from my own API
The batch endpoints return HTTP 200 with a per-item status array. A caller checking only the HTTP code sees success while half the items failed — and the failure is completely silent, because the response body was never read past the status line.
That is not a quirk of my platform; it is the shape of every batch API. But finding it in my own was useful, because it turned into a rule I now apply as a consumer: for any endpoint that accepts more than one thing, the transport status tells you the request arrived, and nothing else.
Versioning the API was the decision that aged best
Every route is versioned, and consuming projects pin the version they were built against. That felt like ceremony when there were two consumers. With an entire portfolio depending on it, it is the only reason I can change anything.
The rule I hold to: within a version, changes are additive only. A new optional field is fine. Removing a field, renaming one, tightening a validation rule or changing an error shape is not — and that last one catches people, because an error format feels like an implementation detail right up until a consumer is parsing it.
The corollary is that a consumer must never be broken by a deploy it did not ask for. A project I have not touched in a year has to keep working, because the alternative is that every platform change becomes a coordinated release across twenty repositories, which is precisely the coupling a shared platform was supposed to remove.
Credentials belong in one place, and it should not be a chat message
The operational lesson, less glamorous than the architecture and more useful day to day: every project's keys live in one vault with a known shape, and the tooling reads them from there. Not pasted into a message, not remembered, not sitting in a file called keys.txt on one machine.
What that bought me is that setting up a project on a new machine is a fetch rather than an archaeology exercise, and rotating a key is one operation rather than a hunt through twenty repositories hoping the search terms match.
What it demands is discipline about what counts as a secret. An origin-restricted browser key is not one, and treating it as though it were leads to elaborate handling of a value that is in the bundle anyway. A key that authorises server-side operations absolutely is one, and it never appears in a client-prefixed environment variable, because those are compiled into the JavaScript that everyone downloads.
Was it worth building
Yes, and the reason is not the money. It is that storage and email became a solved problem across every product simultaneously. A new project wires one key and has uploads, email, image processing and PDF generation on the first day.
The honest cost: it is a server I maintain, which is the one piece of infrastructure the zero-cost constraint does not eliminate — it consolidates it. One platform serving an entire portfolio is a different proposition from a per-product bill, but it is not nothing, and pretending otherwise would be the kind of claim I try not to make.
https://aoneahsan.com/blog/fileshub-storage-utility-case-study