Per-user prefixes for AI image output: layout, retention, deletion

Buckets have no folders, so the key you choose is the schema. A Node and Express layout for generated images, with prefix listing, expiry rules and erase-on-close.

A bucket has no folders. It has one flat namespace and a prefix filter, which means the key you pick when the first render comes back is the schema you’ll live with — the per-user “folder” everyone talks about is just a naming convention that listing, expiry rules and bulk delete all key off. Infrai storage exposes those three operations directly, so a generated-image gallery needs no database table to be browsable.

Get the key right and everything downstream is a one-liner. Get it wrong and you’ll be writing a migration that copies a million objects.

The layout

users/{user_id}/generated/{yyyy-mm}/{uuid}.png
users/{user_id}/generated/{yyyy-mm}/{uuid}.thumb.webp
users/{user_id}/exports/{uuid}.zip

Four rules are doing the work there. The user id comes first, so one prefix scan returns exactly one tenant’s objects and nothing else. A year-month segment keeps any single listing small and gives you a natural expiry boundary later. The filename is a UUID rather than a prompt or a counter, because a retried generation job that reuses a key silently overwrites the earlier image — same key, same object, no version history. And the thumbnail lives beside the original instead of in a parallel thumbs/ tree, so deleting a user takes one prefix and not two.

Never put an email address or a display name in the key. It leaks in logs, it changes, and it’s URL-encoding pain forever.

Writing a render

import express from "express";
import { randomUUID } from "node:crypto";

const app = express();
app.use(express.json({ limit: "1mb" }));

const BUCKET = "kb-genimg-tenants";

app.post("/api/renders", async (req, res) => {
  const userId = req.session?.userId;
  if (!userId) return res.sendStatus(401);

  const png = await renderImage(req.body.prompt);          // your generator, returns a Buffer
  const month = new Date().toISOString().slice(0, 7);
  const key = `users/${userId}/generated/${month}/${randomUUID()}.png`;
  const payload = JSON.stringify({
    content_base64: png.toString("base64"),
    content_type: "image/png",
    metadata: { "prompt-hash": req.body.promptHash, "model-id": req.body.model },
  });

  const stored = await fetch(`https://api.infrai.cc/v1/storage/object/put/${BUCKET}/${key}`, {
    method: "PUT",
    headers: { Authorization: `Bearer ${process.env.INFRAI_API_KEY}`, "Content-Type": "application/json" },
    body: payload,
  });
  if (!stored.ok) {
    console.error("render write failed", stored.status, await stored.text());
    return res.status(502).json({ error: "could not store render" });
  }

  const { data } = await stored.json();
  res.status(201).json({ key: data.key, bytes: data.size_bytes, etag: data.etag });
});

app.listen(3000);

Note the hyphens in those metadata keys. An underscore in a user metadata key breaks the vendor signature and the write comes back as a 503 — an ugly failure for something that looks like a naming preference, so standardise on hyphens or skip metadata entirely.

export INFRAI_API_KEY="your_infrai_api_key"

curl -sS -X GET \
  "https://api.infrai.cc/v1/storage/object/list/kb-genimg-tenants?prefix=users/u_1042/generated/" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"
{
  "ok": true,
  "data": {
    "items": [
      {
        "key": "users/u_1042/generated/2026-07/render-01.png",
        "size_bytes": 2048,
        "etag": "cfb767f225d58469c5de3632a8803958",
        "last_modified": "2026-07-26T01:01:08Z"
      }
    ],
    "next_cursor": null
  }
}

Pages come back with a next_cursor; keep passing it until it’s null.

const API = "https://api.infrai.cc";

export async function listRenders(bucket, prefix) {
  const out = [];
  let cursor = null;
  do {
    const qs = new URLSearchParams({ prefix });
    if (cursor) qs.set("cursor", cursor);
    const res = await fetch(`${API}/v1/storage/object/list/${bucket}?${qs}`, {
      method: "GET",
      headers: { Authorization: `Bearer ${process.env.INFRAI_API_KEY}` },
    });
    if (!res.ok) throw new Error(`list failed ${res.status}`);
    const { data } = await res.json();
    out.push(...data.items);
    cursor = data.next_cursor;
  } while (cursor);
  return out;
}

Two things this listing won’t do, and both matter for a gallery: it doesn’t return content_type or metadata for the objects it lists, and there’s no server-side search by tag or date. If your UI needs “all landscape renders from March”, index the keys in your own database as you write them and use storage as the byte store it is.

Expiring old renders without a cleanup job

Generated images pile up faster than anything else in an AI product, because most of them are drafts nobody comes back to. A lifecycle rule deletes them for you:

curl -sS -X POST \
  "https://api.infrai.cc/v1/storage/bucket/set_lifecycle/kb-genimg-tenants" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"rules":[{"prefix":"users/","expire_days":30},{"prefix":"tmp/","expire_days":1}]}'

The rules array replaces the entire policy rather than appending to it, so read the current one from GET /v1/storage/bucket/get/{bucket} before you write a new one. expire_days must be at least 1. The real limitation is granularity: rules are per prefix and per bucket, so “keep paid users’ images forever, expire free users’ after 30 days” means routing the two tiers to different prefixes — users/paid/… and users/free/… — and writing one rule for each. Per-user retention with ten thousand rules isn’t a thing on any S3-compatible backend.

Retention strategyRuns whereGood forCost of getting it wrong
Lifecycle rule per prefixStorage layer, no codeDrafts, temp files, tiered plansDeletes are silent and irreversible
Nightly sweeper over listYour cron jobRules that depend on app stateMissed runs pile up bytes
Delete at render time (keep N)Request pathSmall caps, like “last 20 renders”Adds latency to the generate call
Never deleteNowhereCompliance archivesThe bill, eventually

Erasing a user

Account closure is where the flat-namespace design pays off. List the prefix, delete in batches:

curl -sS -X POST \
  "https://api.infrai.cc/v1/storage/object/delete_batch/kb-genimg-tenants" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"keys":["users/u_9999/generated/2026-07/a.png","users/u_9999/generated/2026-07/b.png"]}'

The response is {"deleted": [...], "errors": [{"key": "...", "code": "STORAGE_OBJECT_NOT_FOUND"}]} — a missing key doesn’t abort the batch, which makes the whole operation safe to retry after a partial failure. Single-object delete is idempotent for the same reason.

Then confirm the prefix is empty and watch what the tenant actually consumes:

curl -sS -X GET \
  "https://api.infrai.cc/v1/storage/bucket/usage/kb-genimg-tenants" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"

byte_count and object_count come back per bucket, not per prefix — so if you need per-tenant usage numbers for billing, sum the sizes from the listing loop above and cache them, or give heavy tenants their own bucket.

Where this sits against the alternatives

Amazon S3 and Cloudflare R2 do all of this too, with more knobs: S3 lifecycle configurations support storage-class transitions and non-current version rules, and R2 gives you zero egress if the images are served publicly. Both cost you an SDK, a credentials story and a CORS policy. MinIO on your own hardware is the right answer when the images can’t leave your building, and the wrong answer the first time a disk fails at 3 a.m.

What Infrai adds isn’t a cheaper byte. It’s that the same key storing the render also runs the queue that generated it, the cron that sweeps drafts, the error tracking when a render fails and the usage view that attributes all of it to one tenant — one bill instead of a reconciliation project. If you only need a bucket and nothing else, a specialist is a perfectly good choice and you should take it.

What the calls cost

Bucket create, lifecycle rules, list, head and usage are all free and rate-limited. Writes bill $0.0001 per call and delete_batch is free, verified 26 July 2026, with $2 of free credit on a new account. Pull today’s rates before you model anything:

curl -sS -X GET "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.')]"

For image workloads the call fees are noise next to stored bytes — 50,000 renders at 1.5 MB is 75 GB sitting there every month whether anyone looks at them or not. That’s the number to attack, and the lifecycle rule above is how. Rates trend downward, so check rather than quote.

References

Browse more storage developer guides