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 index, and object metadata doesn’t
object/put and set_metadata both take a metadata map, and it round-trips — write {"tenant-id":"tnt_42","uploaded-by":"u_9"} and head hands it back. Underscore keys are accepted as well and are normalised to hyphens on the way out, so compare against the hyphenated form rather than the string you sent.
Use it for provenance, not for lookup. Object metadata can only be read by listing and heading every key in the bucket, so a tenant document index built on it stops being usable somewhere around 50,000 files no matter how faithfully it stores. 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. Each item carries its stored content_type, size_bytes and etag, so a listing is enough to render a folder view without a head per row. It still isn’t an index: you can’t sort by upload date, filter by uploader or full-text anything, which is why the file browser in your app reads Postgres and uses the bucket only for bytes.
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 27 July 2026, and the two sides of the archive are metered in different units. Writes (storage.object.put) are counted: $0.0001 per call. Reads (storage.object.get) are measured: $0.104 per GB of response bytes. So download count is the wrong thing to budget against — a tenant pulling one 40 MB scanned contract costs more to serve than a hundred 200 KB invoices, and that ratio is stable however the rate moves. 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. Bytes at rest are metered per GB-month on top, and for a document archive that’s usually the dominant line. 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 the rest of the document workflow. POST /v1/queue/publish hands a freshly stored PDF to a renderer, POST /v1/cron/create schedules the retention sweep over the same prefixes, and POST /v1/email/send delivers the monthly statement — all on the key that wrote the object, with no second account and no second invoice to reconcile.