
ROZ Storage — short for Routing Object Zoning — is a small Go service with one job: hand out files. The name is also a nod to Roz from Monsters, Inc., the deadpan clerk who guards the paperwork and never lets a misfiled form slide. Fitting, for a service whose whole job is brokering access to stored files.
TL;DR
- Goals: hand out files cheaply, across multiple clouds and multiple tenants — leaning on the client’s own bandwidth for transfers, keeping the backend lean, and never letting the service become a bandwidth bottleneck or a single point of failure.
- What it solves: the previous design proxied every byte through the frontend and backend, on a single cloud, with the app layer acting as plumbing — paying compute and egress just to shuffle files. ROZ takes the service off the data path entirely (clients transfer straight to object storage, so the service stops paying for that traffic), drops the database off the request path, and makes per-category storage lifecycle rules possible — all without migrating the files already in production.
- How: valet-key pre-signed URLs so clients upload and download directly to object storage; a three-method provider interface (Cloudflare R2, Oracle OCI, Azure Blob — pluggable); routing config embedded in the binary with
go:embed; time-ordered UUIDv7 IDs; and a dual-path object layout switched by a timestamp cutover.
What follows is each of those decisions and the tradeoff behind it, in the order they mattered — because that order is the architecture.
Where it started: bytes through the front door
Here’s the system I was replacing. To upload a file, the client streamed it to the frontend, which streamed it to the backend, which streamed it to Azure — every byte of every file passing through every layer, in 1MB multipart chunks, all the way down.
graph LR
U([User]) --> F[frontend]
F -->|metadata + multipart upload, 1MB chunks| B[backend]
B -->|metadata + multipart upload, 1MB chunks| AZ
subgraph AZ["Azure (single cloud)"]
OS[(Object Storage)]
TS[(Table Storage)]
end
Two things are wrong with this picture, and they’re the reasons ROZ exists:
- Every layer is on the data path. The frontend and backend aren’t deciding anything about the file — they’re just plumbing, copying bytes from one socket to the next. That’s CPU, memory, and bandwidth spent on work that adds no value, multiplied by every layer and every megabyte.
- One cloud. The whole thing is welded to Azure. There’s no way to send one tenant’s files to a cheaper or closer store without rewriting the path.
Where it ended up
graph LR
U([User]) --> F[frontend]
F <-->|"1. metadata / 3. complete"| B[backend]
B <-->|"1. metadata / 3. complete"| R[ROZ-storage]
R -->|"location, pre-signed URL"| B
R -->|metadata| DB[(Database)]
F -.->|"2. multipart upload, 1MB chunks (direct)"| CLOUD
subgraph CLOUD["Object storage (provider-agnostic)"]
GCP[(GCP)]
AZURE[(Azure Blob)]
R2[(Cloudflare R2)]
end
Same three-tier app on the control path — frontend asks backend asks ROZ for a place to put the file — but the bytes no longer flow through any of them. ROZ returns a pre-signed URL, the frontend uploads its 1MB chunks directly to whichever cloud that tenant is routed to, and only a “complete” call comes back through the stack. The app layer went from carrying the file to merely brokering it.
Everything else in this post is the set of decisions that turned the first diagram into the second.
Decision 1: The valet key pattern
The first and most consequential decision: ROZ does not proxy file content. It never receives an upload and never streams a download. Instead it issues short-lived, pre-signed URLs and steps out of the way. The client uploads its bytes directly to the object store, and downloads by following a redirect to a signed URL.
If you’ve never met the pattern, here’s the everyday version of it.
Think about how a parcel locker works. The courier doesn’t carry your package into your living room, and the store doesn’t keep it behind its own counter indefinitely. It drops the box in a locker and texts you a code. That code opens exactly one door, for a limited time, and then it’s useless. The store doesn’t have to be in the room when you show up, and you never get a key to the whole locker wall — just the one door, just for a while.
A pre-signed URL is that locker code. ROZ doesn’t hold your file and it doesn’t hand out the master key to the bucket. It signs a URL that grants one narrow permission — “write this object” or “read this object” — that expires in minutes. This is the valet key pattern: you don’t give the valet your car, you give them a key that only does one limited thing.
sequenceDiagram
participant C as Client
participant ROZ as ROZ API
participant M as Metadata store
participant OS as Object store
C->>ROZ: POST /uploads (file_name, content_type, ...)
ROZ->>M: persist metadata
ROZ->>OS: sign a write URL
ROZ-->>C: { upload_url, required_headers }
C->>OS: PUT bytes directly (no ROZ in the path)
Note over C,OS: later...
C->>ROZ: GET /files/:id
ROZ->>M: read metadata
ROZ->>OS: sign a download URL
ROZ-->>C: 302 Found -> signed URL
C->>OS: GET bytes directly
Why I committed to this:
- The service stays tiny and cheap. No large request bodies, no streaming buffers, no memory pressure from a 200MB PDF. ROZ pods can be small and many — a genuinely lean backend.
- The client’s bandwidth moves the bytes. Uploads go straight from the client to object storage, so the service never pays to shuffle that traffic — no doubled hop through the backend, and no compute egress billed for proxying files back out to clients.
- The object store does what it’s good at. Throughput, multipart, resumability — that’s R2’s and Azure’s problem, not mine.
- The blast radius shrinks. If ROZ falls over, in-flight transfers to already-issued URLs keep working. The service sits on the control path, not the data path.
The tradeoff I accepted: I give up inspecting or transforming content in flight. No virus scanning at the gateway, no on-the-fly transcoding, no counting bytes as they pass. If I ever need those, they happen out-of-band — a worker triggered after upload — not inline. For a file manager, that was an easy trade. For a file pipeline, it wouldn’t be.
There’s also a leak in the abstraction worth naming: a signed PUT URL isn’t enough on its own. The signature is computed over certain headers (Content-Type, Content-Encoding, Cache-Control…), so the client must send those exact headers back. That’s why the API response carries a required_headers map the client has to echo verbatim. The locker code comes with a sticky note of instructions.
Decision 2: Provider-agnostic, behind a three-method interface
We run across more than one object store: Cloudflare R2 and Oracle OCI (both S3-compatible), plus Azure Blob (its own SDK and its own SAS-token model). I did not want the business logic to know or care which one it was talking to.
So the entire surface a provider must satisfy is three methods:
type Provider interface {
GeneratePresignedURL(ctx, params) (url string, requiredHeaders map[string]string, err error)
GenerateDownloadURL(ctx, objectPath string, ttlInMinutes int) (string, error)
DeleteFile(ctx, objectPath string) error
}
Two implementations live behind it: an s3 one (R2 + OCI, same SDK) and an azure one. The catalog models each connection as a Provider and an SDK — because the provider is branding (CLOUDFLARE_R2, OCI_OBJECT_STORAGE, AZURE_BLOB) but the SDK is what actually decides the code path (S3_COMPATIBLE vs AZURE_SDK).
Why three methods and not thirty: the interface is the contract every future store has to honor, so I wanted it to be the smallest contract that still does the job. Sign a write, sign a read, delete. Everything tenant-, path-, and policy-related lives above the interface, in the service layer, written once and shared by every provider.
And this is where pluggability stops being a buzzword. Adding a brand-new cloud — Google Cloud Storage, say — isn’t a refactor. It’s implementing those three methods and adding one entry to the datasource catalog. That’s the entire price of admission. The diagram above shows GCP next to Azure and R2 for exactly that reason: not because it’s wired up today, but to make the point that a new provider is a leaf, not a root.
The tradeoff: the abstraction is lowest-common-denominator on purpose. Provider-specific superpowers (R2 bindings, Azure lifecycle tiers, OCI retention locks) don’t get a home in the interface. When I need them, they leak into the concrete implementation or into operational config — which is exactly what happened with lifecycle rules, the quirk that reshaped the whole path scheme below.
Decision 3: Routing config is embedded in the binary, not in a database
Which tenant’s files go to which store? That’s a routing question, and the obvious instinct is “put it in a table.” I went the other way: two JSON files — datasources.json (the catalog of connections) and router-rules.json (the tenant/category → datasource mapping) — compiled into the binary with go:embed.
//go:embed configs/datasources.json
var embeddedDatasources []byte
//go:embed configs/router-rules.json
var embeddedRouterRules []byte
Secrets aren’t baked in, though — the embedded JSON contains ${ENV_VAR} placeholders expanded at startup with os.ExpandEnv. The shape is static; the credentials are injected at runtime.
Why I took the database off the request path:
- Resilience. Routing can’t fail because a database is slow or down. The decision is a map lookup over data guaranteed to be present the moment the process starts.
- Performance. No query, no connection pool, no cache to invalidate.
- Reviewability. Changing where a tenant’s files land is a pull request with a diff, not a manual
UPDATEin prod. The routing table has a git history.
The router resolves in a strict priority order, which is itself a small design statement:
graph TD
A[tenant_id + category] --> B{tenant rule?}
B -- yes --> T[use that datasource]
B -- no --> C{category rule?}
C -- yes --> CT[use that datasource]
C -- no --> D[default datasource]
Tenant beats category beats default. A specific tenant override always wins; a category rule (“all documents go to one store”) is the broad stroke; and there’s always a default, so an unknown tenant is never a 500 — it just lands in the general-purpose bucket.
The sharp version of this — and the part I like most: issuing a pre-signed URL touches no database at all. Routing is an in-memory map lookup over the embedded config, and signing is a pure cryptographic operation — an HMAC over the object path, expiry, and headers using the datasource’s key. There is no row to read in order to hand out a URL. ROZ does record file metadata separately, but the act of authorizing an upload or a download is stateless and in-memory. Only memory is enough. (You’ll see in the next decision how downloads can skip the database entirely, too.)
The tradeoff — and it’s a real one: changing routing means a redeploy. There’s no runtime knob, no admin endpoint. For a config that changes a handful of times a year, I’ll take “boring redeploy” over “live mutable state in a database” every time. If routing churned daily, this would be the wrong call and I’d move it to a store with a cache. Know your change frequency before you copy this.
Decision 4: The file ID is the timestamp (UUIDv7)
Every file gets a UUIDv7 as its ID. Why UUIDv7 specifically, and not v4 or an auto-increment? Because v7 is time-ordered: the creation timestamp is encoded in its leading bits. The ID isn’t just a random label — it’s a clock you can read.
The object path is partitioned by month:
{tenant_id}/{category}/{YYYY-MM}/{file_id}
That YYYY-MM comes straight out of the UUIDv7. Because the ID carries its own creation time, three things fall out for free:
- I can derive the month partition from the ID alone — no separate
created_atlookup at write time. - The IDs sort chronologically, so listings and range scans are naturally time-ordered.
- I can compute an object’s full path from nothing but its ID.
That last point is what lets downloads skip the database. There’s a bulk-download path in ROZ that takes a list of UUIDs and signs every URL in memory, deriving each object path directly from the UUIDv7’s timestamp — no metadata read at all. The valet key (Decision 1), the in-memory routing (Decision 3), and the self-describing ID compound into the same property: you can authorize access to a file with only the ID and some math.
The tradeoff: I’ve coupled my path logic to the ID format. UUIDv7’s time bits are now load-bearing, not just a primary key. If I ever changed ID schemes, the path logic would change with them. I think that’s a fine coupling — the ID and the path are both “identity of a file” — but it’s a coupling, and it should be a conscious one. The payoff shows up immediately, in the next decision.
Decision 5: A path migration with zero file migration
Here’s the quirk that reshaped everything, and the decision I’m proudest of because it cost almost nothing.
The quirk: Cloudflare R2’s lifecycle rules don’t support wildcards. You can expire objects under a fixed prefix, but you can’t write */documents/* to expire one category across all tenants. With my original path — {tenant_id}/{category}/... — the tenant is the root prefix, so “expire this category everywhere” is impossible without enumerating every tenant by hand and updating the rules each time a new one is added. That doesn’t scale.
The fix is to invert the path so category is the root:
V1 (old): {tenant_id}/{category}/{YYYY-MM}/{file_id}
V2 (new): {category}/{tenant_id}/{YYYY-MM}/{file_id}
Now documents/ is a clean fixed prefix and a single lifecycle rule covers every tenant.
The constraint: the V1 files are already in production. Migrating objects in R2 — copying and re-keying millions of them — is expensive and risky. Non-starter. So the service has to read old files where they are and write new files where they belong, at the same time, forever, without anyone having to think about it.
This is where the UUIDv7 decision paid off. The version of the path is a pure function of the file’s timestamp versus a configurable cutover instant:
graph TD
Op["upload / download / delete"] --> G["getObjectPath(timestamp)"]
G --> Q{timestamp < cutover?}
Q -- yes --> V1["V1: tenant / category / YYYY-MM / id"]
Q -- no --> V2["V2: category / tenant / YYYY-MM / id"]
- On upload, the timestamp is “now” (from the freshly minted UUIDv7).
- On download/delete, the timestamp is the file’s creation time — read from metadata, or pulled straight from the UUIDv7 on the in-memory bulk path.
The same function gives the right answer for both, so reads and deletes of old files just work — no flag, no caller awareness, no schema change.
And the safety detail I care about most: the cutover (PATH_V2_CUTOVER_AT) defaults to a date in the far future (9999-12-31). With no configuration, every timestamp is before the cutover, so everything stays V1. The new behavior cannot switch on by accident in an environment where someone forgot to set the variable. Safe by default, opt-in by configuration, reversible by changing one env var before the next deploy. That’s the posture I want every risky change to have.
The tradeoff: I now carry two path schemes indefinitely, and the dual-path logic is a small permanent tax on anyone reading the code. But “permanent small tax” beats “one heroic, risky mass migration” — especially when the heroic option can corrupt production.
What I’d put on the highlight reel
- Control plane / data plane split. Keeping bytes off the service is the decision the others hang from. Small service, big files, no contradiction.
- Smallest interface that works. Three methods kept three storage backends honest and the business logic provider-blind — and made a fourth provider a leaf node.
- Config with a git history. Routing as an embedded, reviewable artifact removed a whole class of “who changed prod?” incidents — at the price of a redeploy I’m happy to pay.
- An ID that carries meaning. UUIDv7 turned a future migration from a schema project into a one-function change, and lets downloads be authorized with no database at all.
- Safe-by-default rollouts. The far-future cutover is a pattern I now reach for reflexively: new behavior should require a deliberate switch, never an accidental one.
And the quirks
- R2 lifecycle has no wildcards. This single limitation dictated the entire path layout. Read your object store’s lifecycle docs before you design your key scheme, not after you’re in production.
- Design the key scheme for the lifecycle limit — especially multi-tenant. Because rules take a fixed prefix (no wildcards) and a bucket caps lifecycle rules at 1000, put the category first —
category/tenant/date/— so a single rule likedocuments/rotates an entire category across every tenant, instead of needing one rule per tenant. And since the cap is per bucket, shard tenants into buckets sized to stay comfortably under it (a few hundred clients each), so a busy bucket never runs out of lifecycle rules. - Pre-signed URLs leak their headers. The signature covers headers, so the client must echo
required_headersexactly. Document this loudly or you’ll burn an afternoon on mystery 403s. - Two SDKs, one interface, leaky edges. S3 presigning and Azure SAS don’t behave identically; the common interface papers over it, but the seams show (which is partly why
requiredHeadersexists at all). - Embedded config means redeploy-to-change. Wonderful for resilience, painful the day you want to reroute a tenant at 2am. Make sure your change frequency actually matches this model.
- Secrets via
os.ExpandEnvover embedded JSON. It works and keeps secrets out of the binary, but it’s a convention you have to know — the config file looks complete while being useless without the right env vars present.
The takeaway
None of these decisions are clever in isolation. The valet key is a documented pattern. A three-method interface is just good taste. go:embed is a stdlib feature. UUIDv7 is an RFC. What made ROZ feel designed rather than assembled was choosing each one for an explicit reason, naming the tradeoff out loud, and letting earlier decisions pay dividends for later ones — UUIDv7 making the migration cheap is the clearest example.
If there’s one habit I’d pass on, it’s that last one: when you make a structural choice, write down what it costs you. The cost is the part you’ll forget, and it’s the part that tells the next person — often future you — whether the decision still holds.