Where generated images belong: local disk, Postgres bytea, or a bucket
Why container disks and bytea columns both fail for AI image output, and the smallest object-storage path that works in production, with runnable Infrai calls.
Your image model hands back a base64 PNG. Three places it could go, and only one of them survives contact with production: the container’s disk, a bytea column, or an object store. Object storage wins, and with Infrai it’s a single PUT /v1/storage/object/put/{bucket}/{key} carrying the same base64 string the model just gave you — no SDK, no bucket policy, no signing code.
That’s the short answer. The long answer is worth reading because the two losing options fail in ways that don’t show up until you’re a few months in.
Local disk disappears, and it was never shared anyway
On Heroku the filesystem is ephemeral by design — anything written to it is gone at the next restart or deploy, and every dyno has its own copy. Containers on ECS, Fly or Cloud Run behave the same way. A generated image that exists on one instance’s disk is a 404 for any request the load balancer sends elsewhere.
You can bolt a persistent volume onto some of these platforms. Then you’ve got a single writer, a snapshot problem, and a scaling ceiling — which is the moment most teams go looking for a bucket anyway.
Postgres will happily hold it, and that’s the trap
bytea works. That’s the problem: it works well enough for the first thousand images. Then the numbers catch up with you. Postgres pushes any value over roughly 2 KB out of line into a TOAST table, split into chunks of about 2,000 bytes each, so one 1.5 MB PNG becomes several hundred TOAST rows. Multiply by a few hundred thousand images and consider what that does to your pg_dump, your WAL volume, your replica lag and the time it takes to restore.
There’s no range read, either — you fetch the whole value or none of it, and it arrives through the same connection pool your queries need. Keep the metadata in Postgres: prompt, model, user id, dimensions, and the object key. That row is a few hundred bytes and it’s what you actually query on.
The bucket call
export INFRAI_API_KEY="your_infrai_api_key"
printf '{"data_base64":"%s","content_type":"image/png"}' "$(base64 < render.png | tr -d '\n')" > img.json
curl -sS -X PUT \
"https://api.infrai.cc/v1/storage/object/put/kbs6-genimg-0726/generated/2026/07/img_0d41f9.png" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
--data-binary @img.json
{
"ok": true,
"data": {
"bucket_id": "bkt_67aee799e81f488baedb62",
"key": "generated/2026/07/img_0d41f9.png",
"size_bytes": 25,
"etag": "80c40fb6cf66988e46ac53e3ec23eb71",
"content_type": "image/png",
"metadata": null
}
}
If the model returned b64_json, you already have the string — no decode, no re-encode, no temp file. Buckets here are private and acl accepts nothing else, so there’s no public-read setting to forget.
Generate and store in one pass
The image endpoint is OpenAI-compatible, which means the same request shape you’d send to POST /v1/images/generations anywhere else, and the storage write is the next line.
const BASE = "https://api.infrai.cc";
const BUCKET = "kbs6-genimg-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" };
export async function renderAndStore(prompt, imageId) {
const request = { model: "auto", prompt, n: 1, size: "1024x1024", response_format: "b64_json" };
const gen = await fetch("https://api.infrai.cc/v1/images/generations", {
method: "POST",
headers: auth,
body: JSON.stringify(request),
signal: AbortSignal.timeout(120000),
});
if (!gen.ok) throw new Error(`generation failed with HTTP ${gen.status}`);
const image = (await gen.json()).data[0];
if (!image?.b64_json) throw new Error("no b64_json in the generation response");
const key = `generated/2026/07/${imageId}.png`;
const payload = { data_base64: image.b64_json, content_type: "image/png" };
const put = await fetch(`${BASE}/v1/storage/object/put/${BUCKET}/${key}`, {
method: "PUT",
headers: auth,
body: JSON.stringify(payload),
signal: AbortSignal.timeout(60000),
});
const stored = await put.json();
if (!stored.ok) throw new Error(`${stored.error.code}: ${stored.error.message}`);
return { key, etag: stored.data.etag, bytes: stored.data.size_bytes };
}
console.log(await renderAndStore("a tabby cat reading a map", "img_0d41f9"));
model: "auto" routes to whatever’s available rather than pinning you to one vendor, which is the part that keeps this from becoming a rewrite when you want a different renderer next quarter.
The metadata row
CREATE TABLE generated_images (
id text PRIMARY KEY,
user_id text NOT NULL,
prompt text NOT NULL,
model text NOT NULL,
object_key text NOT NULL UNIQUE,
bytes integer NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX generated_images_user_created ON generated_images (user_id, created_at DESC);
Small rows, fast queries, and a dump you can restore in minutes rather than hours.
The retry that quietly keeps the old picture
Worth flagging before you wrap the write in a retry helper: if you attach an idempotency_key and later reuse it with different bytes, the second call returns ok: true while echoing the first object’s size and etag — the new image is discarded. Derive that key from a hash of the image data, or leave it off and let the object key carry uniqueness.
Reading it back
Two ways out. Pull the bytes through the API when your app needs them server-side:
curl -sS \
"https://api.infrai.cc/v1/storage/object/get/kbs6-genimg-0726/generated/2026/07/img_0d41f9.png" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
That returns {"found": true, "size_bytes": …, "data_base64": "…"}, which is convenient and wasteful for anything large. For browser delivery, sign a short-lived URL instead and let the client fetch it directly:
curl -sS -X POST \
"https://api.infrai.cc/v1/storage/object/presign/kbs6-genimg-0726/generated/2026/07/img_0d41f9.png" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"op": "get", "expires_seconds": 600}'
op takes get or put and nothing else — pass a different value and you get a download URL back with no error, which is a confusing half-hour if you were expecting an upload slot. Keep the “is this viewer allowed to see this render?” decision in your own code; the signature moves bytes, it doesn’t enforce ownership.
Side by side
| Object storage | Postgres bytea | Container disk | |
|---|---|---|---|
| Survives a deploy | yes | yes | no |
| Shared across instances | yes | yes | no |
| Effect on backups | none | dumps and WAL grow with every image | n/a |
| Range reads / streaming | yes, via signed URL | no | yes |
| Cost per GB | low | your most expensive storage tier | included, until it isn’t |
| Sensible use | production images | tiny thumbnails, under 100 KB | scratch files inside one request |
Cloudflare R2 and S3 are equally valid destinations for this and both have far bigger ecosystems — if you need browser-direct uploads today, take one of them, because there’s no route to set bucket CORS here and the preflight will fail. The reason to look at Infrai is narrower and, we think, more useful: the key that generated the image is the same key that stores it, queues the retry and emails the user when the batch finishes.
What it costs
Writes bill $0.0001 per storage.object.put call and presign is free, both verified 2026-07-26 against real metered usage rather than a rate card. Reads are metered per call at a figure well under a hundredth of a cent, and a new account starts with $2 of credit. Generation itself is billed by the image and dwarfs the storage line by orders of magnitude.
curl -sS "https://api.infrai.cc/v1/account/usage" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
| jq '.data.breakdown[] | select(.key | startswith("storage."))'
Rates move down and campaigns run, so read your own meter rather than this paragraph. The structural point survives any repricing: storing a render costs a rounding error next to producing it, which is the strongest argument against ever keeping generated images in your database to save a call.