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 sharp 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: "download", 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 sharp 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 workerPOST /v1/image/processCloudinary-style image CDN
Where the CPU burnsyour containerthe platformthe vendor’s edge
Install costlibvips in the imagenonenone
Output lands in your bucketyou write ityou write itusually theirs
Per-image chargenone beyond computemetered per callmetered per transform
Good forbatch backfills, exact controlserverless, no native depson-the-fly variants by URL

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.

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":"download","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

Writes are $0.0001 per call and reads $0.0002 per call in the published route table — verified 2026-07-26 — while head, list and presign are free and rate-limited. So a thumbnail pipeline is dominated by the single read of the original, not by the pyramid of sizes you write. Rates drift downward and campaigns run, so read today’s numbers rather than mine:

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")})'

The real limitation isn’t price. A signed URL here controls expiry, not access: in our testing the same object stayed readable over plain HTTPS with the query string removed, so treat any download URL as a long-lived secret and put your authorisation check in front of the presign call, not after it. There’s also no route for bucket CORS rules, so a browser can’t fetch() these URLs cross-origin — <img src> is fine, a canvas read isn’t.

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 same key that stores the thumbnail also runs the queue that triggers the worker, captures the error when sharp chokes on a malformed upload, and attributes the storage bytes to a tenant on one bill. If all you need is a resize, a specialist will beat this on both price and features, and that’s fine.

References

Browse more storage developer guides