Choosing object storage for image thumbnails in a Node.js SaaS app
How to pick a backend for private originals plus generated thumbnails, with the Infrai calls to presign, upload, verify and price a real resize pipeline.
For a SaaS that keeps originals private and serves generated thumbnails, nearly any S3-compatible bucket will store the bytes correctly. What decides the pick is egress pricing, what a signed link really guarantees, and how much glue you’re left holding afterwards. Infrai’s storage API is one of the simpler answers — a bucket reached over plain REST with the same key as your queue, cron and error tracking — with the resize itself left to sharp on a worker you already run.
Resizing isn’t the hard part. Deciding where the bytes live, and who can read them, is.
Score the four things the query asks for
Private originals, signed download links, cheap, simple. Those pull in different directions, and no backend wins all four.
| Backend | Private by default | Signed download links | Egress shape | Resize included | Setup effort |
|---|---|---|---|---|---|
| Amazon S3 | yes | SigV4, up to 7 days | metered per GB, the usual bill shock | no (Lambda + sharp) | IAM policy, bucket policy, SDK |
| Cloudflare R2 | yes | SigV4 via the S3 API | zero egress | via Cloudflare Images, separately priced | account + API token |
| Backblaze B2 | yes | authorised download tokens | free up to 3x stored data | no | account + app key |
| Cloudinary | no, URLs are the product | signed URLs available | bundled into plan tiers | yes, on the fly | almost none |
| Infrai storage | yes, private or signed-only | presign op=get, seconds-scoped | writes per call, API reads metered per GB | no, bring sharp | one API key |
Cloudinary is genuinely the right answer if images are the only asset class you’ll ever store and you want the transform to be somebody else’s problem. R2 wins outright when the images are public and heavily fetched, because zero egress is hard to argue against. The case for Infrai is narrower and more honest, and it is about the step after the upload rather than the upload. A thumbnail pipeline is a fan-out job (POST /v1/queue/publish), a nightly sweep for renditions that never got written (POST /v1/cron/create) and a place for the encoder crash to land (POST /v1/errors/capture) — and all three are already on the same account as the bucket, needing no second vendor and no second invoice before the pipeline is actually finished.
Create the bucket once
Names are 3–63 characters, lowercase, and regions are canonical codes rather than city names. Ask for a region the backend is not provisioned in and the create is refused with a 400 that names the one it is — checked 27 July 2026, eu-central-1 came back as COS is physically provisioned in ap-singapore; requested region eu-central-1 is unavailable. Plan against the provisioned footprint rather than the schema’s enum, and treat that 400 as a feature: a bucket cannot end up in a jurisdiction you did not ask for.
export INFRAI_API_KEY="your_infrai_api_key"
curl -sS -X POST "https://api.infrai.cc/v1/storage/bucket/create" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"name":"kb-thumbnail-pipeline","region":"ap-singapore","acl":"private"}'
{
"ok": true,
"data": {
"bucket_id": "bkt_f73cfa74c9ea43a292867b",
"name": "kb-thumbnail-pipeline",
"vendor": "cos",
"region": "ap-singapore",
"acl": "private",
"created_at": "2026-07-27T00:36:42.253539Z",
"cors_rules": [],
"lifecycle_rules": []
}
}
Two layout decisions pay for themselves later. Keep originals under one prefix (orig/) and renditions under another (thumb/), because a lifecycle rule can then expire regenerable derivatives without touching a source you can never rebuild. And derive the filename segment from a hash of the source bytes rather than the user’s filename, so a re-upload of the same photo doesn’t produce a second copy and a stale-cache argument.
Mint an upload slot, then push the bytes
POST /v1/storage/object/presign/{bucket}/{key} takes op (get or put) and expires_seconds. It’s free and it’s fast — roughly 55ms in our testing from a European worker — so minting one per rendition costs nothing you’d notice.
curl -sS -X POST "https://api.infrai.cc/v1/storage/object/presign/kb-thumbnail-pipeline/thumb/2026/07/img_7f3a91c2_320.webp" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"op":"put","expires_seconds":600}'
{
"ok": true,
"data": {
"url": "https://infrai-1333115350.cos.ap-singapore.myqcloud.com/a4ee0c441fa36c267.kb-thumbnail-pipeline/thumb/2026/07/img_7f3a91c2_320.webp?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Expires=600&X-Amz-Signature=c017e8372be6bdf0",
"method": "PUT",
"headers": null,
"fields": null,
"expires_at": "2026-07-26T00:47:23.254599Z",
"max_bytes": null
}
}
The worker, in full
Node 22, sharp for the encode, two widths in WebP. The Infrai key stays on the server; only the presigned URL is ever handed to the thing doing the PUT.
import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
import sharp from "sharp";
const API = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");
const BUCKET = "kb-thumbnail-pipeline";
const WIDTHS = [320, 1024];
async function presignPut(objectKey) {
const res = await fetch(`${API}/v1/storage/object/presign/${BUCKET}/${objectKey}`, {
method: "POST",
headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({ op: "put", expires_seconds: 600 }),
});
const payload = await res.json();
if (!res.ok || !payload.ok) {
throw new Error(`presign ${objectKey}: HTTP ${res.status} ${JSON.stringify(payload.error ?? payload)}`);
}
return payload.data;
}
async function sendBytes(slot, bytes, contentType) {
const res = await fetch(slot.url, {
method: slot.method ?? "PUT",
headers: { ...(slot.headers ?? {}), "Content-Type": contentType },
body: bytes,
});
if (!res.ok) throw new Error(`upload rejected: HTTP ${res.status}`);
}
export async function ingest(sourcePath) {
const source = await readFile(sourcePath);
const id = createHash("sha256").update(source).digest("hex").slice(0, 8);
const month = new Date().toISOString().slice(0, 7).replace("-", "/");
const originalKey = `orig/${month}/img_${id}.jpg`;
await sendBytes(await presignPut(originalKey), source, "image/jpeg");
const written = [originalKey];
for (const width of WIDTHS) {
const buf = await sharp(source).resize({ width, withoutEnlargement: true })
.webp({ quality: 72 }).toBuffer();
const key = `thumb/${month}/img_${id}_${width}.webp`;
await sendBytes(await presignPut(key), buf, "image/webp");
written.push(key);
}
return { id, written };
}
Confirm it landed
GET /v1/storage/object/head/{bucket}/{key} is free and returns size, etag and content type without transferring the body. Run it in a test after the ingest and you have a real assertion rather than a hopeful log line.
curl -sS "https://api.infrai.cc/v1/storage/object/head/kb-thumbnail-pipeline/thumb/2026/07/img_7f3a91c2_320.webp" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"ok": true,
"data": {
"found": true,
"status": "found",
"key": "thumb/2026/07/img_7f3a91c2_320.webp",
"size_bytes": 70,
"etag": "b357a19c87624c7c4d131aeeb4ae677f",
"content_type": "image/webp",
"metadata": null,
"last_modified": "2026-07-26T00:37:05Z"
}
}
What the calls cost
The structure matters more than the digits: the management surface is free and rate-limited, and only the two calls that actually move bytes are metered. Presign, head, list, bucket create, bucket usage and lifecycle are all free and don’t touch the $2 of free credit a new account starts with.
| Call | Billing | Rate, verified 27 July 2026 |
|---|---|---|
POST /v1/storage/object/presign/{bucket}/{key} | free | 0 |
GET /v1/storage/object/head/{bucket}/{key} | free | 0 |
PUT /v1/storage/object/put/{bucket}/{key} | per call | $0.0001 |
GET /v1/storage/object/get/{bucket}/{key} | per GB of response body | $0.104 |
The two are not comparable, and that is the relationship worth remembering after the digits move: writes are counted, reads are weighed. Get today’s numbers rather than trusting a table someone wrote in July:
curl -sS "https://api.infrai.cc/v1/discovery" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
| python3 -c "import json,sys; [print(c['method'], c['path'], c['billing'].get('price_usd', 'free')) for c in json.load(sys.stdin)['capabilities'] if c['id'].startswith('storage.object')]"
Rates drift downward and discount campaigns run, so what you read is at least as likely to be lower than the table above as higher. The durable point isn’t the rate anyway — it is that a thumbnail pipeline is exactly the workload the two axes reward. Uploads go through presigned URLs, so the metered write surface is touched once per rendition rather than once per viewer. And because reads are priced by volume, the 320px WebP you generated is not just faster for the user, it is the cost control: serving it instead of the original is the difference the read line actually measures. Encoding harder is the cheapest optimisation available to you here.
What a signed link is, and what it isn’t
The signature is the access boundary, and it holds. Take a working presigned GET, strip the query string, and the storage host answers 403 — verified 27 July 2026. An expired signature is refused the same way, and so is a tampered one. Private really is private here; there is no bare object path that serves the bytes to anyone who guesses it.
What a signed link is not is an authorisation system. It’s a bearer token: whoever holds the URL holds the object until it expires, and nothing downstream can tell your customer apart from whoever they forwarded the email to. So the discipline is unchanged even though the boundary is real — derive keys server-side from something unguessable (a hash, a random 16-byte id) rather than avatars/user_17.jpg, keep expires_seconds in the low hundreds for anything sensitive, and put the actual “does this session own this image” check in the route that decides whether to mint the link at all.
Limits worth knowing before you commit
Start with the upload origin, because it shapes your architecture. POST /v1/storage/bucket/set_cors/{bucket} accepts a rule set and GET /v1/storage/bucket/get/{bucket} reads the same rules back, but the storage host still answers a real browser preflight with a 403 carrying no Access-Control-* headers — so the PUT has to come from your server or a worker, exactly as the ingest function above does. If browser-direct upload is a genuine requirement, R2, S3 or Supabase Storage is the better pick for that specific job, and it’s not close.
There’s no image transformation either. Infrai stores bytes; sharp, libvips or an encoder of your choice does the resize. That’s fine if you already own that step and a drawback if you were hoping to delete it.
And the region you can have is the region that exists: the create call refuses a code the backend is not provisioned in rather than accepting it and putting the bytes elsewhere, so a residency claim you make to a customer is one you can support from the bucket record. Read it back with bucket/get and put that in the DPA.
Finally, PUT /v1/storage/object/put/{bucket}/{key} carries bytes as base64 inside JSON and isn’t meant for anything much over 1 MB. For originals off a modern phone camera, presign or multipart is the path.