Avatar uploads: object storage, database blob, or local disk?
A small EU/US app should keep avatar bytes in a bucket and only the key in Postgres. The reasoning, a bytea migration script, and where residency rules bite.
Put the bytes in a bucket and keep only the key in your database. For a small app with users on both sides of the Atlantic, that’s the choice with the cheapest backups, the flattest memory profile and the fewest ways to lose a picture during a deploy. Infrai exposes the write as one authenticated call — PUT /v1/storage/object/put/{bucket}/{key} — so this is a column change and an HTTP request, not a project.
Local disk works right up to the morning you run two containers. A bytea column survives that, but every nightly dump then carries every profile picture, and image bytes start travelling through a connection pool sized for rows.
Three places a profile picture can live
| Bucket (object storage) | Postgres bytea / SQL BLOB | Local disk on the app box | |
|---|---|---|---|
| Second app instance | Fine | Fine | Breaks — the file is on one machine |
| Backup weight | Independent of the DB dump | Dump grows with every user | Usually backed up by nobody |
| Serving | Signed URL, no app CPU | App reads and streams it | Cheap until the box is busy |
| Atomic with the user row | No — needs a cleanup pass | Yes, commit or nothing | No |
| Practical ceiling | Anything | A few hundred KB, honestly | Prototypes |
The blob column has one durable advantage: the picture and the row commit or roll back together, so no user ever points at a key that isn’t there. Microsoft’s blob storage comparison puts the crossover around 256 KB, and avatars sit under that line — which is why the argument keeps coming back.
We’d still take the bucket, and pay for it with a nightly sweep that deletes keys no row references.
Moving avatars out of a bytea column
Here’s the migration, batched at 500 rows so a stalled run doesn’t hold a transaction open for an hour. Node 22, pg, no ORM.
-- before
ALTER TABLE users ADD COLUMN avatar_bytes bytea, ADD COLUMN avatar_type text;
-- after
ALTER TABLE users ADD COLUMN avatar_key text, ADD COLUMN avatar_etag text;
CREATE INDEX users_avatar_key_idx ON users (avatar_key);
import { Client } from "pg";
const API = "https://api.infrai.cc";
const BUCKET = "kb-avatar-store-0726";
const token = process.env.INFRAI_API_KEY;
if (!token) throw new Error("INFRAI_API_KEY is not set");
const db = new Client({ connectionString: process.env.DATABASE_URL });
await db.connect();
const { rows } = await db.query(
`SELECT id, avatar_bytes, avatar_type FROM users
WHERE avatar_bytes IS NOT NULL AND avatar_key IS NULL
ORDER BY id LIMIT 500`,
);
let moved = 0;
for (const row of rows) {
const objectKey = `avatars/u_${row.id}/256.webp`;
const payload = {
data_base64: row.avatar_bytes.toString("base64"),
content_type: row.avatar_type ?? "image/webp",
};
const res = await fetch(`${API}/v1/storage/object/put/${BUCKET}/${objectKey}`, {
method: "PUT",
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const json = await res.json();
if (!res.ok || json.ok === false) {
console.error(`user ${row.id}: ${json?.error?.code ?? `HTTP ${res.status}`}`);
continue;
}
await db.query(
"UPDATE users SET avatar_key = $1, avatar_etag = $2, avatar_bytes = NULL WHERE id = $3",
[json.data.key, json.data.etag, row.id],
);
moved += json.data.size_bytes;
}
console.log(`moved ${rows.length} avatars, ${(moved / 1024).toFixed(1)} KB`);
await db.end();
The data_base64 field is what makes this a single call instead of a two-step dance: you hand over the bytes and the API stores them. It also means the request body is about a third larger than the file, which nobody notices for a 12 KB WebP and everybody notices at 40 MB. That threshold is where multipart starts to matter.
Confirm the object exists before dropping the column
Never trust a migration log. head is free, transfers no body, and tells you what the bucket really holds:
export INFRAI_API_KEY="your_infrai_api_key"
curl -sS \
"https://api.infrai.cc/v1/storage/object/head/kb-avatar-store-0726/avatars/u_4821/256.webp" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"ok": true,
"data": {
"found": true,
"status": "found",
"key": "avatars/u_4821/256.webp",
"size_bytes": 11700,
"etag": "3adfc080d73df108435c904f5b9b8ed2",
"content_type": "image/webp",
"last_modified": "2026-07-26T00:58:38Z"
}
}
Worth flagging: a missing key still answers HTTP 200, with found: false. Check the field, not the status code.
Serving it without a public bucket
Mint a short-lived URL per page render and hand it to the <img> tag:
curl -sS -X POST \
"https://api.infrai.cc/v1/storage/object/presign/kb-avatar-store-0726/avatars/u_4821/256.webp" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"op":"get","expires_seconds":300}'
{
"ok": true,
"data": {
"url": "https://<vendor-host>/<bucket>/avatars/u_4821/256.webp?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Expires=300&X-Amz-Signature=...",
"expires_at": "2026-07-26T01:03:26.297431Z"
}
}
Now the caveat that matters more than any of this. A signature buys you expiry and a URL you can hand to a browser — it is not an authorisation check, and treating it as one is how avatars leak. Derive the key server-side from a session (never from the uploaded filename, per the OWASP file upload guidance), keep the TTL short, and remember the accepted range is 1 to 604800 seconds — seven days, no more, or you get STORAGE_INVALID_TTL.
The EU/US part people get wrong
region is accepted when you create a bucket and echoed back on GET /v1/storage/bucket/get/{bucket} — but in our testing on 26 July 2026, a bucket created with "region": "eu-central-1" returned signed URLs pointing at an ap-singapore vendor host. The field records intent, not placement.
For most small apps that’s a shrug. If your DPA promises European residency, or you’re relying on the EU-US transfer framework to hold a specific line, it’s disqualifying, and you’d be better off with S3 in eu-central-1 or Azure Blob in a European region, where placement is contractual and auditable. MinIO on hardware you rent in Frankfurt is the same answer with more sysadmin.
What the storage line item looks like
Per-call, and small. Verified 26 July 2026: a write bills $0.0001, a read through object/get bills $0.0002, and head, list, presign and both delete routes are free and rate-limited. New accounts start with $2 in credit, which is roughly 19,999 writes before you spend anything. Read today’s numbers yourself:
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.object')]"
Rates drift downward and campaigns run, so treat those as a reading rather than a constant. What doesn’t drift is the shape: bytes at rest and per-call operations are metered on the same account as the queue that resizes the image, the cron that sweeps orphans, and the error tracker that catches the failures — one bill, one usage query, no second vendor to onboard for the next feature.
curl -sS "https://api.infrai.cc/v1/storage/bucket/usage/kb-avatar-store-0726" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
When to pick something else
If you want faces detected and crops generated from a URL, Cloudinary sells that as a product and rebuilding it is a bad trade. Already on Supabase? Its storage client is a dependency you’ve paid for. And if browser-direct upload is a hard requirement, Cloudflare R2 or S3 will let you write CORS rules; these buckets currently have no route that does, so the preflight has nothing to match.
For “let people change their profile picture” on a Node backend in 2026, though: bucket for the bytes, key in the row, sweep for the orphans.