Thumbnails from a private bucket: sharp, a worker, signed GETs

Object storage stores bytes; it won't resize them. The event-driven derivative pipeline for Node 22 with sharp, and how private thumbnails reach the page.

Nothing inside a bucket resizes an image. Infrai’s storage API stores and serves bytes, so the resize belongs to a worker you run: the original lands, an object.created event fires, your process pulls it down, runs sharp over it, writes the derivatives back under sibling keys, and the page gets a short-lived signed URL per size. Four moving parts, no magic.

That’s the shape of the pipeline. The interesting parts are the loop guard, the key convention and what the signed URL actually returns — details every tutorial skips and every deployment trips over. Infrai gives you the event, the storage and the signing; sharp does the pixels.

Key naming decides everything else

Pick the derivative key before you write a line of code, because it’s the contract between the worker that produces thumbnails and the handler that serves them. A suffix on the original’s key is the least surprising choice:

photos/usr_8412/2026/07/sunset.jpg          ← original, whatever the camera gave you
photos/usr_8412/2026/07/sunset@320.webp     ← feed card
photos/usr_8412/2026/07/sunset@1024.webp    ← lightbox

Now one list with a prefix returns the original and every size next to it, and a delete of the prefix cleans up the family:

export INFRAI_API_KEY="your_infrai_api_key"

curl -sS \
  "https://api.infrai.cc/v1/storage/object/list/thumbnails-demo?prefix=photos%2Fusr_8412%2F2026%2F07%2F&delimiter=%2F" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"
{
  "ok": true,
  "data": {
    "items": [
      { "key": "photos/usr_8412/2026/07/sunset.jpg", "size_bytes": 70, "etag": "2cd8bde463f5d82aae0f0cec061d6b8f", "last_modified": "2026-07-26T00:38:37Z" },
      { "key": "photos/usr_8412/2026/07/sunset@320.webp", "size_bytes": 70, "etag": "2cd8bde463f5d82aae0f0cec061d6b8f", "last_modified": "2026-07-26T00:38:38Z" }
    ],
    "next_cursor": null,
    "common_prefixes": []
  }
}

Wiring the bucket to the worker

POST /v1/storage/bucket/set_notification/{bucket} subscribes a callback URL to object events. Supported types are object.created, object.deleted and multipart.completed:

curl -sS -X POST \
  "https://api.infrai.cc/v1/storage/bucket/set_notification/thumbnails-demo" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"events":["object.created"],"target":{"url":"https://hooks.example.com/storage-events"}}'
{
  "ok": true,
  "data": { "subscription_id": "stnf_63cc9e3544750af59d18351e" }
}

Your endpoint receives a JSON POST carrying X-Infrai-Event, with event, account_id, bucket, key, timestamp and subscription_id in the body.

Here’s the part that bites: your worker writes thumbnails into the same bucket, those writes are also object.created, and the subscription happily calls you again. Left alone that’s an infinite loop billed by the call. Guard on the key.

The worker

Node 22, ESM, one dependency. It reads the original with GET /v1/storage/object/get/{bucket}/{key} (which returns metadata plus base64 in JSON, not a raw byte stream), resizes twice, and writes each result back:

import sharp from "sharp";
import { createServer } from "node:http";

const API = "https://api.infrai.cc";
const token = process.env.INFRAI_API_KEY;
if (!token) throw new Error("INFRAI_API_KEY is not set");
const SIZES = [320, 1024];
const auth = { Authorization: `Bearer ${token}`, "Content-Type": "application/json" };

const isDerivative = (key) => /@\d+\.webp$/.test(key);

async function makeThumbnails(bucket, key) {
  if (isDerivative(key)) return { skipped: true };

  const res = await fetch(`${API}/v1/storage/object/get/${bucket}/${key}`, { method: "GET", headers: auth });
  const found = await res.json();
  if (!res.ok || found.ok === false) throw new Error(found?.error?.code ?? `HTTP ${res.status}`);
  if (!found.data.found) throw new Error(`missing original ${key}`);

  const original = Buffer.from(found.data.data_base64, "base64");
  const stem = key.replace(/\.[^.]+$/, "");
  const written = [];

  for (const width of SIZES) {
    const out = await sharp(original).resize({ width, withoutEnlargement: true }).webp({ quality: 80 }).toBuffer();
    const body = { data_base64: out.toString("base64"), content_type: "image/webp" };
    const put = await fetch(`${API}/v1/storage/object/put/${bucket}/${stem}@${width}.webp`, {
      method: "PUT",
      headers: auth,
      body: JSON.stringify(body),
    });
    const stored = await put.json();
    if (!put.ok || stored.ok === false) throw new Error(stored?.error?.code ?? `HTTP ${put.status}`);
    written.push({ width, key: stored.data.key, bytes: stored.data.size_bytes });
  }
  return { written };
}

createServer(async (req, res) => {
  if (req.method !== "POST") { res.writeHead(405).end(); return; }
  const chunks = [];
  for await (const c of req) chunks.push(c);
  try {
    const event = JSON.parse(Buffer.concat(chunks).toString());
    const result = await makeThumbnails(event.bucket, event.key);
    res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify(result));
  } catch (err) {
    res.writeHead(500, { "content-type": "application/json" })
      .end(JSON.stringify({ error: String(err.message ?? err) }));
  }
}).listen(8080);

withoutEnlargement matters more than it looks — without it a 200 px avatar becomes a blurry 1024 px file that costs you storage for nothing. Return 200 quickly and do the sharp work off the request path if your originals are large; a 24 MP JPEG takes real CPU time, and a webhook that takes 30 seconds to answer is a webhook that gets retried.

You can pull one object by hand to see exactly what the worker sees:

curl -sS \
  "https://api.infrai.cc/v1/storage/object/get/thumbnails-demo/photos/usr_8412/2026/07/sunset.jpg" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"

Getting a private thumbnail onto the page

The bucket is private, so there’s no permanent URL. Sign one per view:

curl -sS -X POST \
  "https://api.infrai.cc/v1/storage/object/presign/thumbnails-demo/photos/usr_8412/2026/07/sunset@320.webp" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"op":"get","expires_seconds":300}'

Two measured details from our 26 July 2026 testing. The signed response comes back with Content-Disposition: attachment and x-amz-force-download: true, which is right for a download button and awkward for hot-path rendering. And no Access-Control-Allow-Origin header is sent, so fetch()ing that URL from your own JS is blocked even though an <img> load isn’t. Signing costs nothing, but each round trip took roughly a second from our test host — so cache the URL for a little less than expires_seconds and hand the same one to every viewer in that window.

Worker pipeline versus a transform service

OptionResize happensCost driverBest when
sharp worker + Infrai storageOnce, on uploadCPU + a few calls per imageFixed set of sizes, private files
Cloudinary / a transform CDNOn request, per URL variantTransformations + bandwidthArt direction, many ad-hoc sizes
Amazon S3 + LambdaOnce, on uploadInvocations + storageAlready deep in AWS
MinIO on your own boxOnce, wherever you run itYour hardwareHard data-residency rules, no egress bill

If your product needs ?w=537&crop=faces on demand, Infrai doesn’t support that — no route rewrites an image, and you’d be better off with Cloudinary and its URL grammar. The worker pattern wins when the sizes are known, the files are private, and you’d rather pay once per image than once per view.

What three sizes actually cost

Verified 26 July 2026: storage.object.get is $0.0002 per call and storage.object.put is $0.0001 per call, while presign, head, list and bucket/usage are free and rate-limited. One photo through this pipeline is one read plus two writes — $0.0004 in call fees, plus stored bytes and egress metered separately. Check today’s figures:

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'] in ('storage.object.get','storage.object.put') ])"

Storage rates trend downward and discounts run, so the live number may be lower than the one printed here. Watch the bytes you’re accumulating:

curl -sS "https://api.infrai.cc/v1/storage/bucket/usage/thumbnails-demo" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"

Failure modes

A worker that reads an original the moment the event arrives can race a slow write and get STORAGE_OBJECT_NOT_FOUND — retry once with a short delay before you treat it as fatal. Base64 transport inflates payloads by about a third, which is fine for photos and wrong for RAW files; above a megabyte or so, sign an upload URL instead. And object/list returned content_type: null for objects whose head reports the type correctly, so don’t drive branching logic off the list response.

The rest of the job — the queue that spreads the resize work, the cron that prunes orphaned derivatives, the error tracker that catches a corrupt JPEG — sits on the same key and the same bill as the storage itself.

References

Browse more storage developer guides