home / skills / cameronapak / bknd-skills / bknd-storage-config

bknd-storage-config skill

/skills/bknd-storage-config

This skill helps configure storage backends for Bknd's media module across s3, cloudinary, local, and r2 adapters, with environment-aware setups.

npx playbooks add skill cameronapak/bknd-skills --skill bknd-storage-config

Review the files below or copy the command above to add this skill to your agents.

Files (1)
SKILL.md
11.8 KB
---
name: bknd-storage-config
description: Use when configuring storage backends for file uploads. Covers S3-compatible storage (AWS S3, Cloudflare R2, DigitalOcean Spaces), Cloudinary media storage, local filesystem adapter for development, adapter configuration options, environment variables, and production storage setup.
---

# Storage Configuration

Configure storage backends for Bknd's media module.

## Prerequisites

- `bknd` package installed
- For S3: bucket created with appropriate permissions
- For Cloudinary: account with API credentials
- For R2: Cloudflare Workers environment with R2 binding

## When to Use UI Mode

- Admin panel doesn't support storage configuration
- Storage must be configured in code

## When to Use Code Mode

- All storage configuration (required)
- Setting up different adapters per environment
- Production storage with credentials

## Storage Adapter Overview

| Adapter | Type | Use Case |
|---------|------|----------|
| `s3` | S3-compatible | AWS S3, Cloudflare R2 (external), DigitalOcean Spaces, MinIO |
| `cloudinary` | Media-optimized | Image/video transformations, CDN delivery |
| `local` | Filesystem | Development only (Node.js/Bun runtime) |
| `r2` | Cloudflare R2 | Cloudflare Workers with R2 binding |

## Step-by-Step: S3 Adapter

### Step 1: Create S3 Bucket

Create bucket in AWS console or via CLI:

```bash
aws s3 mb s3://my-app-uploads --region us-east-1
```

### Step 2: Configure CORS (if browser uploads)

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

### Step 3: Get Access Credentials

Create IAM user with S3 access and get:
- Access Key ID
- Secret Access Key

### Step 4: Configure Bknd

```typescript
import { defineConfig } from "bknd";

export default defineConfig({
  media: {
    enabled: true,
    adapter: {
      type: "s3",
      config: {
        access_key: process.env.S3_ACCESS_KEY,
        secret_access_key: process.env.S3_SECRET_KEY,
        url: "https://my-bucket.s3.us-east-1.amazonaws.com",
      },
    },
  },
});
```

### Step 5: Add Environment Variables

```bash
# .env
S3_ACCESS_KEY=AKIA...
S3_SECRET_KEY=wJalr...
```

## S3 URL Formats

Different S3-compatible services use different URL formats:

```typescript
// AWS S3
url: "https://{bucket}.s3.{region}.amazonaws.com"
// Example: "https://my-bucket.s3.us-east-1.amazonaws.com"

// Cloudflare R2 (external access via S3 API)
url: "https://{account_id}.r2.cloudflarestorage.com/{bucket}"
// Example: "https://abc123.r2.cloudflarestorage.com/my-bucket"

// DigitalOcean Spaces
url: "https://{bucket}.{region}.digitaloceanspaces.com"
// Example: "https://my-bucket.nyc3.digitaloceanspaces.com"

// MinIO (self-hosted)
url: "http://localhost:9000/{bucket}"
```

## Step-by-Step: Cloudinary Adapter

### Step 1: Get Cloudinary Credentials

From Cloudinary dashboard, copy:
- Cloud name
- API Key
- API Secret

### Step 2: Configure Bknd

```typescript
import { defineConfig } from "bknd";

export default defineConfig({
  media: {
    enabled: true,
    adapter: {
      type: "cloudinary",
      config: {
        cloud_name: process.env.CLOUDINARY_CLOUD_NAME,
        api_key: process.env.CLOUDINARY_API_KEY,
        api_secret: process.env.CLOUDINARY_API_SECRET,
      },
    },
  },
});
```

### Step 3: Add Environment Variables

```bash
# .env
CLOUDINARY_CLOUD_NAME=my-cloud
CLOUDINARY_API_KEY=123456789
CLOUDINARY_API_SECRET=abcdef...
```

### Optional: Upload Preset

For unsigned uploads or custom transformations:

```typescript
adapter: {
  type: "cloudinary",
  config: {
    cloud_name: process.env.CLOUDINARY_CLOUD_NAME,
    api_key: process.env.CLOUDINARY_API_KEY,
    api_secret: process.env.CLOUDINARY_API_SECRET,
    upload_preset: "my-preset",  // Optional
  },
},
```

## Step-by-Step: Local Adapter (Development)

### Step 1: Create Upload Directory

```bash
mkdir -p ./uploads
```

### Step 2: Register and Configure

```typescript
import { defineConfig } from "bknd";
import { registerLocalMediaAdapter } from "bknd/adapter/node";

// Register the local adapter
const local = registerLocalMediaAdapter();

export default defineConfig({
  media: {
    enabled: true,
    adapter: local({ path: "./uploads" }),
  },
});
```

Files served at `/api/media/file/{filename}`.

### Note on Runtime

Local adapter requires Node.js or Bun runtime (filesystem access). It won't work in:
- Cloudflare Workers
- Vercel Edge Functions
- Browser environments

## Step-by-Step: Cloudflare R2 (Workers)

### Step 1: Create R2 Bucket

```bash
wrangler r2 bucket create my-bucket
```

### Step 2: Add R2 Binding to wrangler.toml

```toml
[[r2_buckets]]
binding = "MY_BUCKET"
bucket_name = "my-bucket"
```

### Step 3: Configure Bknd

```typescript
import { serve, type CloudflareBkndConfig } from "bknd/adapter/cloudflare";

const config: CloudflareBkndConfig = {
  app: (env) => ({
    connection: { url: env.DB },
    config: {
      media: {
        enabled: true,
        adapter: {
          type: "r2",
          config: {
            binding: "MY_BUCKET",
          },
        },
      },
    },
  }),
};

export default serve(config);
```

R2 adapter uses the Cloudflare Workers binding directly, no external credentials needed.

## Media Module Options

### Size Limit

```typescript
export default defineConfig({
  media: {
    enabled: true,
    body_max_size: 10 * 1024 * 1024,  // 10MB max upload
    adapter: { ... },
  },
});
```

### Default Size Behavior

If `body_max_size` not set, uploads have no size limit. Always set a reasonable limit in production.

## Environment-Based Configuration

Different adapters for dev vs production:

```typescript
import { defineConfig } from "bknd";
import { registerLocalMediaAdapter } from "bknd/adapter/node";

const local = registerLocalMediaAdapter();
const isDev = process.env.NODE_ENV !== "production";

export default defineConfig({
  media: {
    enabled: true,
    body_max_size: 25 * 1024 * 1024,  // 25MB
    adapter: isDev
      ? local({ path: "./uploads" })
      : {
          type: "s3",
          config: {
            access_key: process.env.S3_ACCESS_KEY,
            secret_access_key: process.env.S3_SECRET_KEY,
            url: process.env.S3_BUCKET_URL,
          },
        },
  },
});
```

## Verify Storage Configuration

### Check Media Module Enabled

```typescript
import { Api } from "bknd";

const api = new Api({ host: "http://localhost:7654" });

// List files (empty if no uploads yet)
const { ok, data, error } = await api.media.listFiles();

if (ok) {
  console.log("Media module working, files:", data.length);
} else {
  console.error("Media error:", error);
}
```

### Test Upload

```typescript
async function testStorage() {
  const testFile = new File(["test content"], "test.txt", {
    type: "text/plain"
  });

  const { ok, data, error } = await api.media.upload(testFile);

  if (ok) {
    console.log("Upload succeeded:", data.name);

    // Clean up
    await api.media.deleteFile(data.name);
    console.log("Cleanup complete");
  } else {
    console.error("Upload failed:", error);
  }
}
```

### Check via REST

```bash
# List files
curl http://localhost:7654/api/media/files

# Upload test file
echo "test" | curl -X POST \
  -H "Content-Type: text/plain" \
  --data-binary @- \
  http://localhost:7654/api/media/upload/test.txt
```

## Complete Configuration Examples

### AWS S3 Production

```typescript
import { defineConfig } from "bknd";

export default defineConfig({
  connection: {
    url: process.env.DATABASE_URL,
  },
  config: {
    media: {
      enabled: true,
      body_max_size: 50 * 1024 * 1024,  // 50MB
      adapter: {
        type: "s3",
        config: {
          access_key: process.env.AWS_ACCESS_KEY_ID,
          secret_access_key: process.env.AWS_SECRET_ACCESS_KEY,
          url: `https://${process.env.S3_BUCKET}.s3.${process.env.AWS_REGION}.amazonaws.com`,
        },
      },
    },
  },
});
```

### Cloudflare R2 + D1

```typescript
import { serve, type CloudflareBkndConfig } from "bknd/adapter/cloudflare";

const config: CloudflareBkndConfig = {
  app: (env) => ({
    connection: { url: env.DB },  // D1 binding
    config: {
      media: {
        enabled: true,
        body_max_size: 25 * 1024 * 1024,
        adapter: {
          type: "r2",
          config: { binding: "UPLOADS" },
        },
      },
    },
  }),
};

export default serve(config);
```

### Development with Hot Reload

```typescript
import { defineConfig } from "bknd";
import { registerLocalMediaAdapter } from "bknd/adapter/node";

const local = registerLocalMediaAdapter();

export default defineConfig({
  connection: {
    url: "file:data.db",
  },
  config: {
    media: {
      enabled: true,
      adapter: local({ path: "./public/uploads" }),
    },
  },
});
```

## Common Pitfalls

### S3 403 Forbidden

**Problem:** Upload fails with 403 error.

**Causes:**
1. Invalid credentials
2. Bucket policy blocks access
3. URL format incorrect

**Fix:**
```typescript
// Check URL format - must NOT have trailing slash
url: "https://bucket.s3.region.amazonaws.com"  // CORRECT
url: "https://bucket.s3.region.amazonaws.com/" // WRONG

// Verify credentials
console.log("Key:", process.env.S3_ACCESS_KEY?.substring(0, 8) + "...");
```

### Local Adapter 404

**Problem:** Files not found after upload.

**Causes:**
1. Upload directory doesn't exist
2. Wrong path (absolute vs relative)

**Fix:**
```bash
# Create directory first
mkdir -p ./uploads

# Use relative path from project root
adapter: local({ path: "./uploads" })  // CORRECT
adapter: local({ path: "/uploads" })   // WRONG (absolute)
```

### R2 Binding Not Found

**Problem:** "No R2Bucket found with key" error.

**Fix:** Ensure wrangler.toml has correct binding:

```toml
[[r2_buckets]]
binding = "MY_BUCKET"      # This name goes in config
bucket_name = "actual-bucket"
```

### Cloudinary Upload Fails Silently

**Problem:** `putObject` returns undefined.

**Causes:**
1. Invalid credentials
2. API key doesn't have upload permission

**Fix:**
- Verify credentials in Cloudinary dashboard
- Ensure API key has write access (not read-only)

### Wrong Adapter for Runtime

**Problem:** Local adapter fails in serverless.

**Fix:** Use S3/R2/Cloudinary for serverless:

```typescript
// Cloudflare Workers - use r2
// Vercel - use s3
// AWS Lambda - use s3
// Node.js server - local is OK for dev
```

### Missing Environment Variables

**Problem:** Credentials undefined at runtime.

**Fix:**
```typescript
// Add validation
if (!process.env.S3_ACCESS_KEY) {
  throw new Error("S3_ACCESS_KEY not set");
}

// Or provide defaults for dev
const config = {
  access_key: process.env.S3_ACCESS_KEY ?? "dev-key",
  // ...
};
```

### CORS Not Configured

**Problem:** Browser uploads blocked.

**Fix:** Configure CORS on the bucket itself (AWS/R2 console), not in Bknd.

## Security Checklist

1. **Never commit credentials** - Use environment variables
2. **Restrict bucket access** - Don't make bucket public unless needed
3. **Set size limits** - Always configure `body_max_size`
4. **Use HTTPS** - Especially for credential-based uploads
5. **Rotate keys** - Periodically rotate access keys
6. **Principle of least privilege** - IAM user needs only S3 access to one bucket

## DOs and DON'Ts

**DO:**
- Use environment variables for credentials
- Set `body_max_size` in production
- Use S3/R2/Cloudinary for production
- Configure CORS for browser uploads
- Test storage before deploying

**DON'T:**
- Commit credentials to git
- Use local adapter in production
- Leave upload size unlimited
- Forget to create upload directory for local
- Use trailing slash in S3 URL

## Related Skills

- **bknd-file-upload** - Upload files after storage configured
- **bknd-serve-files** - Serve and deliver uploaded files
- **bknd-env-config** - Manage environment variables
- **bknd-deploy-hosting** - Deploy with production storage
- **bknd-assign-permissions** - Set media permissions

Overview

This skill helps configure storage backends for Bknd's media module, covering S3-compatible services (AWS S3, Cloudflare R2, DigitalOcean Spaces), Cloudinary, and the local filesystem adapter for development. It provides concrete configuration patterns, environment variable guidance, runtime constraints, and troubleshooting tips to get uploads working in dev and production.

How this skill works

The skill outlines adapter-specific setup steps and example code for defineConfig or Cloudflare serve wrappers. It explains required credentials, URL formats, CORS needs, body_max_size controls, and how to switch adapters by environment. It also includes verification steps (list/upload via API or curl) and common error causes with fixes.

When to use it

  • You need to configure storage backends in code because the admin UI does not support it.
  • You want S3-compatible storage (AWS, R2, Spaces, MinIO) or Cloudinary for production media delivery.
  • You need a local filesystem adapter for development with Node.js or Bun.
  • You are deploying to Cloudflare Workers and need the R2 binding configuration.
  • You must set different adapters or limits per environment (dev vs production).

Best practices

  • Keep credentials in environment variables and never commit them to source control.
  • Set body_max_size in production to prevent large or abusive uploads.
  • Choose adapters compatible with your runtime: local only for Node/Bun; use s3/r2/cloudinary for serverless.
  • Validate adapter URLs (no trailing slash) and use correct S3-compatible URL formats.
  • Configure bucket CORS for browser uploads and restrict bucket access with least privilege.

Example use cases

  • Local development: register the local adapter and store uploads under ./uploads with hot reload.
  • Production AWS S3: configure access_key/secret and URL using environment variables and set a 50MB upload limit.
  • Cloudflare Workers: bind an R2 bucket in wrangler.toml and configure the r2 adapter with the binding name.
  • Cloudinary: use cloud_name, api_key, api_secret for transformed images and optional upload_preset for unsigned uploads.
  • Hybrid setups: local adapter in dev and s3 adapter in production selected via NODE_ENV.

FAQ

Why do uploads return 403 when using S3?

Common causes are invalid credentials, incorrect bucket URL (watch trailing slash), or restrictive bucket policy. Verify keys, URL format, and permissions.

When should I use the local adapter?

Use local only for development on Node.js or Bun where filesystem access is available. Do not use local in edge runtimes or serverless production.