Choose media storage
Choose one storage adapter for uploaded media. A database backup contains media metadata, not the stored files, so back up the storage backend separately.
Overview
Section titled “Overview”| Storage | Use it when | Signed uploads |
|---|---|---|
| R2 binding | The site runs on Cloudflare Workers | No |
| S3 | A Node.js site uses AWS S3, R2’s S3 API, MinIO, or compatible storage | Yes |
| Local | A Node.js site has one writable persistent volume | No |
Cloudflare R2 binding
Section titled “Cloudflare R2 binding”Use the R2 binding adapter on Cloudflare Workers. The binding supplies access at runtime, so the site does not need R2 access keys.
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”To serve media from a public bucket, connect a custom domain with the Cloudflare API, then set its origin as publicUrl. Cloudflare’s r2.dev development URL is rate-limited and is not intended for production traffic.
storage: r2({ binding: "MEDIA", publicUrl: "https://media.example.com",});If the same bucket stores automatic JSON backups, a public bucket origin can expose objects under backups/. Use a private bucket and EmDash’s media route, or restrict the public origin to media objects. See Backups.
S3-compatible storage
Section titled “S3-compatible storage”The S3 adapter works on Node.js with Cloudflare R2’s S3 API, AWS S3, MinIO, and compatible services.
The following configuration resolves the endpoint, bucket, credentials, region, and optional public URL from S3_* variables when the Node.js process starts:
import emdash, { s3 } from "emdash/astro";
export default defineConfig({ integrations: [ emdash({ storage: s3(), }), ],});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 the S3 adapter on Node.js when direct signed uploads to R2 are required. Create scoped R2 API credentials with the Cloudflare API or CLI, then set the following runtime variables:
S3_ENDPOINT=https://<account-id>.r2.cloudflarestorage.comS3_BUCKET=emdash-mediaS3_ACCESS_KEY_ID=<r2-access-key-id>S3_SECRET_ACCESS_KEY=<r2-secret-access-key>S3_REGION=autoS3_PUBLIC_URL=https://media.example.comKeep the real values in the Node.js hosting platform’s secret manager. The public URL is optional and does not replace the S3 API endpoint used for uploads.
Point the same runtime variables at MinIO. Set S3_ENDPOINT to the MinIO API origin, S3_BUCKET to the bucket name, and the two credential variables to a scoped MinIO access key. Set S3_PUBLIC_URL only when that origin serves the bucket’s objects publicly.
Local filesystem
Section titled “Local filesystem”Use local storage for development or a single Node.js server with a persistent disk. Files are stored in a directory on that 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.
Use separate storage for separate environments
Section titled “Use separate storage for separate environments”The following configuration uses a local directory during development and R2 in the Cloudflare production build:
import emdash, { 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.