# How Instatic Implements Media Storage with Pluggable Adapters: A Technical Deep Dive

> Explore Instatic's media storage with pluggable adapters. Learn how Instatic handles uploads via a registry and two-phase contract for local and cloud storage.

- Repository: [CoreBunch/Instatic](https://github.com/CoreBunch/Instatic)
- Tags: deep-dive
- Published: 2026-07-26

---

**Instatic routes all media uploads through a singleton registry that delegates to pluggable storage adapters, enforcing a two-phase upload contract that keeps raw file bytes outside the QuickJS sandbox while supporting both local disk and remote cloud storage backends.**

Instatic's media subsystem is built around a flexible, plugin-friendly architecture that separates storage concerns from core CMS logic. The system uses a centralized registry to manage adapters that handle everything from local filesystem writes to remote object stores like S3 or Cloudflare R2. This design ensures that media bytes never cross into the QuickJS sandbox, preserving memory efficiency and security boundaries.

## The Singleton Registry Pattern

At the heart of Instatic's storage system lies the `MediaStorageRegistry`, implemented in [`src/core/plugins/mediaStorageRegistry.ts`](https://github.com/CoreBunch/Instatic/blob/main/src/core/plugins/mediaStorageRegistry.ts). This singleton acts as the central authority for all storage operations, maintaining a registry of available adapters and routing upload and read requests to the appropriate implementation.

### Built-in Local Disk Adapter

The registry automatically registers a built-in local disk adapter at boot time. This adapter serves as the default storage mechanism and uses the filesystem to persist media files. When no specific adapter is elected for a given media role, the system falls back to this local implementation.

### Plugin Registration via QuickJS Bridge

Plugins can contribute custom storage adapters through the QuickJS bridge using the `api.cms.media.registerStorageAdapter` method. This allows third-party developers to integrate external storage services without modifying core Instatic code. The registry stores these plugin adapters alongside the built-in local adapter, making them available for role-based election.

## Role-Based Adapter Election

Instatic supports distinct storage strategies for different media types through its role-based election system. Each media role—`original`, `variant`, `avatar`, `font`, or `plugin-pack`—can be assigned a specific storage adapter independently.

### The Active Adapter Table

Election results are persisted in the `active_media_storage_adapter` table, with helper functions defined in [`server/repositories/mediaStorageAdapters.ts`](https://github.com/CoreBunch/Instatic/blob/main/server/repositories/mediaStorageAdapters.ts). The system provides utilities like `getElectedAdapterId`, `electAdapter`, and `countAssetsForAdapter` to manage these assignments programmatically. An empty string (`''`) in the adapter ID field signifies the default local disk adapter.

When an upload request arrives, the dispatch handler ([`server/handlers/cms/mediaUploadDispatch.ts`](https://github.com/CoreBunch/Instatic/blob/main/server/handlers/cms/mediaUploadDispatch.ts)) queries this table to determine which adapter should handle the operation based on the media's intended role.

## The Two-Phase Upload Contract

All storage adapters must implement a strict two-phase upload contract that separates URL generation from byte transfer. This contract is enforced by the upload executor in [`server/handlers/cms/mediaUploadExecutor.ts`](https://github.com/CoreBunch/Instatic/blob/main/server/handlers/cms/mediaUploadExecutor.ts).

### Phase 1: Initiating the Upload

The process begins when the host calls `adapter.beginWrite(input)`, passing metadata including `suggestedStoragePath`, `role`, `mimeType`, and `size`. The adapter returns a `MediaStorageUploadPlan` containing one or more upload steps. Each step specifies either a signed HTTP URL (for remote storage) or a local filesystem indicator.

```typescript
// Phase 1: Begin upload
const plan = await adapter.beginWrite({
  suggestedStoragePath: 'uploads/2024/09/hero.jpg',
  role: 'original',
  mimeType: 'image/jpeg',
  size: 1_024_000,
});
// plan.steps contains signed URLs or local filesystem paths

```

### Phase 2: Streaming Bytes and Finalization

The host then streams file bytes directly to the URLs provided in the plan steps using Bun's native `fetch` implementation. This bypasses the QuickJS sandbox entirely, ensuring that large media files never consume sandbox memory.

```typescript
// Phase 2: Host streams bytes directly to each step
for (const step of plan.steps) {
  if (step.method === 'LOCAL') {
    // Local disk path extraction from file:// protocol
    await writeFile(step.url.slice('file://'.length), fileBuffer);
  } else {
    // Remote upload to signed URL
    await fetch(step.url, {
      method: step.method, // 'PUT' or 'POST'
      headers: step.headers,
      body: fileBuffer,
    });
  }
}

// Phase 3: Finalize and persist
const result = await adapter.finalizeWrite({
  storagePath: plan.storagePath,
  uploadReceipts: receiptsFromSteps,
});
// result.publicUrl stored in media_assets.public_url

```

### Abort and Cleanup

If any step fails, the host immediately invokes `adapter.abortWrite({ storagePath: plan.storagePath })` to trigger cleanup of partial uploads. This ensures that failed transfers do not leave orphaned chunks in remote storage or incomplete files on local disk.

## Local Disk vs. Remote Storage Implementation

The local disk adapter uses a sentinel value `LOCAL_DISK_STEP_METHOD = 'LOCAL'` to distinguish itself from remote adapters. When the executor encounters this method, it writes files using `fs/promises.writeFile` rather than HTTP requests.

Remote adapters must return actual HTTP methods (`'PUT'` or `'POST'`) with fully qualified signed URLs. The host handles all network I/O, maintaining the invariant that the QuickJS sandbox never processes raw media bytes. This distinction is crucial for avoiding the 64 MiB heap limit imposed on the sandbox environment.

Read operations for local assets are handled by the static `/uploads/*` handler, which is why the local adapter's `getReadUrl` method returns `undefined`—the public URL is already known and stored directly in the database.

## Read-Side Resolution

When serving media assets, Instatic resolves the original adapter through `mediaStorageRegistry.resolveForRead(storageAdapterId)`. This lookup bypasses role-based checks because the role was validated during the initial upload.

The system retrieves the adapter instance that originally wrote the asset and delegates read URL generation to it. For most remote adapters, this generates signed GET URLs or returns public CDN paths. For local assets, the system simply returns the `public_url` value stored in the `media_assets` table (defined in [`server/repositories/media.ts`](https://github.com/CoreBunch/Instatic/blob/main/server/repositories/media.ts)).

## Security: Isolating Media Bytes from the Sandbox

A critical architectural constraint prevents raw media bytes from entering the QuickJS sandbox. The test file [`src/__tests__/architecture/media-storage-no-bytes-in-sandbox.test.ts`](https://github.com/CoreBunch/Instatic/blob/main/src/__tests__/architecture/media-storage-no-bytes-in-sandbox.test.ts) enforces this invariant, ensuring that the host—not the plugin runtime—handles all file I/O.

By restricting adapters to only generating URLs and metadata while the host performs the actual byte transfer, Instatic prevents large media uploads from crashing the sandbox or exhausting its limited heap. This design enables support for multi-gigabyte files without risking memory violations in the plugin environment.

## Summary

- **Singleton Registry**: The `MediaStorageRegistry` in [`src/core/plugins/mediaStorageRegistry.ts`](https://github.com/CoreBunch/Instatic/blob/main/src/core/plugins/mediaStorageRegistry.ts) manages all storage adapters and handles plugin registration via `api.cms.media.registerStorageAdapter`.
- **Role-Based Election**: Each media role can elect a specific adapter through the `active_media_storage_adapter` table, allowing flexible storage strategies per content type.
- **Two-Phase Contract**: Uploads follow a strict pattern of `beginWrite` → direct byte streaming → `finalizeWrite`, with `abortWrite` available for cleanup on failure.
- **Sandbox Isolation**: Raw media bytes never cross the QuickJS boundary; the host performs all I/O using Bun's native fetch or filesystem APIs, avoiding the 64 MiB sandbox limit.
- **Adapter Flexibility**: The system supports local disk storage via a `LOCAL` sentinel value and remote storage through signed URL generation, unified under a common interface.

## Frequently Asked Questions

### How does Instatic prevent media bytes from entering the QuickJS sandbox?

Instatic enforces a strict two-phase upload contract where adapters only generate upload plans containing signed URLs or local paths. The host application performs all actual byte transfer using Bun's native `fetch` or `fs/promises` APIs. This architecture ensures that raw file data never passes through the QuickJS sandbox, preventing memory exhaustion and maintaining the 64 MiB heap limit invariant.

### Can different media types use different storage adapters?

Yes. Instatic supports role-based adapter election, allowing each media role—`original`, `variant`, `avatar`, `font`, or `plugin-pack`—to be assigned a distinct storage adapter. These assignments are stored in the `active_media_storage_adapter` table and resolved at upload time by the dispatch handler in [`server/handlers/cms/mediaUploadDispatch.ts`](https://github.com/CoreBunch/Instatic/blob/main/server/handlers/cms/mediaUploadDispatch.ts).

### What happens if a media upload fails mid-way?

If any step of the upload process fails, the host immediately calls `adapter.abortWrite({ storagePath })` to trigger cleanup. This method allows adapters to delete partial uploads from remote storage or remove incomplete files from local disk, preventing orphaned data and storage waste.

### How do I implement a custom S3 adapter for Instatic?

To implement a custom adapter, register it via `api.cms.media.registerStorageAdapter` in your plugin's initialization code. Your adapter must implement `beginWrite` to return signed S3 URLs in the upload plan steps, `finalizeWrite` to confirm upload completion and return the public URL, and `abortWrite` to clean up failed transfers. The host will handle all HTTP communication with S3, keeping your plugin code sandbox-safe.