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 lightest 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} — on the same key that resizes the image, sweeps the orphaned objects on a cron and captures the upload that failed, 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. The signature is what makes the object readable — strip the query string and the storage host answers 403 — but it says nothing about whose avatar it is. It is a bearer grant with a clock, so the “may this session see this user’s picture?” decision has to happen before you mint it. Derive the key server-side from the 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 honoured on bucket/create, and asking for one that isn’t provisioned is refused rather than quietly downgraded. Checked on 2026-07-27, a create with "region": "eu-central-1" comes back as a 400 that names the region you can actually have:

{
  "ok": false,
  "error": {
    "code": "INVALID_ARGUMENT",
    "http_status": 400,
    "message": "COS is physically provisioned in ap-singapore; requested region eu-central-1 is unavailable",
    "retryable": false
  }
}

For most small apps that’s a shrug — avatars are not personal data anyone audits placement for. 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, 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

Verified 2026-07-27: a write bills $0.0001 per call, and head, list, presign and both delete routes are free and rate-limited. New accounts start with $2 in credit.

Reads are the one that isn’t per call. object/get meters egress at $0.104 per GB, which for avatars turns the whole cost question into a rendition question: a 12 KB 256px WebP served on every page view and the 4 MB phone photo it was made from differ by more than two orders of magnitude on the same traffic. Storing one small variant at upload time is the entire optimisation. 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 of the account: POST /v1/image/resize makes the 256px variant, POST /v1/queue/publish runs that off the request path, POST /v1/cron/create sweeps the orphaned keys nightly and POST /v1/errors/capture records the upload that failed — every one of them already on the same key as the bucket, so the next step in this feature needs no second account and shows up on one bill.

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 the picker must upload straight from the browser, take R2 or S3: POST /v1/storage/bucket/set_cors/{bucket} does store rules here and bucket/get reads them back, but the storage host doesn’t yet answer a browser preflight with them, so the file goes through your server instead.

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