
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.
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.
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.
- Goal 1Anyone on the team can ship an asset, without holding a storage key.
- Goal 2Every change is recorded, and any mistake can be undone.
- Goal 3One click from upload to a fresh CDN edge, on whichever provider the bucket lives.
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;
};

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;
};


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();
}
}
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.
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}}"] }'

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.
+ session
only
Numbers mark the chapters behind each part. Point at one to see what it touches.
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:
- ✓ Goal 1Anyone ships, nobody holds a key.Teammates ship assets with a role per connection, not a credential.
- ✓ Goal 2Every change is reversible.Uploads, moves, deletes and saves are logged with who and when, and undone in one click.
- ✓ Goal 3One 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.
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.
- 01Sign in with GoogleCreate an organization, or join one from an invite.
- 02Add a connectionEndpoint, bucket and public URL, plus a purge template if you want one‑click purge.
- 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
