Abdul Ahad QureshiFullstack Engineer
← Index Case 01 / 05 · 2026 · In production

Balti.

Role

Designed and built it
Product, backend, frontend

Used by

ImagineArt and Chatly in production, 40+ users in the first month

Stack

Next.js 15 route handlers, React 19, TypeScript, Neon Postgres, AWS SDK v3, Google OAuth, Vercel

Live

balti-landing.vercel.app ↗
Try it with your team ↓

Balti banner: CDN file manager for teams, with the file browser, a right-click menu on purge from CDN cache, and a purge confirmation.
Fig. 01The file browser
Overview

A browser file manager for any S3‑compatible bucket that purges the CDN in one click, so a whole team can ship assets without storage keys.

Chapter 01 / 08The start

One storage key, one engineer

ImagineArt and Chatly ship their images, fonts and configs from S3‑compatible buckets behind a CDN. Shipping one file meant opening Cyberduck with a personal storage key, uploading, then calling the CDN’s purge API by hand in Postman.

Only people holding a key could do it. Nothing was logged, and an overwritten file was simply gone.

Chapter 02 / 08The goal

Hand it to the whole team, safely

I set out to replace that routine with one web app, and wrote down what had to be true at the end.

  1. Goal 1Anyone on the team can ship an asset, without holding a storage key.
  2. Goal 2Every change is recorded, and any mistake can be undone.
  3. Goal 3One click from upload to a fresh CDN edge, on whichever provider the bucket lives.
Chapter 03 / 08The keys

First, take the keys away from people

My first version was a tool for me: it kept the keys in the browser and sent them with every request. For a team, that means every person holds a production key.

So connections became something an organization owns. Keys are sealed with AES‑256‑GCM before they reach Postgres, and opened only on the server after the session and role checks pass. Teammates get a role per connection instead of a key.

Trade‑offThe server now holds credentials, so its encryption key becomes the thing to guard. I took that over issuing every teammate their own key.

See the codesrc/server/crypto.ts

A fresh 12-byte IV per seal, and the GCM auth tag stored beside the ciphertext, so a tampered row fails to open instead of decrypting to garbage.

export const encryptJson = (value: unknown) => {
  const iv = randomBytes(12);
  const cipher = createCipheriv('aes-256-gcm', getKey(), iv);
  const ciphertext = Buffer.concat([
    cipher.update(JSON.stringify(value), 'utf8'),
    cipher.final(),
  ]);
  return `${iv.toString('base64')}.${cipher.getAuthTag().toString('base64')}.${ciphertext.toString('base64')}`;
};

export const decryptJson = <T>(payload: string): T => {
  const [ivPart, tagPart, dataPart] = payload.split('.');
  const decipher = createDecipheriv('aes-256-gcm', getKey(), Buffer.from(ivPart, 'base64'));
  decipher.setAuthTag(Buffer.from(tagPart, 'base64'));
  const plaintext = Buffer.concat([
    decipher.update(Buffer.from(dataPart, 'base64')),
    decipher.final(),
  ]);
  return JSON.parse(plaintext.toString('utf8')) as T;
};
Teams, roles and invites banner: organization admin with an invite link, a members table with Owner, Admin, Editor and Viewer roles, and an access request.
Fig. 02Orgs, invites and roles per connection
Chapter 04 / 08Mistakes

Then make mistakes cheap

More people with write access to a production CDN only works if mistakes can be taken back. S3 versioning would have done it, but not every provider Balti supports offers it, and it needs bucket permissions Balti should not ask for.

Instead, before any delete, overwrite or save, the server copies the originals into a hidden trash folder and logs a recipe for reversing the change. An admin undoes it in one click.

Trade‑offAn extra copy of every file that gets replaced, and the storage it takes.

See the codesrc/server/store.ts · src/server/s3.ts

Every audit entry carries its own undo recipe as a tagged union, so the rollback route is a switch over four cases, not a guess about what happened.

export type AuditRollback =
  | { type: 'restore-trash'; trashPrefix: string; keys: { trashKey: string; originalKey: string }[] }
  | { type: 'move-back'; moves: { from: string; to: string }[] }
  | { type: 'delete-keys'; keys: string[] }
  | { type: 'none' };

export const snapshotToTrash = async (
  connection: ConnectionPayload,
  keys: string[],
  auditId: string
) => {
  const mapping = keys.map((key) => ({
    originalKey: key,
    trashKey: `${TRASH_PREFIX}${auditId}/${key}`,
  }));
  await copyObjects(
    connection,
    mapping.map((entry) => ({ from: entry.originalKey, to: entry.trashKey }))
  );
  return mapping;
};

Read the full story of the undo →

Every change, reversible banner: the activity log with delete, upload, save and move entries, each with Roll back, and a roll back confirmation dialog.
Fig. 03Activity log with roll back
Edit configs in place banner: a JSON file open in the code editor with Valid JSON, Format and Publish, and a published toast.
Fig. 04Edit JSON in place
Chapter 05 / 08The limits

Living inside serverless limits

Balti runs as Next.js route handlers on Vercel: one deploy and no servers to run. But a request body caps near 4.5 MB, and people upload videos.

So uploads skip the server and go straight to the bucket with presigned URLs, and Balti writes the bucket’s CORS rule itself. Where a bucket refuses, files go up in 4 MB chunks that the server joins with an S3 multipart upload.

Trade‑offThe fallback writes every byte twice. Cheaper than running a second service just for uploads.

See the codesrc/server/s3.ts · assembleObjectFromChunks

Chunks are 4 MB to stay under the function body cap, but S3 rejects multipart parts under 5 MB. So chunks are buffered until a part is big enough, and a failed join aborts the multipart upload.

for (let index = 0; index < chunkKeys.length; index++) {
  const response = await client.send(
    new GetObjectCommand({ Bucket: connection.bucket, Key: chunkKeys[index] })
  );
  const bytes = await response.Body?.transformToByteArray();
  if (!bytes) throw new Error(`Upload chunk ${index + 1} is missing`);
  pendingBuffers.push(bytes);
  pendingSize += bytes.length;
  const isLastChunk = index === chunkKeys.length - 1;
  if (!isLastChunk && pendingSize >= MINIMUM_MULTIPART_PART_SIZE) {
    await uploadPendingAsPart();
  }
}

Read how uploads work →

Chapter 06 / 08The purge

One click to a fresh edge

Every CDN purges differently, and the two products use different ones. Rather than write an integration per CDN, each connection stores one HTTP template with {{url}} and {{path}}, with presets for Cloudflare, Bunny and Fastly.

Select files, purge, and the CDN serves the new version. Purges run three at a time and wait out rate limits.

Trade‑offWhoever sets up a connection has to know their CDN’s purge endpoint, once.

Try it on your own bucket ↓

See the codeA connection, as stored

The whole CDN integration is this template. Balti fills in the URL for each file.

name:          "Marigold Assets"
endpoint:      "https://<account>.r2.cloudflarestorage.com"
bucket:        "marigold-web"
publicBaseUrl: "https://cdn.marigold.example"

# Purge: any HTTP API. Balti fills in the URL.
purge:
  method:       "POST"
  urlTemplate:  "https://api.cloudflare.com/client/v4/zones/<zone_id>/purge_cache"
  headers:      [ Authorization: "Bearer ••••••••" ]
  bodyTemplate: '{ "files": ["{{url}}"] }'
One-click cache purge banner: the purge API template with Cloudflare selected, the right-click menu, and a toast confirming 48 assets purged.
Fig. 05One purge template for any CDN
Chapter 07 / 08The system

What it became

Every bucket operation now goes through the server, so keys never reach the browser and every change passes one place where it is checked and logged.

Client Browser app

File browser, upload queue, previews and a code editor.

Chapters05
Direct path Presigned PUT

Uploads skip the server and go straight to the bucket.

Chapters05
→HTTPS
+ session
Server · Vercel Next.js route handlers

Check the session and role, decrypt the keys, then act.

Chapters030405
Auth Google OAuth

Sign in, or join an org from an invite link.

Chapters03
→Server
only
Storage Any S3‑compatible bucket

R2, S3, Spaces, Wasabi or MinIO.

Chapters0405
Database Neon Postgres

Orgs, encrypted connections and the audit log.

Chapters0304
Edge CDN purge APIs

Cloudflare, Bunny, Fastly or any HTTP purge API.

Chapters06

Numbers mark the chapters behind each part. Point at one to see what it touches.

Chapter 08 / 08The end

Where it landed

Balti now runs the CDNs behind ImagineArt and Chatly, and onboarded 40+ users and 2 organizations in its first month. Back to the three goals:

18ImagineArt products shipping from one connection
40+Users in the first month, across 2 organizations
0Storage keys handed to teammates
1Click from upload to a fresh CDN edge
  1. ✓ Goal 1
    Anyone ships, nobody holds a key.Teammates ship assets with a role per connection, not a credential.
  2. ✓ Goal 2
    Every change is reversible.Uploads, moves, deletes and saves are logged with who and when, and undone in one click.
  3. ✓ Goal 3
    One click to a fresh edge.Upload and purge happen in the same place, on R2, S3 or anything S3‑compatible.

Trade‑offs I live with

  • An extra copy for every destructive write, and a trash folder that still needs a retention rule.
  • Chunked uploads write every byte twice when a bucket refuses direct uploads.
  • Org settings live as one JSON document, so two admins saving in the same second could overwrite each other. Fine at team size; the first thing to normalize as it grows.

Each of those was chosen to keep Balti one small app that a team can trust with production.

Try Balti

Give your team the CDN, not the keys.

Balti is open to any team that ships assets to a CDN. Connect a bucket in about a minute, invite the people who ship, and purge the edge in one click. Your files stay in your own bucket.

  1. 01Sign in with GoogleCreate an organization, or join one from an invite.
  2. 02Add a connectionEndpoint, bucket and public URL, plus a purge template if you want one‑click purge.
  3. 03Invite your teamChoose who sees each connection and what they can change.

Cloudflare R2 · AWS S3 · any S3‑compatible storage · any CDN with an HTTP purge API

(06) Contact Open to new work

Let’s talk.

Need someone to own a feature from the database to the UI? I’m open to new roles and collaborations.

© 2026 Abdul Ahad Qureshi Set in Geist on a twelve‑column grid