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 — an S3-compatible bucket reached 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.

BackendPrivate by defaultSigned download linksEgress shapeResize includedSetup effort
Amazon S3yesSigV4, up to 7 daysmetered per GB, the usual bill shockno (Lambda + sharp)IAM policy, bucket policy, SDK
Cloudflare R2yesSigV4 via the S3 APIzero egressvia Cloudflare Images, separately pricedaccount + API token
Backblaze B2yesauthorised download tokensfree up to 3x stored datanoaccount + app key
Cloudinaryno, URLs are the productsigned URLs availablebundled into plan tiersyes, on the flyalmost none
Infrai storageyes, private or signed-onlypresign op=get, seconds-scopedper-call metering, no per-GB egress line todayno, bring sharpone 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: you already need a queue, a cron, an error sink and somewhere to put bytes, and you’d rather have one credential and one invoice than five.

Create the bucket once

Names are 3–63 characters, lowercase, and regions are canonical codes rather than city names.

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":"eu-central-1","acl":"private"}'
{
  "ok": true,
  "data": {
    "bucket_id": "bkt_f73cfa74c9ea43a292867b",
    "name": "kb-thumbnail-pipeline",
    "vendor": "cos",
    "region": "eu-central-1",
    "acl": "private",
    "created_at": "2026-07-26T00: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 per call. 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.

CallBillingRate, verified 26 July 2026
POST /v1/storage/object/presign/{bucket}/{key}free0
GET /v1/storage/object/head/{bucket}/{key}free0
PUT /v1/storage/object/put/{bucket}/{key}per call$0.0001
GET /v1/storage/object/get/{bucket}/{key}per call$0.0002

Reads run about twice writes, which is the relationship worth remembering after the digits move. 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: because uploads go through presigned URLs, the metered surface is touched once per rendition rather than once per viewer, and a gallery that serves ten thousand views a day still bills like a handful of writes.

A presigned op=get URL carries an expiry, and an expired signature is refused. Treat that as the whole guarantee.

It is not an access-control system. The URL discloses the storage path of the object, including your account’s prefix, so anyone who has held one link has learned the shape of your namespace — and object keys are guessable if you name them avatars/user_17.jpg. The mitigation is boring and effective: derive keys server-side from something unguessable (a hash, a random 16-byte id), keep expires_seconds in the low hundreds for anything sensitive, and put the real authorisation check in your own API route that decides whether to mint the link at all.

Limits worth knowing before you commit

The bucket has no CORS surface — Infrai doesn’t support setting CORS rules, and cors_rules sent to bucket/create is silently dropped — so a browser cannot PUT directly into an Infrai bucket today. Uploads have to originate from your server or a worker. If browser-direct upload is the requirement, R2 or S3 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.

One more, worth flagging because it surprises people: the region you set is recorded on the bucket, but the presigned URL we get back currently resolves through a Singapore vendor endpoint even for a bucket created as eu-central-1. If data residency is a contractual requirement, verify the host in a presigned URL before you sign anything, rather than trusting the field.

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.

References

Browse more storage developer guides