Storage
Configure local filesystem or S3-compatible storage for asset uploads. The StorageAdapter interface lives at the bottom for custom-adapter authors.
Aphex uses a pluggable storage system for file uploads. By default it writes to the local filesystem so you can develop without setting anything up. For production you'll usually swap to S3, R2, or another S3-compatible backend.
Quick setup
The base template's src/lib/server/storage/index.ts picks an adapter at boot from environment variables:
import { s3Storage } from '@aphexcms/storage-s3';
import { createStorageAdapter } from '@aphexcms/cms-core/server';
import { env } from '$env/dynamic/private';
let storageAdapter;
if (env.R2_BUCKET && env.R2_ENDPOINT && env.R2_ACCESS_KEY_ID && env.R2_SECRET_ACCESS_KEY) {
storageAdapter = s3Storage({
bucket: env.R2_BUCKET,
endpoint: env.R2_ENDPOINT,
accessKeyId: env.R2_ACCESS_KEY_ID,
secretAccessKey: env.R2_SECRET_ACCESS_KEY,
publicUrl: env.R2_PUBLIC_URL || ''
}).adapter;
} else {
storageAdapter = createStorageAdapter('local', {
basePath: './static/uploads',
baseUrl: '/uploads'
});
}
export { storageAdapter };Leave the R2_* vars empty in .env. Files land in ./static/uploads/ and are served by SvelteKit's static handler at /uploads/.... Restart not required after upload.
R2_BUCKET=my-bucket
R2_ENDPOINT=https://<account>.r2.cloudflarestorage.com
R2_ACCESS_KEY_ID=...
R2_SECRET_ACCESS_KEY=...
R2_PUBLIC_URL=https://cdn.your-app.comFilenames get a timestamp + random suffix. The CMS skips its built-in local adapter when an S3 helper returns disableLocalStorage: true.
Default local adapter
When you omit the storage option from createCMSConfig entirely, Aphex spins up its own local adapter — different from the template's:
| Property | Default local (no template) | Template's static/uploads adapter |
|---|---|---|
| Storage path | ./storage/assets (private) | ./static/uploads |
| Serving path | /media/{id}/{filename} (CMS handler) | /uploads/... (SvelteKit static) |
| Access control | yes — passes through assetService | none — anyone with the URL |
| Cache-Control | max-age=31536000 (1y) | SvelteKit defaults |
| Max file size | 10 MB | configurable |
| Allowed MIME types | jpg / png / webp / gif / avif / pdf / text | configurable |
If you want the access-controlled /media/... serving without the template's pass-through, just delete the conditional in src/lib/server/storage/index.ts and don't set storage on the config at all.
Switching to S3
pnpm add @aphexcms/storage-s3s3Storage() returns { adapter, disableLocalStorage: true } so you can plug it straight into the config:
import { s3Storage } from '@aphexcms/storage-s3';
import { env } from '$env/dynamic/private';
const storage = s3Storage({
bucket: env.R2_BUCKET,
endpoint: env.R2_ENDPOINT,
accessKeyId: env.R2_ACCESS_KEY_ID,
secretAccessKey: env.R2_SECRET_ACCESS_KEY,
publicUrl: env.R2_PUBLIC_URL
});
export default createCMSConfig({
storage
// ...
});Options
Prop
Type
Provider snippets
s3Storage({
bucket: env.R2_BUCKET,
endpoint: env.R2_ENDPOINT, // https://<account>.r2.cloudflarestorage.com
accessKeyId: env.R2_ACCESS_KEY_ID,
secretAccessKey: env.R2_SECRET_ACCESS_KEY,
publicUrl: env.R2_PUBLIC_URL // your cdn / public bucket URL
});s3Storage({
bucket: 'my-bucket',
endpoint: 'https://s3.us-east-1.amazonaws.com',
accessKeyId: env.AWS_ACCESS_KEY_ID,
secretAccessKey: env.AWS_SECRET_ACCESS_KEY,
region: 'us-east-1'
});s3Storage({
bucket: 'my-bucket',
endpoint: 'http://localhost:9000',
accessKeyId: 'minioadmin',
secretAccessKey: 'minioadmin'
});Upload flow
When a file is uploaded via the admin UI or POST /api/assets:
Validation — MIME type and size are checked against the adapter's limits before anything touches disk.
Image metadata extraction — for images, Sharp extracts width, height, format, color space, dominant color, and ICC profile presence.
Storage — the file is stored via the adapter. S3 helpers generate unique filenames with a timestamp and random suffix.
Database record — an asset row lands in cms_assets with the metadata, storage path, and adapter name. If the database write fails, the storage write is rolled back.
URL generation — local storage gets a /media/{id}/{filename} URL (access-controlled). S3 returns its own public URL.
Image metadata
Sharp extracts the following on image upload:
| Field | Description |
|---|---|
width | Width in pixels |
height | Height in pixels |
format | Image format (jpeg, png, webp, …) |
space | Color space (srgb, rgb, …) |
channels | Number of color channels |
density | DPI if available |
hasProfile | Whether an ICC color profile is embedded |
hasAlpha | Whether the image has transparency |
dominantColor | Dominant RGB color |
Stored in the asset's metadata JSONB column.
Asset access control
Assets served through the CDN route /media/[id]/[filename] go through the same auth + capability stack as documents:
- Public assets — served without authentication.
- Private assets — fields marked
private: truein your schema require an authenticated session. - Organization isolation — assets respect the same multi-tenant rules as documents. Parent orgs can read child org assets.
For S3 storage with a public URL, assets bypass the CDN route entirely and are served directly from the S3 endpoint — keep that in mind if you need access control on every read.
Adapter tracking and migrations
Each asset row stores which storageAdapter was used (e.g. 'local' or 's3'). This enables:
- Safe migrations — move from local to S3 without breaking existing assets. Old files keep working through the local adapter while new ones flow into S3.
- Adapter mismatch warnings — deleting an asset stored by a different adapter logs a warning rather than silently dropping the row.
- Mixed storage — older assets on local storage continue to work after switching backends.
Custom adapters
Implement the StorageAdapter interface and pass the result to createCMSConfig({ storage }). Most users will never need to do this — start with the S3 helper unless you're integrating an unusual backend.
interface StorageAdapter {
readonly name: string;
// Core operations (required)
store(data: UploadFileData): Promise<StorageFile>;
delete(path: string): Promise<boolean>;
exists(path: string): Promise<boolean>;
getUrl(path: string): string;
// Info and health
getStorageInfo(): Promise<{ totalSize: number; availableSpace?: number }>;
isHealthy(): Promise<boolean>;
// Connection lifecycle (optional)
connect?(): Promise<void>;
disconnect?(): Promise<void>;
// Extended operations (optional — enable file serving, admin browsing, signed URLs)
getObject?(path: string): Promise<Buffer>;
listObjects?(options?: ListObjectsOptions): Promise<ListObjectsResult>;
copyObject?(sourcePath: string, destPath: string): Promise<boolean>;
getObjectMetadata?(path: string): Promise<StorageObjectMetadata>;
getSignedUrl?(path: string, expiresIn?: number): Promise<string>;
}The name field identifies which adapter stored each file in cms_assets.storageAdapter, so it must be unique per backend. Implementing only the required six methods gives you upload + delete + exists + URL generation; the optional methods unlock features like in-app file browsing and pre-signed URLs.
See also
Last updated on