Skip to content

Deployment shapes

There are three shapes worth deploying. One process serving the API, the Studio and your frontend together; the CMS as its own service with the frontend deployed separately; or the whole API as a single Cloudflare Worker with no server at all. Everything else is a variation on those.

TrokkyExpress.create() returns an integration; mount() attaches its routers to an Express app you own. Your frontend can be routes on the same app.

server.ts
import express from 'express'
import { TrokkyExpress } from '@trokky/trokky/express'
import '@trokky/trokky/adapters/filesystem-data'
import '@trokky/trokky/adapters/filesystem-media'
import config from './trokky.config'
const app = express()
const trokky = await TrokkyExpress.create(config)
trokky.mount(app)
app.use('/studio', studioRouter({ apiPath: '/api' }))
app.listen(Number(process.env.PORT) || 3000)

mount() attaches two things: the API router at /api and a static router at /. The API path is overridable:

trokky.mount(app, { apiPath: '/backend/api' })
app.use('/admin', studioRouter({ apiPath: '/backend/api' }))

The Studio is a router from @trokky/studio/express that you mount yourself. It reads its own mount path from the request, so it follows wherever you put it, prefixes included; tell it the API path so it knows where to call. Mount it before any catch-all of your own, such as a frontend handler or express.static, or that will swallow it. trokky.getMountedPaths() returns { apiPath }.

studioRouter() also accepts backendUrl (an absolute API URL, for proxies that rewrite the Host header), branding and i18n defaults for the login screen, and customFields. Everything else the Studio fetches from the API once it runs.

mount() also calls app.set('trust proxy', 1) unconditionally, so Express reads X-Forwarded-Proto from the first proxy hop. This happens whether or not you set server.trustProxy, and it happens even with no proxy in front of you.

What this shape buys you: one deploy, one origin, no CORS, and relative media URLs that work everywhere. What it costs: the frontend and the CMS scale together and restart together. A traffic spike on the site competes with editors saving documents.

startServer(config) creates the Express app for you, mounts the API, adds a /health endpoint, and returns { app, server, integration, stop, getInfo }. It does not mount the Studio: add server.app.use('/studio', studioRouter({ apiPath: '/api' })) after it returns, before your own catch-all.

import { startServer } from '@trokky/trokky/express'
import config from './trokky.config'
const server = await startServer(config)

It listens on 0.0.0.0 at server.port, falling back to PORT, then 3000. It also installs SIGTERM/SIGINT handlers that call server.stop().

This matters beyond convenience: server.lifecycle, server.trustProxy, hooks, routes and mail are read by startServer and by nothing else. Configure them and mount manually with TrokkyExpress.create(), and they are silently ignored. If you need custom routes, event hooks or outbound mail, use startServer.

The CMS runs as its own service. Your frontend is built and hosted elsewhere and talks to it over HTTP.

The Studio still runs inside the CMS process — it is served from the same Express app. What is split is your site, not the admin UI.

Four things change:

CORS stops being free. The default server.cors.origin in production is false, which rejects browser requests from another origin. Name your frontend explicitly:

server: {
cors: {
origin: ['https://example.com'],
credentials: true,
},
}

Media URLs must be absolute. With the CMS on cms.example.com and the site on example.com, a media URL of /media/... resolves against the wrong host. Set mediaBaseUrl on the media adapter to the CMS origin.

The frontend needs the API URL at the right time. If your frontend reads import.meta.env.TROKKY_API_URL, that value is baked in when the site is built, not when it runs. Setting it on the running process changes nothing. This one has a specific symptom — pages render, every image 404s — and its own entry in Traps.

Both services need the same JWT secret if you ever run more than one CMS replica. See below.

What this shape buys you: independent scaling, a static frontend on a CDN, and CMS restarts that do not take the site down. What it costs: CORS configuration, absolute URLs, and a second set of environment variables to keep in sync.

@trokky/trokky/workers exports createFetchHandler, which serves the API from a web-standard fetch handler. There is no Express app, no listening socket and no disk: storage is cloudflare-d1 for documents and cloudflare-r2 for media, both reached through bindings on env.

src/index.ts
import { TrokkyCore } from '@trokky/trokky'
import { createFetchHandler, type FetchHandler } from '@trokky/trokky/workers'
import { CloudflareD1Adapter } from '@trokky/trokky/adapters/cloudflare-d1'
import { CloudflareR2Adapter } from '@trokky/trokky/adapters/cloudflare-r2'
import { schemas } from './schemas'
interface Env {
DB: D1Database
MEDIA: R2Bucket
TROKKY_JWT_SECRET: string
}
let handler: FetchHandler | undefined
export default {
async fetch(request: Request, env: Env): Promise<Response> {
if (!handler) {
const core = new TrokkyCore(
{
// Required by TrokkyConfig even though the adapters below are passed directly.
storage: { adapter: 'cloudflare-d1', options: {} },
schemas,
},
{
data: new CloudflareD1Adapter({ database: env.DB }),
media: new CloudflareR2Adapter({ bucket: env.MEDIA }),
},
{ jwtSecret: env.TROKKY_JWT_SECRET },
)
handler = createFetchHandler({ core, basePath: '/api' })
}
return handler(request)
},
}

That handler requires a token on every protected endpoint, exactly as the Express integration does. Studio sends one, and a headless frontend reading the API needs an API token. Before 3.5.1, a Worker built this way served every endpoint to anyone, including user listing and token creation. Upgrade, or pass authentication: { enabled: true } explicitly if you are pinned to an older version. The only way to open it on purpose is authentication: { enabled: false }.

Two details in that snippet are not decoration. The storage block is required by the TrokkyConfig type and ignored when you pass adapters directly — omit it and TypeScript rejects the call. And the handler is cached in a module-scope variable rather than built per request, because bindings only exist inside fetch: building it every request would re-check the D1 schema on every request.

The matching Wrangler configuration:

{
"name": "my-cms",
"main": "src/index.ts",
"compatibility_date": "2026-08-06",
"compatibility_flags": ["nodejs_compat"],
"d1_databases": [{ "binding": "DB", "database_name": "my-cms", "database_id": "..." }],
"r2_buckets": [{ "binding": "MEDIA", "bucket_name": "my-cms-media" }]
}

Trokky’s core imports crypto, events and module directly — the token service, the OAuth2 authorization server, the event bus and the crypto adapter selector. Without the flag the Worker fails to start. This is unrelated to password hashing, which uses WebCrypto on its own.

Optional Node dependencies take care of themselves

Section titled “Optional Node dependencies take care of themselves”

sharp, pg and bcrypt are optional dependencies of @trokky/trokky, and none of them run on Workers. You do not have to do anything about them: the code that loads them uses require, which no bundler follows, so they never reach the bundle.

This was not always true. Before 3.2 those modules sat behind a dynamic import(), which a bundler resolves whether or not the branch executes, and the surviving reference broke the Worker at startup with Unable to resolve ... dependency "sharp": no matching module rules — sharp pulls in detect-libc, which wants fs and child_process. If you are pinned to an earlier version, alias the three to an empty module in your build.

  • The Studio is not served by the Worker. studioRouter() is an Express router. Host the Studio separately and point it at the Worker’s API.
  • No static file serving. Those routes return 501 on edge runtimes. Media is served through the API, or directly from R2 when you set publicBaseUrl.
  • No startServer configuration. hooks, routes, mail, server.lifecycle and server.trustProxy are read by startServer and by nothing else, so they are silently ignored here — the same trap as mounting manually in shape one.
  • No image variants at all. sharp is a native libvips binding and Workers runs no native code, so it cannot work there. On this path the image processor defaults to 'none' anyway — the 'sharp' default comes from the Express configuration layer, not the core — so uploads store originals and nothing generates thumbnails. Processing images at the edge means a remote service such as Cloudflare Images; Trokky has no processor wired up for one yet.

Rate limits are per isolate unless you share them

Section titled “Rate limits are per isolate unless you share them”

The default rate limiter counts in memory, which on Workers means per isolate — and Cloudflare runs many. The published limit is then effectively multiplied by however many isolates are warm.

D1RateLimitStore fixes that by keeping the counters in the D1 database you already have. It is not reachable through configuration: build the limiter yourself and pass it to the core.

import { RateLimiter } from '@trokky/trokky'
import { D1RateLimitStore } from '@trokky/trokky/adapters/cloudflare-d1'
const core = new TrokkyCore(config, adapters, {
jwtSecret: env.TROKKY_JWT_SECRET,
rateLimiter: new RateLimiter({
windowMs: 60_000,
maxRequests: 1000,
store: new D1RateLimitStore({ database: env.DB }),
}),
})

Keep tablePrefix the same on the store and the adapter, or they will not see the same table.

What this shape buys you: no server, no disk, no restarts to lose state to, and the API running in every Cloudflare location. What it costs: no Studio in the same deploy, and no image processing.

Trokky itself reads five:

VariableRead byEffect
NODE_ENVwithDefaultsSets env when config.env is unset. production tightens CORS and rate limits and removes the development JWT secret
PORTstartServerListening port when server.port is unset
DATABASE_URLpostgres-dataConnection string when connection is unset
TROKKY_JWT_SECRETThe engineJWT signing key when security.jwtSecret is unset
STUDIO_URLdefault for studio.urlPublic Studio URL: base of links in system emails and of the device-flow approval page; falls back to http://localhost:<port> for mail, with a boot warning

Everything else your config reads from process.env is your own convention. TROKKY_API_URL and TROKKY_API_TOKEN in these docs are frontend variables read by your code, not by Trokky.

On Workers none of this comes from process.env. Plain values and secrets arrive on the handler’s env argument, and D1 and R2 arrive there as bindings rather than as connection strings — which is why the Cloudflare adapters take an object and not a URL.

The signing key is resolved in this order: security.jwtSecret, then TROKKY_JWT_SECRET, then a freshly generated random secret.

There is no error on that last branch. A production deploy with neither set will start, sign tokens, and work — until the process restarts, at which point every session and every issued token is invalid, because the new process generated a different secret. With more than one replica behind a load balancer it is worse: a token minted by one replica is rejected by the others, so sign-ins appear to fail at random.

Set it explicitly:

security: {
jwtSecret: process.env.TROKKY_JWT_SECRET,
}

defineConfig() throws when env is production and security.jwtSecret is missing, which catches this — but only if you actually wrap your config in defineConfig(). TrokkyExpress.create() and startServer() do not call it for you.

filesystem-media writes real files, so a deployment using it needs a writable path that survives a restart. Two shapes avoid that question entirely: the Workers shape, which uses cloudflare-r2 through a binding, and any server using s3-media, which writes to an S3-compatible bucket over HTTP.

On a host with an ephemeral filesystem — a container image rebuilt on each deploy, a platform that discards the disk between releases — here is what is gone after the next deploy, exactly:

With filesystem-data, all six directories:

  • contentDir — every document, published and draft.
  • usersDir — every user account and password hash. Editors cannot sign in. If security.adminUser is configured, that one account is recreated on boot; nobody else is.
  • tokensDir — every API token and refresh token. Frontend builds authenticating with a stored token start failing.
  • webhooksDir — every webhook registration, so outbound notifications stop silently.
  • settingsDir — instance settings, back to defaults.
  • auditLogsDir — the entire audit history.

With filesystem-media, regardless of which data adapter you chose:

  • Every uploaded file and every generated variant. Documents still reference media ids that no longer resolve, so the CMS looks healthy and the site renders with broken images.

Switching to postgres-data fixes the first list and none of the second. Media still needs a volume — unless you switch it to s3-media and point it at a bucket, which works on the server you already have, or move to Workers and cloudflare-r2.

There are two workable production shapes on a server, and the choice is whether you want a volume. With one — postgres-data for content, a persistent volume mounted at mediaDir:

storage: {
data: {
adapter: 'postgres-data',
options: { connection: process.env.DATABASE_URL },
},
media: {
adapter: 'filesystem-media',
options: {
mediaDir: '/data/media',
mediaBaseUrl: 'https://cms.example.com',
},
},
}

Without one — postgres-data for content, s3-media for uploads, nothing on disk:

storage: {
data: {
adapter: 'postgres-data',
options: { connection: process.env.DATABASE_URL },
},
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,
},
},
}

The second shape has no volume to forget about, which is the failure this page keeps warning you about. It costs credentials to hold and a bucket to create first. Storage adapters covers the options on all three media adapters.

media.processor defaults to 'sharp', and sharp is an optional dependency of @trokky/trokky. If your install skipped optional dependencies, or your platform has no prebuilt binary for its architecture, image processing throws at the point of use with Sharp is not installed.

Uploads still work; variants do not get generated. Requested transforms come back as the original file with a 200 — see Traps for why that is silent.

Set media.processor: 'none' when you want that explicitly rather than as an accident.

Backups are not a deployment concern you can skip

Section titled “Backups are not a deployment concern you can skip”

A restore rewrites document ids for everything except singletons. If you are planning a migration between hosts, read Backup and restore and Singletons first — the second one describes a failure that only appears at restore time.