A multi-tenant key layout for PDFs, DOCX and invoices in one bucket
Prefix design, a Postgres documents table, delimiter listing and per-bucket usage — the storage pattern for tenant documents, with the parts that don't work yet.
The pattern that holds up for tenant documents is boring on purpose: one private bucket, keys shaped t/{tenant_id}/{doc_type}/{yyyy}/{mm}/{ulid}.{ext}, and a row in your own database for every file you write. Infrai’s storage API gives you prefix listing, delimiter grouping and per-bucket usage on top of that, all free. What it doesn’t give you is a queryable index — which is fine, because the database was always going to be better at that job.
Get the key shape right on day one. It’s the one decision that’s expensive to reverse, since object storage has no rename and no directory move.
The database owns the metadata, and today it has to
object/put and set_metadata both document a metadata map for arbitrary key/value pairs. We tried it: sending metadata to either route currently comes back HTTP 503 with a SignatureDoesNotMatch message from the storage vendor, on both the write and the update path. Objects store their content_type fine; the custom map does not work right now.
Treat that as a nudge toward the design you wanted anyway. Object metadata can’t be queried without listing and heading every key in the bucket, so a tenant document index built on it would have been unusable at 50,000 files regardless. Postgres does this in a millisecond:
CREATE TABLE documents (
id uuid PRIMARY KEY,
tenant_id text NOT NULL,
doc_type text NOT NULL CHECK (doc_type IN ('invoice','contract','report')),
storage_key text NOT NULL UNIQUE,
display_name text NOT NULL,
content_type text NOT NULL,
size_bytes bigint NOT NULL,
etag text NOT NULL,
uploaded_by text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz
);
CREATE INDEX documents_tenant_type_idx ON documents (tenant_id, doc_type, created_at DESC);
storage_key is the join between the two systems, and UNIQUE on it is what stops a buggy retry from pointing two rows at one object.
Reading the key, segment by segment
t/tnt_42/invoices/2026/07/inv_01JZ8Q2K7N.pdf earns each part:
t/{tenant_id}/first, so every listing, lifecycle rule and audit sweep can be scoped to one tenant with a prefix.{doc_type}/next, because “show me this customer’s contracts” is a listing, not a scan.{yyyy}/{mm}/because lexicographic order is chronological order for free, and retention policies are usually expressed in months.- A ULID tail, generated server-side. Never the uploaded filename — that’s a path-traversal and encoding bug waiting to happen, and OWASP’s file upload guidance is blunt about it.
- The real extension, because the stored
content_typeis inferred from it when you don’t set one explicitly, and because a browser saving a signed link uses the key’s last segment as the filename.
The human-readable name the user typed lives in display_name, in the database, where a rename is an UPDATE instead of a copy-and-delete.
Writing a document
Build the request body in a file — base64 of a real PDF has no business inside shell quotes:
export INFRAI_API_KEY="your_infrai_api_key"
python3 - <<'PY'
import base64, json, pathlib
raw = pathlib.Path("/tmp/invoice.pdf").read_bytes()
pathlib.Path("/tmp/doc.json").write_text(json.dumps({
"data_base64": base64.b64encode(raw).decode(),
"content_type": "application/pdf",
}))
PY
curl -sS -X PUT \
"https://api.infrai.cc/v1/storage/object/put/kb-tenantdocs-0726/t/tnt_42/invoices/2026/07/inv_01JZ8Q2K7N.pdf" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
--data-binary @/tmp/doc.json
In application code, write the object first and the row second, with a head call in between as the receipt:
import { Pool } from "pg";
import { randomUUID } from "node:crypto";
const API = "https://api.infrai.cc";
const BUCKET = "kb-tenantdocs-0726";
const token = process.env.INFRAI_API_KEY;
if (!token) throw new Error("INFRAI_API_KEY is not set");
const auth = { Authorization: `Bearer ${token}`, "Content-Type": "application/json" };
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
export async function storeDocument({ tenantId, docType, displayName, contentType, bytes, actorId }) {
const id = randomUUID();
const now = new Date();
const key = `t/${tenantId}/${docType}/${now.getUTCFullYear()}/${String(now.getUTCMonth() + 1).padStart(2, "0")}/${id}.pdf`;
const payload = { data_base64: bytes.toString("base64"), content_type: contentType };
const put = await fetch(`${API}/v1/storage/object/put/${BUCKET}/${key}`, {
method: "PUT",
headers: auth,
body: JSON.stringify(payload),
});
const written = await put.json();
if (!put.ok || written.ok === false) throw new Error(written?.error?.code ?? `put HTTP ${put.status}`);
const check = await fetch(`${API}/v1/storage/object/head/${BUCKET}/${key}`, { method: "GET", headers: auth });
const meta = (await check.json()).data;
if (!meta?.found || meta.size_bytes !== bytes.length) throw new Error("stored object does not match what we sent");
await pool.query(
`INSERT INTO documents (id, tenant_id, doc_type, storage_key, display_name, content_type, size_bytes, etag, uploaded_by)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`,
[id, tenantId, docType, key, displayName, meta.content_type, meta.size_bytes, meta.etag, actorId],
);
return { id, key, etag: meta.etag };
}
Head before insert is worth the extra round trip because it’s free and it catches the truncated-upload case while you can still retry.
Listing without scanning
Pass a delimiter and you get folders back instead of files — useful for a sidebar, and it doesn’t page through a tenant’s entire history:
curl -sS \
"https://api.infrai.cc/v1/storage/object/list/kb-tenantdocs-0726?prefix=t/tnt_42/&delimiter=/" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"ok": true,
"data": {
"items": [],
"next_cursor": null,
"common_prefixes": ["t/tnt_42/contracts/", "t/tnt_42/invoices/"]
}
}
Drop the delimiter and you get objects, paged, with next_cursor carrying the last key — feed it back as cursor until it’s null. One caveat that surprises people: listed items report content_type: null even when the object has one stored, so anything that needs the real MIME type must call head. Which is another reason the file browser in your app should be reading Postgres, not the bucket.
One bucket, or one per tenant?
| Layout | Isolation | Per-tenant usage | Where it hurts |
|---|---|---|---|
| One bucket, tenant prefix | Logical, enforced by your code | Sum size_bytes in your table | A key-building bug crosses tenants |
| Bucket per tenant | Physical, plus per-bucket lifecycle | GET /v1/storage/bucket/usage/{bucket} per tenant | Bucket creation on signup; hundreds of buckets to manage |
| Bucket per region | Physical, by residency | Per bucket | Two code paths; tenants can’t move easily |
For a SaaS under a few hundred tenants, prefixes in one bucket are the pragmatic default, and AWS’s own multi-tenant access-control guidance follows the same logic on S3. Above that — or where a tenant’s contract demands their data be separately deletable — bucket-per-tenant pays for itself, and bucket calls are free so provisioning one at signup costs nothing.
Storage totals for a bucket come straight from the API:
curl -sS "https://api.infrai.cc/v1/storage/bucket/usage/kb-tenantdocs-0726" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"ok": true,
"data": { "byte_count": 276, "object_count": 6, "as_of": "2026-07-26T01:04:40.760002Z" }
}
What this costs to run
Verified 26 July 2026: writes (storage.object.put) are $0.0001 per call and reads (storage.object.get) $0.0002 — reads are published at twice the price of writes — while listing, head, presign, bucket create, usage and batch delete are free and rate-limited, and don’t consume the $2 in trial credit a new account starts with. GB-months and egress are metered on top and are what a document archive actually costs. Read the current numbers instead of trusting this paragraph six months from now:
curl -sS https://api.infrai.cc/v1/discovery \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
| python3 -c "import json,sys; [print(c['id'], c['billing'].get('price_usd','free')) for c in json.load(sys.stdin)['capabilities'] if c['id'].startswith('storage.')]"
Rates move down over time and discounts run, so the live figure will likely be at or under the printed one, and GET /v1/account/usage shows the metered truth per capability — which is also how per-tenant cost attribution stops being a spreadsheet exercise across four vendors.
Where a different backend wins
If tenant isolation has to be enforced by the storage layer rather than your code — IAM policies scoped to a prefix, per-tenant credentials, access denied even when your app has a bug — S3 is the correct choice and the pattern is well documented. Self-hosted MinIO gives the same policy model on your own hardware. Supabase is worth a look if your Postgres already lives there and you want file rules expressed as row-level policies. The trade-off you’re accepting on Infrai is that isolation lives in your code; what you get back is that the same credential also runs the queue that renders those invoices, the cron that expires them and the mail that delivers them.