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 BLOBLocal disk on the app box
Second app instanceFineFineBreaks — the file is on one machine
Backup weightIndependent of the DB dumpDump grows with every userUsually backed up by nobody
ServingSigned URL, no app CPUApp reads and streams itCheap until the box is busy
Atomic with the user rowNo — needs a cleanup passYes, commit or nothingNo
Practical ceilingAnythingA few hundred KB, honestlyPrototypes

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.

References

Browse more storage developer guides