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.S3_BUCKET && env.S3_ENDPOINT && env.S3_ACCESS_KEY_ID && env.S3_SECRET_ACCESS_KEY) {
storageAdapter = s3Storage({
bucket: env.S3_BUCKET,
endpoint: env.S3_ENDPOINT,
accessKeyId: env.S3_ACCESS_KEY_ID,
secretAccessKey: env.S3_SECRET_ACCESS_KEY,
publicUrl: env.S3_PUBLIC_URL || ''
}).adapter;
} else {
storageAdapter = createStorageAdapter('local', {
basePath: env.APHEX_UPLOADS_DIR || './uploads',
options: { legacyBasePaths: ['./static/uploads', './uploads'] },
baseUrl: '/uploads'
});
}
export { storageAdapter };Leave the S3_* vars empty in .env. Files land in ./uploads/ and are served at /media/{id}/{filename}, the route that enforces access control. Restart not required after upload.
Keep this directory outside static/. Everything under static/ is published at the site
root and copied into the build output, so uploads kept there are readable by anyone who guesses
the path — no session, no access check — and any file present at build time ships inside the
artifact permanently. That silently defeats private: true, which only the /media route
enforces.
S3_BUCKET=my-bucket
S3_ENDPOINT=https://<account>.r2.cloudflarestorage.com
S3_ACCESS_KEY_ID=...
S3_SECRET_ACCESS_KEY=...
S3_PUBLIC_URL=https://cdn.your-app.com
# AWS S3 only — R2 and MinIO ignore it. See the callout below.
# S3_REGION=us-east-1One set of variables covers every S3-compatible provider; only the endpoint and region differ between them.
Set S3_REGION on AWS
The region defaults to auto, which is what R2 and MinIO expect and what AWS S3 rejects — the
region is part of the SigV4 credential scope, so against a real S3 bucket every request signs as
auto and comes back SignatureDoesNotMatch with the configuration looking entirely correct.
Leave it unset for R2 and MinIO; set it to the bucket's region for AWS.
Previously R2_*
These variables used to be named R2_*. Both spellings are still read, S3_* winning where both
are set, so an existing deployment needs no change.
Filenames 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 ./uploads adapter |
|---|---|---|
| Storage path | ./storage/assets | ./uploads (or APHEX_UPLOADS_DIR) |
| Serving path | /media/{id}/{filename} (CMS handler) | /media/{id}/{filename} (CMS handler) |
| Access control | yes — passes through assetService | yes — same route, same checks |
| Cache-Control | max-age=31536000 (1y) | same |
| Max file size | 10 MB | configurable |
| Allowed MIME types | jpg / png / webp / gif / avif / pdf / text | configurable |
Both keep files out of the served tree, so /media is the only way to reach an asset in
either case. The template's version exists only to make the directory movable via
APHEX_UPLOADS_DIR, which is what a container deploy needs — point it at a mounted volume
and uploads survive a redeploy.
When changing the root for an installation that already has assets, copy or move the files
with their directory layout intact. The base template's legacyBasePaths setting rebases rows
written under the former ./static/uploads and ./uploads roots to the current
APHEX_UPLOADS_DIR; it never reads from those old directories. Remove static/uploads after
the move so files cannot bypass /media access control.
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.S3_BUCKET,
endpoint: env.S3_ENDPOINT,
accessKeyId: env.S3_ACCESS_KEY_ID,
secretAccessKey: env.S3_SECRET_ACCESS_KEY,
publicUrl: env.S3_PUBLIC_URL
});
export default createCMSConfig({
storage
// ...
});Options
Prop
Type
Provider snippets
s3Storage({
bucket: env.S3_BUCKET,
endpoint: env.S3_ENDPOINT, // https://<account>.r2.cloudflarestorage.com
accessKeyId: env.S3_ACCESS_KEY_ID,
secretAccessKey: env.S3_SECRET_ACCESS_KEY,
publicUrl: env.S3_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 — every asset gets an access-controlled /media/{id}/{filename} URL,
regardless of backend. Variants are siblings of it: /media/{id}/w960-{configHash}.webp.
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, or a signed URL. - Organization isolation — assets respect the same multi-tenant rules as documents. Parent orgs can read child org assets.
Every asset is proxied through this route by default, including S3 and R2 ones — the bytes are
read with the adapter's getObject and streamed back, so the checks above actually decide whether
the caller gets the file.
Older versions redirected S3/R2 assets straight to the bucket's public URL, which meant the access
checks ran and were then bypassed by the redirect — and broke outright on a private bucket. If you
relied on that redirect for bandwidth reasons, see signedDownloads below.
Proxying costs a round-trip through your app for every byte, which is the wrong trade for large files — a 200 MB video download shouldn't occupy a server process. Opt those out with a predicate:
export default createCMSConfig({
signedDownloads: {
// Access checks still run first: a signed URL is only ever minted for a
// request that was already allowed to read the file.
shouldUseSignedURL: (asset) => asset.size > 25 * 1024 * 1024,
expiresIn: 900 // seconds, default 15 minutes
}
});Requires getSignedUrl on the adapter. Without it the route proxies anyway rather than failing —
serving the file correctly beats refusing to serve it.
Private assets
Mark a field private: true and any asset uploaded into it requires authorization to read:
{ name: 'contract', type: 'file', private: true }
{ name: 'proofSheet', type: 'image', private: true }Both image and file fields support it. A request for a private asset without authorization gets
401; with a session belonging to another organization, 403. Private responses are always sent
Cache-Control: private, no-store, so they never land in a shared cache.
Privacy is resolved from the field an asset was uploaded into, recorded on the asset at upload time. An asset uploaded into a public field and later reused in a private one stays public. If you need an asset to be private, upload it through the private field — the media library passes the field along when you open it from one.
Signed URLs
A session is the wrong mechanism for a public-facing page: an <img>, a <video>, or a link in an
email carries no admin cookie. A signed URL grants one asset for a bounded window, so your app
can decide who may see a file and then hand them a link that proves it.
Set a secret:
export default createCMSConfig({
security: {
// Long and random; keep it out of source and stable across deploys.
assetSigningSecret: env.APHEX_ASSET_SIGNING_SECRET
}
});Then mint links server-side, where the secret lives — a load function, an API route, an email
renderer:
import { signAssetUrl } from '@aphexcms/cms-core/server';
export async function load({ locals }) {
const contract = await getContractFor(locals.user);
return {
// Valid for one hour, for this asset only.
url: signAssetUrl(
locals.aphexCMS.config.security?.assetSigningSecret,
`/media/${contract.assetId}/${contract.filename}`,
contract.assetId,
{ expiresIn: 3600 }
)
};
}The result is an ordinary URL on your own domain:
/media/b3f1c0de-…/contract.pdf?exp=1736450000&sig=8Qb2…Everything still runs through the media route, so range requests, image derivatives and access
checks all behave exactly as they do for any other asset. The bucket stays private and its key
layout stays invisible — unlike signedDownloads above, which redirects the viewer to a URL on the
storage provider.
What the signature covers. The asset id and the expiry, and nothing else. Not the filename,
which is cosmetic and would make a rename break live links. Not the requested width either — a
responsive srcset asks for the same image at several widths, so binding the width would mean a
signature per breakpoint. A signature answers "may this caller read this asset", not "which
rendition"; every derivative is the same picture, and the access decision is identical for all of
them.
Failure is closed. With no assetSigningSecret configured, signAssetUrl returns the URL
unchanged and verification always fails — so a misconfiguration costs access rather than granting
it. Expired, tampered and mismatched signatures are all rejected identically, without saying why.
Signed URLs are only needed for private assets. Public ones are served to anyone with the link, as in any CMS — signing them adds nothing.
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;
// Required: `/media/:id/:filename` proxies every asset through this, which is
// what makes its access checks real. An adapter that can't read its own
// objects back can't serve them.
getObject(path: string): Promise<Buffer>;
// Reading (optional — both are optimisations of getObject, and callers
// fall back to it when an adapter doesn't implement them)
getStream?(path: string): Promise<ReadableStream<Uint8Array>>;
getObjectRange?(path: string, start: number, end: number): Promise<ReadableStream<Uint8Array>>;
// 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 admin browsing, signed URLs)
listObjects?(options?: ListObjectsOptions): Promise<ListObjectsResult>;
copyObject?(sourcePath: string, destPath: string): Promise<boolean>;
getObjectMetadata?(path: string): Promise<StorageObjectMetadata>;
getSignedUrl?(path: string, expiresIn?: number): Promise<string>;
getSignedUploadUrl?(path: string, expiresIn?: number, contentType?: string): Promise<string>;
// Turn an adapter-relative key into whatever this adapter uses to address it.
resolvePath?(key: string): string;
// Adopt the app's `upload.maxFileSize`, so the limit has a single home.
setMaxFileSize?(bytes: number): void;
}Two of the optional methods are worth implementing rather than leaving to the fallback:
getStreammatters on serverless hosts. Vercel Functions cap a response body at 4.5 MB and returnFUNCTION_PAYLOAD_TOO_LARGEpast it, while a streamed response has no such cap — so an ordinary 5 MB photo proxied throughgetObjectis a hard error there, not just a slow request.getObjectRangeis what makes206 Partial Contentreal for video. Without it a browser still plays the file, but only by downloading from byte zero: seeking to the last minute of a recording transfers everything before it.
The name field identifies which adapter stored each file in cms_assets.storageAdapter, so it must be unique per backend. The required methods give you upload, delete, exists, URL generation and serving; the optional ones unlock in-app file browsing, pre-signed URLs, and full variant cleanup on delete.
getObject is required as of v1 — it was optional before. A custom adapter that doesn't
implement it will no longer type-check, and couldn't serve files anyway now that /media proxies.
Two optional methods are worth implementing even though nothing breaks without them:
listObjects— deleting an asset sweeps everything under its{assetId}/key prefix, which is how derivatives generated under a previous image config get cleaned up. The variant record is replaced wholesale when the config hash changes, so the database has no memory of those files; a prefix listing is the only thing that can still find them. Without it, deletion falls back to the recorded variants and older ones are orphaned in the bucket.setMaxFileSize— letsupload.maxFileSizebe the single place the limit is set. Without it your adapter keeps its own constructor value, and the two can disagree.
See also
Last updated on
Database
Aphex runs on SQLite by default (zero-infra) and Postgres for scale. Run migrations, switch adapters with one env var, and customize multi-tenancy.
Deployment
Ship Aphex to production — one-click on Render or Railway, or self-hosted with Docker. Platform comparison, production checklist, full environment variable reference.