Skip to content

Storage adapters

Trokky stores documents and files through two separate adapters. storage.data holds documents, users, API tokens, webhooks, settings and audit logs. storage.media holds uploaded files and their generated variants. Both are required — there is no adapter that does both jobs.

Six adapters ship in @trokky/trokky:

AdapterKindWhat it uses
filesystem-datadataJSON files in directories you name
filesystem-mediamediaReal files in a directory you name
postgres-datadataA PostgreSQL database
cloudflare-d1dataA D1 database, bound to a Worker
cloudflare-r2mediaAn R2 bucket, bound to a Worker
s3-mediamediaAny S3-compatible bucket, over HTTP

There is no Postgres media adapter. Two media adapters need no disk: cloudflare-r2, which runs only on Workers because it takes a binding, and s3-media, which talks to any S3-compatible bucket over HTTP and therefore runs anywhere — including R2 itself, from a Node server.

An adapter registers itself in a global registry as a side effect of being imported. The configuration then names a registered adapter by string. Both halves are needed:

server.ts
import { TrokkyExpress } from '@trokky/trokky/express'
import '@trokky/trokky/adapters/postgres-data'
import '@trokky/trokky/adapters/filesystem-media'
const trokky = await TrokkyExpress.create({
schemas,
storage: {
data: {
adapter: 'postgres-data',
options: { connection: process.env.DATABASE_URL },
},
media: {
adapter: 'filesystem-media',
options: { mediaDir: '/data/media' },
},
},
})

Drop the import and the config still names postgres-data, but nothing registered it. Startup fails with Failed to create data adapter "postgres-data". A bundler that strips side-effect imports produces the same failure from a file that looks correct — see Traps for that one.

StorageConfig names an adapter that does not exist

Section titled “StorageConfig names an adapter that does not exist”

The data adapter field is typed as a union that also includes dynamodb. It is not implemented. TypeScript accepts it, and startup then fails because nothing registered under that name.

The media union used to name s3, which was never implemented either; it says s3-media as of 3.5.0, and the S3 options below are typed. Treat the six in the table above as the list.

Documents become JSON files on disk, one file per document, under directories you name. You can read them, diff them, and commit them.

Choose it while you are learning, for single-editor sites, and for content you want reviewable in git. Its costs are real: there is no transaction across files, concurrent writers on the same document are not coordinated, and listing a collection means reading the directory. It also cannot outlive an ephemeral filesystem — see Deployment shapes.

OptionDefaultWhat it does
contentDir'./content'Documents, one directory per collection
usersDir'./users'User records, including password hashes
tokensDir'./tokens'API tokens and refresh tokens
webhooksDir'./webhooks'Webhook registrations
settingsDir'./settings'Instance settings
auditLogsDir'./audit-logs'Audit log entries
createDirstrueCreate the directories above if missing
prettyJsontrueIndent written JSON
jsonSpaces2Indentation width when prettyJson is on
silentfalseSuppress the adapter’s warning logs

Those six directories are independent. Naming only contentDir leaves users, tokens and audit logs writing to ./users, ./tokens and ./audit-logs relative to the process working directory, which is rarely what you meant. Set all six, or set none:

data: {
adapter: 'filesystem-data',
options: {
contentDir: path.join(process.cwd(), 'data/content'),
usersDir: path.join(process.cwd(), 'data/system/users'),
tokensDir: path.join(process.cwd(), 'data/system/tokens'),
webhooksDir: path.join(process.cwd(), 'data/system/webhooks'),
settingsDir: path.join(process.cwd(), 'data/system/settings'),
auditLogsDir: path.join(process.cwd(), 'data/system/audit-logs'),
},
}

The adapter’s own type also accepts syncWrites (default false), fileMode (default 0o644) and dirMode (default 0o755). These are not declared on the storage.data.options type, so passing them through TrokkyExpress.create() is a TypeScript error even though the values would reach the adapter at runtime.

Documents live in a data JSONB column, one row per document. Users, tokens, webhooks, settings and audit logs get their own tables, all prefixed.

Choose it when more than one person edits at once, when the content set is large enough that reading a directory per list is slow, or when your host has no persistent disk for data. The costs: you now operate a database, your content is no longer readable as files, and pg is an optional dependency of @trokky/trokky — if your installer skips optional dependencies, the adapter throws on import.

OptionDefaultWhat it does
connectionprocess.env.DATABASE_URL, then 'postgresql://localhost:5432/trokky'Connection string, or a pg PoolConfig object
schema'public'PostgreSQL schema holding the tables
tablePrefix'trokky_'Prefix on every table Trokky creates
pool.max20Maximum pooled connections
pool.idleTimeoutMillis30000Idle connection timeout
pool.connectionTimeoutMillis2000Pool acquisition timeout
autoMigratetrueCreate tables and indexes on construction
sslfalsePassed to pg as the ssl option
enableQueryLoggingfalseLog every query
connectionTimeout5000Connection timeout in milliseconds

schema and tablePrefix are validated against ^[a-zA-Z_][a-zA-Z0-9_]*$ and ^[a-zA-Z0-9_]*$ respectively, and the constructor throws on anything else. They are interpolated into SQL, which is why.

Two mismatches to know about. The storage.data.options type declares useMigrations and dropExisting; the adapter reads neither, so setting them does nothing. Conversely autoMigrate, ssl, enableQueryLogging and connectionTimeout are read by the adapter but not declared on that type, so TypeScript rejects them in a TrokkyExpress.create() call even though they would take effect. The options the type and the adapter agree on are connection, schema, tablePrefix and pool.

With autoMigrate on, migration starts during construction rather than on first query. A database that is unreachable at boot surfaces as a startup error, not as a failing request later.

Documents live in a data JSON text column, one row per document, in a D1 database. Users, tokens, webhooks, settings, audit logs and pending auth-flow state get their own tables, all prefixed.

Choose it when you are deploying to Cloudflare Workers, which is the only place it runs: D1 is reached through a binding on the Worker’s env, not through a connection string, so there is nothing for a Node process to dial.

OptionDefaultWhat it does
databasenone, requiredThe D1 binding, e.g. env.DB
tablePrefix'trokky_'Prefix on every table Trokky creates
autoMigratetrueCreate tables and indexes on first use

Unlike postgres-data, migration is lazy rather than constructor-time: the first real call waits for the schema. A Worker isolate that never serves a request never migrates.

Two D1 properties worth knowing before you choose it:

There are no interactive transactions. The adapter exposes no beginTransaction, because D1 has none. The three places that need atomicity each do it in a single statement instead, so single-use auth state and password compare-and-set are still safe across isolates. Your own code cannot wrap several writes in a transaction.

SQLite is not Postgres. Booleans, integers and JSON nulls round-trip correctly because the adapter compares json_type alongside the value, but anything you write against the database directly needs the same care. Documents are one JSON blob; do not expect a column per field.

Uploads are written as R2 objects, with each file’s bytes and its metadata record stored as two separate objects, and variants under a third prefix.

Choose it when you are on Workers. Inside a Worker it beats s3-media against the same bucket: it is faster, and it holds no credentials.

OptionDefaultWhat it does
bucketnone, requiredThe R2 binding, e.g. env.MEDIA
prefix''Key prefix, so one bucket can host several installs
publicBaseUrlunsetOrigin of a public bucket or custom domain in front of it
maxFileSize104857600Reject uploads larger than this, in bytes
silentfalseSuppress the adapter’s informational logs

publicBaseUrl decides whether media URLs point at R2 directly. Leave it unset and getFileUrl/getVariantUrl return null, so media is served through Trokky’s own media routes. An R2 bucket binding cannot presign, so there is no third option: either the bucket is publicly reachable at some origin, or bytes flow through your Worker.

The layout is worth knowing if you ever look in the bucket: files/<id> holds the bytes, meta/<id>.json the record, variants/<id>/<name> each generated variant, all under prefix. The record object is written last on upload and deleted first on delete, so an interrupted write can leave bytes with no record — cleanup() reclaims those — but never a listing entry pointing at bytes that are gone.

Uploads are written to any S3-compatible bucket over signed HTTP: Cloudflare R2, MinIO, Backblaze B2, DigitalOcean Spaces, Ceph, or S3 itself. Added in 3.5.0.

Choose it when you are on a server and do not want a disk that has to survive a restart — the ordinary container or VPS deployment. It is the answer to “can my Node install put media on R2?”, which it can: R2 speaks the S3 API as well as the binding.

OptionDefaultWhat it does
endpointnone, requiredService endpoint, no bucket in it, e.g. https://<account>.r2.cloudflarestorage.com
bucketnone, requiredThe bucket name. It must already exist
accessKeyIdnone, requiredAccess key with read and write on that bucket
secretAccessKeynone, requiredIts secret
region'auto'SigV4 signs over a region whether the backend has one or not. R2 wants auto; AWS wants the bucket’s real region
forcePathStyletrueAddress objects as <endpoint>/<bucket>/<key>. Set false for AWS regional virtual-hosted URLs
prefix''Key prefix, so one bucket can host several installs
publicBaseUrlunsetOrigin of a public bucket or CDN in front of it
defaultUrlExpirySeconds900Lifetime of a presigned URL when no expiresIn is given
maxFileSize104857600Reject uploads larger than this, in bytes
silentfalseSuppress the adapter’s informational logs
import '@trokky/trokky/adapters/s3-media'
storage: {
media: {
adapter: 's3-media',
options: {
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: process.env.S3_REGION ?? 'auto',
},
},
}

The bucket is not created for you. Object stores do not make one on demand, and the first upload fails if it is missing.

“Required” above means the constructor throws without it. Every S3 option is optional in the storage.media.options type, so omitting endpoint type-checks and fails at boot instead.

Unlike an R2 binding, SigV4 can sign a URL. So with no publicBaseUrl set, getFileUrl and getVariantUrl return a presigned URL where the other adapters return null — and that URL is the authorisation, valid 15 minutes by default, usable by anyone holding it. Nothing in the return type distinguishes it from a public link. Do not log it, embed it in a cached page, or put it in a webhook payload without deciding that is acceptable. Set publicBaseUrl and you get a plain URL from that origin instead.

They still return null for an object that is not there. And expiresIn must be a whole number of seconds from 1 to 604800 — SigV4’s seven-day ceiling — or the call throws rather than handing back a URL that would only fail later.

files/<id> holds the bytes, meta/<id>.json the record, variants/<id>/<name> each variant — the same keys cloudflare-r2 writes. One bucket can be handed from a Worker to a Node server and back, which is the supported way to move an install between the two. A test writes with one adapter and reads with the other.

There is one addition: zero-byte objects under index/, which carry the sortable fields in the key. ListObjectsV2 returns no user metadata, so without them a media listing would need one request per file.

The records remain the only authority for what exists, and the distinction matters. A missing index entry costs one record read and nothing else. A wrong one — what an interrupted updateFile leaves — is served as it stands, so filters, sorts and totals use the stale values until cleanup() reconciles them against the records. Neither case can hide a file, because listings enumerate from meta/.

  • No multipart upload. The 100MB ceiling is well inside S3’s 5GB single-PUT limit. Raising maxFileSize past 5GB will not work — and media.upload.maxFileSize caps uploads at 50MB before the adapter ever sees them, so that is the effective default limit.
  • The whole file is buffered in memory before the PUT. Memory, not S3, is the practical ceiling.
  • prefix is validated. Letters, digits, dot, dash, underscore, tilde and slash only, and no .. segment — in a URL those are path operators, and writes would climb out of the bucket while listings would not.
  • Bulk deletes are one request per key. DeleteObjects needs a Content-MD5 and WebCrypto has no MD5, so deleting a file’s variants or a cleanup() sweep costs a request each. Both are rare and small.

Uploads are written as real files under mediaDir, alongside JSON metadata. Generated variants are written next to them.

On a server the decision is where mediaDir points and whether that path survives a redeploy — a container without a volume loses every upload on the next deploy. If you do not want to answer that question, use s3-media instead. On Workers, use cloudflare-r2: filesystem-media has no disk to write to there.

OptionDefaultWhat it does
mediaDir'./media'Directory holding files, metadata and variants
mediaBaseUrl'/media'Prefix for generated media URLs
createDirstrueCreate mediaDir if missing
prettyJsontrueIndent written metadata JSON
jsonSpaces2Indentation width when prettyJson is on
silentfalseSuppress the adapter’s warning logs

mediaBaseUrl deserves a note. The adapter’s own default is null, which makes it emit file:// URLs. The Express integration overrides that with '/media' when you leave it unset, so through TrokkyExpress.create() you get relative URLs, not file:// ones. Set it to an absolute origin when the frontend fetching media is not on the same origin as the CMS.

As with filesystem-data, syncWrites, fileMode and dirMode exist on the adapter but not on the storage.media.options type.

The two adapters are chosen independently, so the useful combinations are:

  • filesystem-data + filesystem-media — everything on one disk. The default, and the right one locally.
  • postgres-data + filesystem-media — content in a database, uploads on a volume. This is the production shape on a server, and the one where people forget that the volume is still needed.
  • postgres-data + s3-media — content in a database, uploads in a bucket, no disk anywhere. The shape for a container or VPS that you would rather not attach a volume to. Available since 3.5.0.
  • cloudflare-d1 + cloudflare-r2 — both on Cloudflare bindings, no disk anywhere, and no credentials to hold. The edge shape, Workers only. See Deployment shapes.

The two halves are chosen independently in principle, but not every pairing runs: the Cloudflare adapters need a Worker’s env, and the filesystem adapters need a filesystem. Mixing one of each gives you a config that compiles and a process that cannot start.

Schemas, queries, the Studio and the HTTP API are identical across adapters. Backups are too: trokky backup describes content rather than storage, so a filesystem backup restores into Postgres and back. Backup and restore covers what restore does and does not preserve.