Storage Options
EmDash stores uploaded media (images, documents, videos) in a configurable storage backend. Choose based on your deployment platform and requirements.
Overview
Section titled “Overview”| Storage | Best For | Features |
|---|---|---|
| R2 Binding | Cloudflare Workers | Zero-config, fast |
| S3 | Any platform | Signed uploads, CDN support |
| Local | Development | Simple filesystem storage |
Cloudflare R2 (Binding)
Section titled “Cloudflare R2 (Binding)”Use R2 bindings when deploying to Cloudflare Workers for the fastest integration.
import emdash from "emdash/astro";import { r2 } from "@emdash-cms/cloudflare";
export default defineConfig({ integrations: [ emdash({ storage: r2({ binding: "MEDIA" }), }), ],});Configuration
Section titled “Configuration”| Option | Type | Description |
|---|---|---|
binding | string | R2 binding name from wrangler.jsonc |
publicUrl | string | Optional public URL for the bucket |
Add the R2 binding to your Wrangler configuration:
{ "r2_buckets": [ { "binding": "MEDIA", "bucket_name": "emdash-media" } ]}[[r2_buckets]]binding = "MEDIA"bucket_name = "emdash-media"Public Access
Section titled “Public Access”For public media URLs, enable public access on your R2 bucket:
- Go to Cloudflare Dashboard > R2 > your bucket
- Enable public access under Settings
- Add the public URL to your config:
storage: r2({ binding: "MEDIA", publicUrl: "https://pub-xxxx.r2.dev",});S3-Compatible Storage
Section titled “S3-Compatible Storage”The S3 adapter works with Cloudflare R2 (via S3 API), MinIO, and other S3-compatible services.
The following configuration points EmDash at an S3-compatible bucket:
import emdash, { s3 } from "emdash/astro";
export default defineConfig({ integrations: [ emdash({ storage: s3({ endpoint: process.env.S3_ENDPOINT, bucket: process.env.S3_BUCKET, accessKeyId: process.env.S3_ACCESS_KEY_ID, secretAccessKey: process.env.S3_SECRET_ACCESS_KEY, region: "auto", // Optional, defaults to "auto" publicUrl: process.env.S3_PUBLIC_URL, // Optional CDN URL }), }), ],});Configuration
Section titled “Configuration”| Option | Type | Required | Description |
|---|---|---|---|
endpoint | string | yes | S3 endpoint URL |
bucket | string | yes | Bucket name |
accessKeyId | string | no* | Access key |
secretAccessKey | string | no* | Secret key |
region | string | no | Region (default: "auto") |
publicUrl | string | no | Optional CDN or public URL |
* Both accessKeyId and secretAccessKey must be provided together, or both omitted.
Resolving S3 config from environment variables
Section titled “Resolving S3 config from environment variables”Any field omitted from s3({...}) is read from the matching S3_* environment variable
when the process starts. This lets you build a container image once and inject credentials
at boot without a rebuild. Explicit values in s3({...}) always take precedence over
environment variables.
| Environment variable | Field | Notes |
|---|---|---|
S3_ENDPOINT | endpoint | Must be a valid http/https URL |
S3_BUCKET | bucket | |
S3_ACCESS_KEY_ID | accessKeyId | |
S3_SECRET_ACCESS_KEY | secretAccessKey | |
S3_REGION | region | Defaults to "auto" |
S3_PUBLIC_URL | publicUrl | Optional CDN prefix |
Environment variables are read from process.env when the process starts. This is a
Node-only feature.
Calling s3() with no arguments reads every field from the S3_* environment variables:
import emdash, { s3 } from "emdash/astro";
export default defineConfig({ integrations: [ emdash({ // s3() with no args: all fields from S3_* environment variables storage: s3(),
// Or mix: override one field, rest from environment // storage: s3({ publicUrl: "https://cdn.example.com" }), }), ],});R2 via S3 API
Section titled “R2 via S3 API”Use S3 credentials with R2 for features like signed upload URLs:
storage: s3({ endpoint: "https://<account-id>.r2.cloudflarestorage.com", bucket: "emdash-media", accessKeyId: process.env.R2_ACCESS_KEY_ID, secretAccessKey: process.env.R2_SECRET_ACCESS_KEY, publicUrl: "https://pub-xxxx.r2.dev",});Generate R2 API credentials in the Cloudflare dashboard under R2 > Manage R2 API Tokens.
Point the S3 adapter at a MinIO endpoint with its access credentials:
storage: s3({ endpoint: "https://minio.example.com", bucket: "emdash-media", accessKeyId: process.env.MINIO_ACCESS_KEY, secretAccessKey: process.env.MINIO_SECRET_KEY, publicUrl: "https://minio.example.com/emdash-media",});Local Filesystem
Section titled “Local Filesystem”Use local storage for development. Files are stored in a directory on disk.
import emdash, { local } from "emdash/astro";
export default defineConfig({ integrations: [ emdash({ storage: local({ directory: "./uploads", baseUrl: "/_emdash/api/media/file", }), }), ],});Configuration
Section titled “Configuration”| Option | Type | Description |
|---|---|---|
directory | string | Directory path for file storage |
baseUrl | string | Base URL for serving files |
The baseUrl should match EmDash’s media file endpoint (/_emdash/api/media/file) unless you configure a custom static file server.
Environment-Based Configuration
Section titled “Environment-Based Configuration”Switch storage backends based on environment:
import emdash, { s3, local } from "emdash/astro";import { r2 } from "@emdash-cms/cloudflare";
const storage = import.meta.env.PROD ? r2({ binding: "MEDIA" }) : local({ directory: "./uploads", baseUrl: "/_emdash/api/media/file", });
export default defineConfig({ integrations: [emdash({ storage })],});Signed Uploads
Section titled “Signed Uploads”The S3 adapter supports signed upload URLs, allowing clients to upload directly to storage without passing through your server. This improves performance for large files.
Signed uploads are automatic when using the S3 adapter. The admin interface uses them when available.
Adapters that support signed uploads:
- S3 (including R2 via S3 API)
Adapters that do not support signed uploads:
- R2 binding (use S3 adapter with R2 credentials instead)
- Local
Storage Interface
Section titled “Storage Interface”All storage adapters implement the same interface:
interface Storage { upload(options: { key: string; body: Buffer | Uint8Array | ReadableStream; contentType: string; }): Promise<UploadResult>;
download(key: string): Promise<DownloadResult>; delete(key: string): Promise<void>; exists(key: string): Promise<boolean>; list(options?: ListOptions): Promise<ListResult>; getSignedUploadUrl(options: SignedUploadOptions): Promise<SignedUploadUrl>; getPublicUrl(key: string): string;}This consistency allows switching storage backends without changing application code.