Resize with sharp, store in a private bucket, hand back a signed URL
A Node 22 thumbnail worker end to end: pull the original, resize it, write the WebP to a private bucket, and sign a short-lived download link.
Thumbnail generation is three calls and one library. Pull the original bytes, run sharp().resize().webp() in your worker, write the result back under a thumbs/ prefix, then sign a download URL when a page actually needs one. On Infrai every one of those steps is plain REST with the same bearer token, so there’s no SDK to install for the storage half and no IAM policy to write.
The numbers from our own fixture set the expectation: a 640x480 PNG of 102,936 bytes became a 256x256 WebP of 1,370 bytes. That’s the reason to bother — you’re not saving storage, you’re saving 100 KB of transfer on every avatar in a list view.
What each step actually returns
Reading an object gives you JSON, not a byte stream. GET /v1/storage/object/get/{bucket}/{key} returns found, size_bytes and data_base64, which suits a worker that’s about to hand a Buffer to the resizer anyway.
export INFRAI_API_KEY=your_infrai_api_key
curl -s -X GET \
"https://api.infrai.cc/v1/storage/object/get/kb-thumbs-node22/originals/u_8412/portrait.png" \
-H "Authorization: Bearer $INFRAI_API_KEY"
Note the key keeps its slashes. originals/u_8412/portrait.png is one path parameter, not three segments you have to escape — buckets have no directories, and the slash is just a character in the name.
The worker, complete
Node 22, ESM, sharp 0.33+. It reads, resizes, writes and signs, and it fails loudly on the ok: false envelope instead of trusting the HTTP status.
import sharp from "sharp";
import process from "node:process";
const BASE = "https://api.infrai.cc";
const TOKEN = process.env.INFRAI_API_KEY;
if (!TOKEN) throw new Error("INFRAI_API_KEY is missing");
const BUCKET = "kb-thumbs-node22";
const headers = { authorization: `Bearer ${TOKEN}`, "content-type": "application/json" };
async function api(path, init) {
const res = await fetch(`${BASE}${path}`, init);
const payload = await res.json();
if (!payload.ok) {
throw new Error(`${path}: ${payload.error?.code ?? res.status} ${payload.error?.message ?? ""}`);
}
return payload.data;
}
async function makeThumbnail(sourceKey, thumbKey, edge = 256) {
const source = await api(`/v1/storage/object/get/${BUCKET}/${sourceKey}`, { method: "GET", headers });
if (!source.found) throw new Error(`missing original: ${sourceKey}`);
const original = Buffer.from(source.data_base64, "base64");
const thumb = await sharp(original)
.resize(edge, edge, { fit: "cover", position: "attention" })
.webp({ quality: 80 })
.toBuffer();
await api(`/v1/storage/object/put/${BUCKET}/${thumbKey}`, {
method: "PUT",
headers,
body: JSON.stringify({ data_base64: thumb.toString("base64"), content_type: "image/webp" }),
});
const link = await api(`/v1/storage/object/presign/${BUCKET}/${thumbKey}`, {
method: "POST",
headers,
body: JSON.stringify({ op: "get", expires_seconds: 600 }),
});
return { bytesIn: original.length, bytesOut: thumb.length, url: link.url, expiresAt: link.expires_at };
}
const result = await makeThumbnail("originals/u_8412/portrait.png", "thumbs/u_8412/portrait_256.webp");
console.log(`${result.bytesIn} -> ${result.bytesOut} bytes, link expires ${result.expiresAt}`);
position: "attention" is the one sharp option worth arguing about — it crops toward the busiest region instead of the centre, which stops a fit: "cover" square from decapitating everyone in your user list.
If you’d rather not ship sharp at all
sharp pulls libvips, and on some serverless images that’s a 30 MB layer and a cold start you’d rather not pay. The same account can do the resize server-side through POST /v1/image/process, which takes an ordered ops pipeline and re-encodes once at the end.
node -e 'const fs=require("fs");const b=fs.readFileSync("portrait.png").toString("base64");fs.writeFileSync("op.json",JSON.stringify({image:{base64:b},ops:[{op:"resize",params:{width:256,height:256,fit:"cover"}}],format:"webp"}))'
curl -s -X POST "https://api.infrai.cc/v1/image/process" \
-H "Authorization: Bearer $INFRAI_API_KEY" \
-H "Content-Type: application/json" \
--data-binary @op.json
The response tells you what it did, and this is where the rough edges are:
{
"ok": true,
"data": {
"image_id": "pim_757c18b2a433257fc1f8639c",
"url": "data:image/webp;base64,UklGRlIFAABXRUJQVlA4IEYFAAAwKACdASoAAQAB",
"format": "webp",
"width": 256,
"height": 256,
"size_bytes": 1370,
"ops_applied": ["resize(256x256,fit=cover)", "format_convert(webp,q=90)"]
}
}
That url is a data URL, not a hosted asset — even with store: true in the request, what comes back is the bytes inline, so you still write them to your bucket yourself. Two more caveats we hit while checking this: the narrower POST /v1/image/resize route accepts a format field and ignores it (ask for WebP, get PNG back at 665 bytes), and both routes want an image reference shaped as {"base64": "..."} rather than a bare string, or you get IMAGE_INPUT_INVALID. Use /v1/image/process when you need a format change.
| sharp in your worker | POST /v1/image/process | Cloudinary-style image CDN | |
|---|---|---|---|
| Where the CPU burns | your container | the platform | the vendor’s edge |
| Install cost | libvips in the image | none | none |
| Output lands in your bucket | you write it | you write it | usually theirs |
| Per-image charge | none beyond compute | free, rate-limited | metered per transform |
| Good for | batch backfills, exact control | serverless, no native deps | on-the-fly variants by URL |
Sign the link, don’t publish the bucket
Buckets are private by default, so the thumbnail has no public URL. POST /v1/storage/object/presign/{bucket}/{key} returns url and expires_at, and the download works for anyone holding that URL until it expires. op takes exactly two values, get and put; anything else comes back as a 400, so a typo fails at the presign rather than at the transfer.
curl -s -X POST \
"https://api.infrai.cc/v1/storage/object/presign/kb-thumbs-node22/thumbs/u_8412/portrait_256.webp" \
-H "Authorization: Bearer $INFRAI_API_KEY" \
-H "Content-Type: application/json" \
-d '{"op":"get","expires_seconds":600}'
Then confirm the object is really there before a template renders an <img> pointing at it:
curl -s -X GET \
"https://api.infrai.cc/v1/storage/object/head/kb-thumbs-node22/thumbs/u_8412/portrait_256.webp" \
-H "Authorization: Bearer $INFRAI_API_KEY"
found: true, size_bytes: 1370, content_type: "image/webp". HEAD is free, so use it liberally in tests.
Cost, and the limitation you should plan around
PUT /v1/storage/object/put/{bucket}/{key} meters at $0.0001 per call — verified 2026-07-27 — while head, list and presign are free and rate-limited.
Reads are the line that behaves differently, and it’s the one that shapes the design. GET /v1/storage/object/get/{bucket}/{key} meters by egress volume at $0.104 per GB. Bytes served, not requests made.
Which is the whole argument for building the pyramid in the first place. Serving the 1,370-byte WebP instead of the 102,936-byte original is a seventy-fold cut on that line, and a 40 KB thumbnail standing in for a 4 MB camera roll photo is nearer a hundred-fold. Counting how many times a list view loads tells you nothing useful; the rendition you point it at tells you everything. Rates drift and campaigns run, so read today’s numbers — and read the unit beside them, because a unit moving from per-call to per-GB rewrites your model in a way a moving figure never does:
curl -s -X GET "https://api.infrai.cc/v1/discovery" \
-H "Authorization: Bearer $INFRAI_API_KEY" \
| node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{const d=JSON.parse(s);for(const c of d.capabilities)if(c.path.includes("/storage/object/"))console.log(c.method,c.path,c.billing.is_billable?`$${c.billing.price_usd}`:"free")})'
Two boundaries are worth planning around rather than discovering. The signature on a download link is the access check — strip the query string and the storage host answers 403 — but the intact link is still a bearer token, so anyone holding it before expires_at gets the bytes. Short TTLs, server-derived keys, and the authorisation check in front of the presign call rather than after it.
The second is CORS. POST /v1/storage/bucket/set_cors/{bucket} stores rules and bucket/get reads them back, but the storage host answers a real browser preflight with 403 and no Access-Control-* headers, so a tab can’t fetch() these URLs cross-origin — <img src> renders fine, a canvas read doesn’t. Anything genuinely browser-to-bucket belongs on a store whose CORS you own.
When to pick something else
If your product is images and you want on-the-fly variants addressed by URL, an image CDN like Cloudinary earns its keep — you stop writing derivative files at all. If you’re already deep in AWS with IAM roles and Lambda, S3 plus a resize function is fewer moving parts than adding a vendor.
The case for keeping it here is the rest of the job. The next step after this one is already on the same key: POST /v1/queue/publish hands the object key to the worker that triggers the resize, POST /v1/errors/capture catches the malformed upload that blew it up, POST /v1/email/send tells the owner their avatar is live, and GET /v1/account/usage attributes the bytes to a tenant — no second account, no second vendor, no second bill. If all you need is a resize, a specialist will beat this on both price and features, and that’s fine.