Back to list
cameronapak

bknd-storage-config

by cameronapak

A no-build, un-bloated stack built upon Web Standards that feels freeing to use and can be deployed anywhere.

23🍴 2📅 Jan 21, 2026

SKILL.md


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

AdapterTypeUse Case
s3S3-compatibleAWS S3, Cloudflare R2 (external), DigitalOcean Spaces, MinIO
cloudinaryMedia-optimizedImage/video transformations, CDN delivery
localFilesystemDevelopment only (Node.js/Bun runtime)
r2Cloudflare R2Cloudflare Workers with R2 binding

Step-by-Step: S3 Adapter

Step 1: Create S3 Bucket

Create bucket in AWS console or via CLI:

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

Step 2: Configure CORS (if browser uploads)

{
  "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

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

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

S3 URL Formats

Different S3-compatible services use different URL formats:

// 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

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

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

Optional: Upload Preset

For unsigned uploads or custom transformations:

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

mkdir -p ./uploads

Step 2: Register and Configure

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

wrangler r2 bucket create my-bucket

Step 2: Add R2 Binding to wrangler.toml

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

Step 3: Configure Bknd

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

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:

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

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

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

# 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

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

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

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:

// 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:

# 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:

[[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:

// 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:

// 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
  • 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

Score

Total Score

75/100

Based on repository quality metrics

SKILL.md

SKILL.mdファイルが含まれている

+20
LICENSE

ライセンスが設定されている

+10
説明文

100文字以上の説明がある

+10
人気

GitHub Stars 100以上

0/15
最近の活動

1ヶ月以内に更新

+10
フォーク

10回以上フォークされている

0/5
Issue管理

オープンIssueが50未満

+5
言語

プログラミング言語が設定されている

+5
タグ

1つ以上のタグが設定されている

+5

Reviews

💬

Reviews coming soon