---
title: Storage Options
description: Configure media storage with R2, S3, or local filesystem.
---

import { Aside, Tabs, TabItem } from "@astrojs/starlight/components";

EmDash stores uploaded media (images, documents, videos) in a configurable storage backend. Choose based on your deployment platform and requirements.

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

Use R2 bindings when deploying to Cloudflare Workers for the fastest integration.

```js title="astro.config.mjs"
import emdash from "emdash/astro";
import { r2 } from "@emdash-cms/cloudflare";

export default defineConfig({
	integrations: [
		emdash({
			storage: r2({ binding: "MEDIA" }),
		}),
	],
});
```

### Configuration

| Option      | Type     | Description                           |
| ----------- | -------- | ------------------------------------- |
| `binding`   | `string` | R2 binding name from `wrangler.jsonc` |
| `publicUrl` | `string` | Optional public URL for the bucket    |

### Setup

Add the R2 binding to your Wrangler configuration:

<Tabs>
  <TabItem label="wrangler.jsonc">
    ```jsonc
    {
      "r2_buckets": [
        {
          "binding": "MEDIA",
          "bucket_name": "emdash-media"
        }
      ]
    }
    ```
  </TabItem>
  <TabItem label="wrangler.toml">
    ```toml
    [[r2_buckets]]
    binding = "MEDIA"
    bucket_name = "emdash-media"
    ```
  </TabItem>
</Tabs>

### Public Access

For public media URLs, enable public access on your R2 bucket:

1. Go to Cloudflare Dashboard > R2 > your bucket
2. Enable public access under Settings
3. Add the public URL to your config:

```js
storage: r2({
	binding: "MEDIA",
	publicUrl: "https://pub-xxxx.r2.dev",
});
```

<Aside type="tip">
	Connect a custom domain to R2 for branded URLs like `https://media.example.com`.
</Aside>

<Aside type="note">
	R2 bindings do not support signed upload URLs. If you need direct client uploads, use the S3
	adapter with R2 credentials instead.
</Aside>

## S3-Compatible Storage

The S3 adapter works with Cloudflare R2 (via S3 API), MinIO, and other S3-compatible services.

<Aside type="caution" title="Install the AWS SDK first">
	EmDash uses the AWS SDK at runtime for the S3 adapter but does not bundle it — core is
	deliberately SDK-agnostic so R2-only and local-only deployments stay lean. Install the SDK
	in your project before using `s3()`:

    ```sh
    pnpm add @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
    ```

    If you skip this step, `astro build` will fail with `Rollup failed to resolve import
    "@aws-sdk/client-s3"`. The R2 binding adapter (`r2()`) and local adapter do not need the
    SDK and are unaffected.

</Aside>

```js title="astro.config.mjs"
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

| 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

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.

```js title="astro.config.mjs — runtime environment variable example"
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" }),
		}),
	],
});
```

<Aside type="note" title="Cloudflare Workers">
	This runtime resolution does not work on Cloudflare Workers. Worker secrets and variables
	are exposed through the `env` parameter of the fetch handler (or `import { env } from
	"cloudflare:workers"`), not through `process.env` — even with the `nodejs_compat` flag
	enabled. For Workers deployments:

    - If you are using R2, use the [`r2()` adapter](#cloudflare-r2-binding) from
      `@emdash-cms/cloudflare`. This is the recommended path.
    - If you need a non-R2 S3-compatible backend on Workers, pass explicit values to
      `s3({...})` in `astro.config.mjs`. Note that `astro.config.mjs` runs at build time,
      so Worker secrets are not directly accessible there; you will need to inject values
      through your build pipeline.

</Aside>

### R2 via S3 API

Use S3 credentials with R2 for features like signed upload URLs:

```js
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.

### MinIO

```js
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

Use local storage for development. Files are stored in a directory on disk.

```js title="astro.config.mjs"
import emdash, { local } from "emdash/astro";

export default defineConfig({
	integrations: [
		emdash({
			storage: local({
				directory: "./uploads",
				baseUrl: "/_emdash/api/media/file",
			}),
		}),
	],
});
```

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

<Aside type="caution">
	Local storage does not support signed upload URLs. Files are uploaded through the server, which
	may be slower for large files.
</Aside>

## Environment-Based Configuration

Switch storage backends based on environment:

```js title="astro.config.mjs"
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

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

All storage adapters implement the same interface:

```typescript
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.
