convex-file-system
ConvexFS (convex-fs) — path-based file storage and serving component for Convex powered by bunny.net CDN. Covers installation, setup, file upload/download flows, path management, blob lifecycle, atomic transactions (move/copy/delete), compare-and-swap, signed URLs, file expiration, garbage collection, auth for uploads/downloads, multiple filesystems, React integration, and production best practices. Use when working with ConvexFS, uploading files in Convex, serving files via CDN, managing file paths, building file storage features, or configuring bunny.net with Convex. Triggers on: convex-fs, ConvexFS, bunny.net, file upload, file storage convex, blob, commitFiles, registerRoutes, buildDownloadUrl, fs.stat, fs.list, fs.transact, fs.move, fs.copy, fs.delete, fs.writeFile, fs.getDownloadUrl, "how do I upload files in Convex", "serve files from Convex", "ConvexFS setup".
NPX Install
npx skill4agent add imfa-solutions/skills convex-file-systemTags
Translated version includes tags in frontmatterSKILL.md Content
View Translation Comparison →ConvexFS — File Storage for Convex
— Path-based file storage with global CDN delivery via bunny.net.convex-fs
Installation & Setup
1. Install
npm install convex-fs2. Register component
// convex/convex.config.ts
import { defineApp } from "convex/server";
import fs from "convex-fs/convex.config.js";
const app = defineApp();
app.use(fs);
export default app;3. Create ConvexFS instance
// convex/fs.ts
import { ConvexFS } from "convex-fs";
import { components } from "./_generated/api";
export const fs = new ConvexFS(components.fs, {
storage: {
type: "bunny",
apiKey: process.env.BUNNY_API_KEY!,
storageZoneName: process.env.BUNNY_STORAGE_ZONE!,
cdnHostname: process.env.BUNNY_CDN_HOSTNAME!,
tokenKey: process.env.BUNNY_TOKEN_KEY, // recommended for signed URLs
},
});4. Register HTTP routes
// convex/http.ts
import { httpRouter } from "convex/server";
import { registerRoutes } from "convex-fs";
import { components } from "./_generated/api";
import { fs } from "./fs";
const http = httpRouter();
registerRoutes(http, components.fs, fs, {
pathPrefix: "/fs",
uploadAuth: async (ctx) => {
const identity = await ctx.auth.getUserIdentity();
return identity !== null;
},
downloadAuth: async (ctx, blobId, path) => {
const identity = await ctx.auth.getUserIdentity();
return identity !== null;
},
});
export default http;POST /fs/uploadGET /fs/blobs/{blobId}5. Environment variables
BUNNY_API_KEY=your-api-key
BUNNY_STORAGE_ZONE=your-storage-zone-name
BUNNY_CDN_HOSTNAME=your-zone.b-cdn.net
BUNNY_TOKEN_KEY=your-token-auth-key
BUNNY_REGION=ny # Optional: ny, la, sg, uk, se, br, jh, syd (default: Frankfurt)Core Concepts
Architecture
React Client ──▶ Convex Backend (ConvexFS) ──▶ bunny.net CDN (File Storage + Edge)- File metadata (paths, content types, sizes) → Convex tables
- File contents (blobs) → bunny.net Edge Storage
Paths
list()- matches
prefix: "/users"AND/users/alice.txt/users-backup/data.bin - matches only under
prefix: "/users/"/users/
Blob lifecycle
Upload → Pending (4h TTL) → Committed (refCount=1) → Deleted (refCount=0) → GC cleanup- Reference counting: copy increments refCount (zero-copy), delete decrements it.
- Garbage collection: 3 automatic jobs — upload GC (hourly), blob GC (hourly), file expiration GC (every 15s).
- Grace period: orphaned blobs retained for (default 24h) before permanent deletion.
blobGracePeriod
Attributes
interface FileAttributes {
expiresAt?: number; // Unix timestamp — auto-deleted by FGC
}Core API
Query methods
// Get file metadata by path
const file = await fs.stat(ctx, "/uploads/photo.jpg");
// Returns: { path, blobId, contentType, size, attributes } | null
// List files with pagination
const result = await fs.list(ctx, {
prefix: "/uploads/",
paginationOpts: { numItems: 50, cursor: null },
});
// Returns: { page: FileMetadata[], continueCursor, isDone }Mutation methods
// Commit uploaded blobs to paths
await fs.commitFiles(ctx, [
{ path: "/file.txt", blobId: "uuid-here" }, // overwrite if exists
{ path: "/new.txt", blobId: "uuid", basis: null }, // FAIL if exists
{ path: "/update.txt", blobId: "new-uuid", basis: "old-uuid" }, // CAS: fail if changed
]);
// Atomic multi-operation transaction
await fs.transact(ctx, [
{ op: "move", source: file, dest: { path: "/new/path.txt" } },
{ op: "copy", source: file, dest: { path: "/backup.txt", basis: null } },
{ op: "delete", source: file },
{ op: "setAttributes", source: file, attributes: { expiresAt: Date.now() + 3600000 } },
]);
// Convenience methods
await fs.move(ctx, "/old.txt", "/new.txt"); // throws if source missing or dest exists
await fs.copy(ctx, "/a.txt", "/b.txt"); // throws if source missing or dest exists
await fs.delete(ctx, "/file.txt"); // idempotent (no-op if missing)commitFilestransact| Value | Meaning |
|---|---|
| No check — overwrite if exists |
| File must NOT exist |
| File's current blobId must match (compare-and-swap) |
Action methods
// Generate signed download URL
const url = await fs.getDownloadUrl(ctx, blobId, { extraParams: { filename: "doc.pdf" } });
// Download blob data
const data = await fs.getBlob(ctx, blobId); // ArrayBuffer | null
// Download file contents + metadata
const result = await fs.getFile(ctx, "/file.txt"); // { data, contentType, size } | null
// Upload data and get blobId
const blobId = await fs.writeBlob(ctx, imageData, "image/webp");
// Upload + commit in one call
await fs.writeFile(ctx, "/report.pdf", pdfData, "application/pdf");Client utilities
import { buildDownloadUrl } from "convex-fs";
const url = buildDownloadUrl(siteUrl, "/fs", file.blobId, file.path, { filename: "doc.pdf" });Upload & Serve Flow
Upload (React)
const handleUpload = async (file: File) => {
const siteUrl = (import.meta.env.VITE_CONVEX_URL ?? "").replace(/\.cloud$/, ".site");
// 1. Upload blob
const res = await fetch(`${siteUrl}/fs/upload`, {
method: "POST",
headers: { "Content-Type": file.type },
body: file,
});
const { blobId } = await res.json();
// 2. Commit to path (via mutation)
await commitFile({ blobId, filename: file.name });
};Serve (React)
import { buildDownloadUrl } from "convex-fs";
function Image({ path }: { path: string }) {
const file = useQuery(api.files.getFile, { path });
const siteUrl = (import.meta.env.VITE_CONVEX_URL ?? "").replace(/\.cloud$/, ".site");
if (!file) return <div>Loading...</div>;
const url = buildDownloadUrl(siteUrl, "/fs", file.blobId, file.path);
return <img src={url} alt={path} />;
}Security Rules
- Always authenticate uploads — open upload endpoints are a serious risk.
- Use path-based authorization — validate path ownership in .
downloadAuth - Enable token authentication on bunny.net — prevents URL tampering.
- Set appropriate URL TTLs: sensitive content 60–300s, general 3600s (default), streaming 3600s+.
Conflict Handling
import { ConvexError } from "convex/values";
import { isConflictError } from "convex-fs";
try {
await fs.commitFiles(ctx, files);
} catch (e) {
if (e instanceof ConvexError && isConflictError(e.data)) {
// e.data: { code, path, expected, found }
// Codes: SOURCE_NOT_FOUND, SOURCE_CHANGED, DEST_EXISTS, DEST_NOT_FOUND, DEST_CHANGED, CAS_CONFLICT
}
}Constructor Options
| Option | Type | Default | Description |
|---|---|---|---|
| | required | Bunny.net backend config |
| | | Signed URL expiration (seconds) |
| | | Orphaned blob retention (seconds) |
Reference Files
- Patterns & examples: User files, temp files, atomic ops, CAS updates, retry, pagination, React hooks → See references/examples.md
- Advanced topics: Multiple filesystems, disaster recovery, testing, GC details, Bunny.net setup, TypeScript types, troubleshooting → See references/advanced.md