Storage Adapters
Storage adapters decide where uploaded files live and how their URLs are resolved.
Use this page when you already understand upload collections and now need to decide where the file bytes should live. By the end, you should know which adapters Dyrected currently ships, which environment variables the CLI actually scaffolds, and which storage concerns belong here instead of on the upload collection page.
import { defineConfig } from '@dyrected/core'
import { s3Storage } from '@dyrected/storage-s3'
export default defineConfig({
storage: s3Storage({
bucket: process.env.S3_BUCKET!,
region: process.env.S3_REGION!,
credentials: {
accessKeyId: process.env.S3_ACCESS_KEY_ID!,
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY!,
},
}),
collections: [Media],
globals: [],
})What a storage adapter does
A storage adapter persists file bytes and resolves the public URL that upload documents expose through doc.url. Always consume that resolved URL rather than reconstructing storage paths yourself. Local storage serves bytes from your app's own upload path, while cloud adapters usually return a provider or CDN URL directly.
The split is simple:
- the upload collection decides which files are allowed and what metadata to keep
- the storage adapter decides where files live and how their URLs resolve
If your collection also defines imageSizes, the root-level image service handles the processing step for those generated variants.
Choosing storage
The recommended path depends on how you are running Dyrected:
- Dyrected Cloud: do not configure a custom storage adapter unless your project setup explicitly requires one. Cloud manages storage for you.
- Local development or one durable server: use
localStorage. - Self-hosted production or multiple app instances: use an object store through
s3Storageorb2Storage. - Provider-owned media pipeline: use
cloudinaryStoragewhen Cloudinary should own delivery URLs and transformations.
Local filesystem
Use local storage when the app and the uploaded files live on the same durable disk. This is the simplest path for local development and for single-node deployments with persistent storage.
import { defineConfig } from '@dyrected/core'
import { localStorage } from '@dyrected/storage-local'
export default defineConfig({
storage: localStorage({
uploadDir: './public/uploads',
staticUrlPrefix: '/uploads',
}),
collections: [Media],
globals: [],
})This path does not need storage-specific environment variables. It is not the right choice for ephemeral filesystems or multi-instance deployments unless those instances share the same mounted volume.
S3 and other S3-compatible object storage
Use s3Storage when you need shared object storage. This is the general self-hosted production path.
import { defineConfig } from '@dyrected/core'
import { s3Storage } from '@dyrected/storage-s3'
export default defineConfig({
storage: s3Storage({
bucket: process.env.S3_BUCKET!,
region: process.env.S3_REGION!,
credentials: {
accessKeyId: process.env.S3_ACCESS_KEY_ID!,
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY!,
},
endpoint: process.env.S3_ENDPOINT,
forcePathStyle: process.env.S3_FORCE_PATH_STYLE === 'true',
baseUrl: process.env.S3_BASE_URL,
}),
collections: [Media],
globals: [],
})If you scaffold a self-hosted app with dyrected init and choose s3, the current CLI writes these variables into .env.example:
S3_BUCKETS3_REGIONS3_ACCESS_KEY_IDS3_SECRET_ACCESS_KEY
The adapter also supports runtime-only options that the CLI does not scaffold for you:
S3_ENDPOINTif your S3-compatible provider needs a custom API endpointS3_FORCE_PATH_STYLEif that provider requires path-style URLsS3_BASE_URLif you serve files through a custom public origin or CDN
Keep the credentials server-side. Frontend code should consume the returned doc.url, not your bucket naming scheme.
Backblaze B2
Use b2Storage when you want the native B2 adapter instead of going through an S3-compatible layer.
import { defineConfig } from '@dyrected/core'
import { b2Storage } from '@dyrected/storage-b2'
export default defineConfig({
storage: b2Storage({
bucketId: process.env.B2_BUCKET_ID!,
bucketName: process.env.B2_BUCKET_NAME!,
applicationKeyId: process.env.B2_KEY_ID!,
applicationKey: process.env.B2_APPLICATION_KEY!,
baseUrl: process.env.B2_BASE_URL,
}),
collections: [Media],
globals: [],
})If you scaffold with dyrected init and choose b2, the CLI writes:
B2_BUCKET_IDB2_BUCKET_NAMEB2_KEY_IDB2_APPLICATION_KEY
The runtime adapter also supports baseUrl, which is useful when your files are delivered through a custom domain or CDN. The CLI does not currently scaffold that variable for you.
Cloudinary
Use cloudinaryStorage when Cloudinary should own the delivery URL and media pipeline.
import { defineConfig } from '@dyrected/core'
import { cloudinaryStorage } from '@dyrected/storage-cloudinary'
export default defineConfig({
storage: cloudinaryStorage({
cloudName: process.env.CLOUDINARY_CLOUD_NAME!,
apiKey: process.env.CLOUDINARY_API_KEY!,
apiSecret: process.env.CLOUDINARY_API_SECRET!,
folder: process.env.CLOUDINARY_FOLDER,
}),
collections: [Media],
globals: [],
})If you scaffold with dyrected init and choose cloudinary, the CLI writes:
CLOUDINARY_CLOUD_NAMECLOUDINARY_API_KEYCLOUDINARY_API_SECRET
The adapter also supports folder, but the CLI does not scaffold a matching env var for it today. Add one yourself if you want the adapter to prefix uploads under a specific Cloudinary folder.
URL resolution and serving
Always treat doc.url as the public contract.
- local storage builds a root-relative URL from
staticUrlPrefix - cloud adapters usually return a provider or CDN URL
resolve()only matters for adapters that need Dyrected to serve the file bytes itself
That is why frontend code should not guess at filenames, prefixes, or provider domains. The adapter is allowed to change those decisions without changing the document shape.
Keep collection-level upload rules on Upload overview. Things like allowedMimeTypes, maxFileSize, and imageSizes are content-model decisions, not storage-provider decisions. This page is for choosing an adapter, wiring credentials, and understanding how doc.url is produced.
Generated reference
The contracts below are generated from the public @dyrected/core exports by @dyrected/knowledge, so the storage, file-data, and image-service interfaces stay in sync with the package. Use them when you are implementing a custom adapter or checking the exact argument and return shapes.
FileData
Metadata returned after a file is uploaded and stored. Stored on the document in upload collections.
export interface FileData {
filename: string;
filesize?: number;
mimeType: string;
/** Public URL of the stored file. */
url: string;
width?: number;
height?: number;
focalPoint?: { x: number; y: number };
/** Base64-encoded BlurHash string for progressive image loading. */
blurhash?: string;
/** `'upload'` for server-stored files; `'external'` for provider-managed files. */
type?: "upload" | "external";
provider?: string;
provider_metadata?: unknown;
[key: string]: unknown;
}| Option | Description |
|---|---|
filename (required) | |
filesize (optional) | |
mimeType (required) | |
url (required) | Public URL of the stored file. |
width (optional) | |
height (optional) | |
focalPoint (optional) | |
blurhash (optional) | Base64-encoded BlurHash string for progressive image loading. |
type (optional) | `'upload'` for server-stored files; `'external'` for provider-managed files. |
provider (optional) | |
provider_metadata (optional) |
ImageService
Processes uploaded images — generates metadata (dimensions, BlurHash) and
produces resized variants defined in UploadConfig.imageSizes.
export interface ImageService {
process(args: {
buffer: Uint8Array;
mimeType: string;
config?: boolean | UploadConfig;
focalPoint?: { x: number; y: number };
}): Promise<{
metadata: {
width?: number;
height?: number;
/** Base64-encoded BlurHash for progressive loading. */
blurhash?: string;
};
/** Generated image sizes keyed by their `name`. */
sizes?: Record<string, { buffer: Uint8Array; width: number; height: number; filename: string }>;
}>;
}| Option | Description |
|---|---|
process (optional) |
StorageAdapter
The interface every storage adapter must implement.
Dyrected ships adapters for local disk, AWS S3 and other S3-compatible services through the S3 adapter, Cloudinary, and Backblaze B2. Implement this interface to use any other storage provider.
export interface StorageAdapter {
/**
* Upload a file and return its metadata (URL, dimensions, etc.).
* The `prefix` is a path prefix used for multi-tenant setups.
*/
upload(args: { filename: string; buffer: Uint8Array; mimeType: string; prefix?: string }): Promise<FileData>;
/** Delete a file by its stored filename. */
delete(args: { filename: string }): Promise<void>;
/** Return the public URL for a stored file. */
getURL(args: { filename: string }): string;
/**
* Retrieve the file's raw bytes and MIME type for serving via the API.
* Only needed by adapters that serve files through the Dyrected API
* (e.g. `LocalStorage`). Cloud adapters return `null` here and rely on
* direct CDN URLs instead.
*/
resolve?(args: { filename: string }): Promise<{ buffer: Uint8Array; mimeType: string } | null>;
}| Option | Description |
|---|---|
upload (optional) | Upload a file and return its metadata (URL, dimensions, etc.). The `prefix` is a path prefix used for multi-tenant setups. |
delete (required) | Delete a file by its stored filename. |
getURL (required) | Return the public URL for a stored file. |
resolve (required) | Retrieve the file's raw bytes and MIME type for serving via the API. Only needed by adapters that serve files through the Dyrected API (e.g. `LocalStorage`). Cloud adapters return `null` here and rely on direct CDN URLs instead. |
Custom adapters and escape hatches
When a shipped adapter does not match your infrastructure, implement StorageAdapter yourself and return a complete FileData object from upload.
The main escape hatches are:
- custom public origins through
baseUrl-style options where the shipped adapter supports them - a provider-specific custom adapter when your storage API is not covered by the shipped packages
- a custom
resolve()implementation when Dyrected, not the provider, must serve the bytes
If you go that route, update the generated contract source instead of hand-maintaining parallel interface docs.