Dyrecteddyrected
Guides

Configuring Storage

Step-by-step setup for S3, Cloudflare R2, DigitalOcean Spaces, and Cloudinary.

Point Dyrected's file uploads at the storage provider you want — S3, Cloudflare R2, DigitalOcean Spaces, or Cloudinary — by configuring a storage adapter (the small class that tells Dyrected where files live and how to serve them). This guide sets up each provider from scratch: creating the bucket, configuring CORS, setting env vars, and wiring up the adapter.


AWS S3

1. Create a bucket

  1. Go to the S3 consoleCreate bucket
  2. Choose a region close to your users
  3. Uncheck Block all public access (uploads need to be publicly readable)
  4. Leave everything else as default → Create bucket

2. Set a bucket policy for public reads

In your bucket → PermissionsBucket policy:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": "*",
      "Action": "s3:GetObject",
      "Resource": "arn:aws:s3:::YOUR_BUCKET_NAME/*"
    }
  ]
}

3. Create an IAM user

  1. IAM → UsersCreate user
  2. Attach the AmazonS3FullAccess policy (or a scoped policy for your bucket only)
  3. Go to Security credentialsCreate access key → select Application running outside AWS
  4. Save the Access Key ID and Secret Access Key

4. Configure CORS

In your bucket → PermissionsCross-origin resource sharing (CORS):

[
  {
    "AllowedHeaders": ["*"],
    "AllowedMethods": ["GET", "PUT", "POST", "DELETE"],
    "AllowedOrigins": ["https://yourdomain.com"],
    "ExposeHeaders": ["ETag"]
  }
]

5. Add env vars and configure the adapter

S3_BUCKET=your-bucket-name
S3_REGION=eu-west-1
S3_ACCESS_KEY=AKIA...
S3_SECRET_KEY=...
import { S3StorageAdapter } from '@dyrected/storage-s3'

export default defineConfig({
  storage: new S3StorageAdapter({
    bucket:    process.env.S3_BUCKET!,
    region:    process.env.S3_REGION!,
    credentials: {
      accessKeyId:     process.env.S3_ACCESS_KEY!,
      secretAccessKey: process.env.S3_SECRET_KEY!,
    },
  }),
})

Cloudflare R2

R2 has no egress fees and is S3-compatible. Recommended for most new projects.

1. Create a bucket

  1. Cloudflare dashboard → R2Create bucket
  2. Choose a location hint (optional)

2. Enable public access

In your bucket → SettingsPublic accessAllow access. Copy the public bucket URL (e.g. https://pub-xxxx.r2.dev).

Or set up a custom domain: SettingsCustom domains → connect your domain.

3. Create an API token

R2 → Manage R2 API tokensCreate API token

  • Permissions: Object Read & Write
  • Scope: your bucket

Copy the Access Key ID, Secret Access Key, and your Account ID.

4. Add env vars and configure the adapter

CF_ACCOUNT_ID=abc123
R2_BUCKET=my-bucket
R2_ACCESS_KEY=...
R2_SECRET_KEY=...
R2_PUBLIC_URL=https://pub-xxxx.r2.dev   # or your custom domain
new S3StorageAdapter({
  bucket:   process.env.R2_BUCKET!,
  region:   'auto',
  endpoint: `https://${process.env.CF_ACCOUNT_ID}.r2.cloudflarestorage.com`,
  credentials: {
    accessKeyId:     process.env.R2_ACCESS_KEY!,
    secretAccessKey: process.env.R2_SECRET_KEY!,
  },
  baseUrl: process.env.R2_PUBLIC_URL,
})

DigitalOcean Spaces

Spaces is S3-compatible and includes a built-in CDN.

1. Create a Space

  1. DigitalOcean → SpacesCreate a Space
  2. Choose a region, enable CDN if you want it
  3. Set File Listing to Restricted

2. Create an access key

APISpaces access keysGenerate New Key

3. Add env vars and configure the adapter

DO_SPACES_BUCKET=my-space
DO_SPACES_REGION=nyc3
DO_SPACES_KEY=...
DO_SPACES_SECRET=...
# CDN endpoint (if enabled):
DO_SPACES_CDN=https://my-space.nyc3.cdn.digitaloceanspaces.com
new S3StorageAdapter({
  bucket:   process.env.DO_SPACES_BUCKET!,
  region:   process.env.DO_SPACES_REGION!,
  endpoint: `https://${process.env.DO_SPACES_REGION}.digitaloceanspaces.com`,
  credentials: {
    accessKeyId:     process.env.DO_SPACES_KEY!,
    secretAccessKey: process.env.DO_SPACES_SECRET!,
  },
  baseUrl: process.env.DO_SPACES_CDN,
})

Cloudinary

Best for projects that need image transformations (crop, resize, format conversion) on the fly rather than pre-generating fixed sizes.

1. Create a Cloudinary account

Sign up at cloudinary.com. Find your Cloud name, API Key, and API Secret on the dashboard.

2. Add env vars and configure the adapter

CLOUDINARY_CLOUD_NAME=your-cloud-name
CLOUDINARY_API_KEY=...
CLOUDINARY_API_SECRET=...
import { CloudinaryStorageAdapter } from '@dyrected/storage-cloudinary'

export default defineConfig({
  storage: new CloudinaryStorageAdapter({
    cloudName: process.env.CLOUDINARY_CLOUD_NAME!,
    apiKey:    process.env.CLOUDINARY_API_KEY!,
    apiSecret: process.env.CLOUDINARY_API_SECRET!,
    folder:    'my-project',
  }),
})

3. Using Cloudinary transformations

With Cloudinary, skip imageSizes in your upload config. Instead, apply transformations at the URL level in your frontend:

// Original URL from API:
// https://res.cloudinary.com/my-cloud/image/upload/v123/hero.jpg

// Apply a transformation:
const thumbnailUrl = doc.url.replace(
  '/upload/',
  '/upload/w_300,h_300,c_fill,f_auto,q_auto/'
)

Testing your setup

After configuring, test with a direct upload:

curl -X POST https://yoursite.com/api/collections/media \
  -H "x-api-key: your-api-key" \
  -F "[email protected]" \
  -F "alt=Test upload"

A successful response includes a url field pointing to your storage provider. If the URL resolves in the browser, the setup is working correctly.


Writing a custom adapter

All storage adapters implement the StorageAdapter interface from @dyrected/core:

import type { StorageAdapter, FileData } from '@dyrected/core'

export class MyStorageAdapter implements StorageAdapter {
  async upload(args: {
    filename: string
    buffer: Uint8Array
    mimeType: string
    prefix?: string
  }): Promise<FileData> {
    // Upload the file and return its metadata
    return { filename: args.filename, mimeType: args.mimeType, url: '...' }
  }

  async delete(args: { filename: string }): Promise<void> {
    // Remove the file
  }

  getURL(args: { filename: string }): string {
    // Return the public URL for a stored file
    return `https://cdn.example.com/${args.filename}`
  }
}

The resolve() method

resolve() is an optional method used only by adapters that serve files through the Dyrected API itself (rather than through a CDN URL):

async resolve(args: { filename: string }): Promise<{ buffer: Uint8Array; mimeType: string } | null> {
  // Read the file from local disk and return its bytes
}

When to implement resolve(): Only if your adapter stores files somewhere that cannot be accessed with a direct public URL — for example, on the local filesystem in a self-hosted setup. Dyrected calls resolve() to serve the file through its own API at /api/media/<filename>.

When to omit resolve(): Cloud adapters (S3, R2, Cloudinary, Spaces) never implement this. Their files are publicly accessible via CDN URLs, so Dyrected redirects directly to those URLs without reading the bytes itself. Omitting resolve() on a cloud adapter is correct — it is not a missing feature.

AdapterImplements resolve()?Why
LocalStorageAdapterYesFiles live on disk; served through Dyrected API
S3StorageAdapterNoFiles served directly from S3 / CDN URL
CloudinaryStorageAdapterNoFiles served directly from Cloudinary CDN

On this page