Deployment shapes
There are two shapes worth deploying. One process serving the API, the Studio and your frontend together, or the CMS as its own service with the frontend deployed separately. Everything else is a variation on those.
Shape one: one process
Section titled “Shape one: one process”TrokkyExpress.create() returns an integration; mount() attaches its routers to an Express app you own. Your frontend can be routes on the same app.
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.listen(Number(process.env.PORT) || 3000)mount() attaches three things: the API router at /api, a static router at /, and the Studio router at /studio. Both paths are overridable:
trokky.mount(app, { apiPath: '/backend/api', studioPath: '/admin' })The Studio is told the API path at mount time, so it follows wherever you put it. trokky.getMountedPaths() returns { apiPath, studioPath } if you want to log them or build links from them.
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.
The higher-level entry point
Section titled “The higher-level entry point”startServer(config) creates the Express app for you, mounts Trokky, adds a /health endpoint, and returns { app, server, integration, stop, getInfo }.
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.
Shape two: split services
Section titled “Shape two: split services”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.
Environment variables
Section titled “Environment variables”Trokky itself reads five:
| Variable | Read by | Effect |
|---|---|---|
NODE_ENV | withDefaults | Sets env when config.env is unset. production tightens CORS and rate limits and removes the development JWT secret |
PORT | startServer | Listening port when server.port is unset |
DATABASE_URL | postgres-data | Connection string when connection is unset |
TROKKY_JWT_SECRET | The engine | JWT signing key when security.jwtSecret is unset |
STUDIO_URL | startServer’s mail service | Base URL in links inside system emails; falls back to http://localhost:<port> |
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.
The JWT secret is the one that bites
Section titled “The JWT secret is the one that bites”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.
Persistent storage
Section titled “Persistent storage”filesystem-media writes real files. There is no other media adapter, so every deployment needs a writable path that survives a restart.
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. Ifsecurity.adminUseris 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.
The workable production shape is postgres-data for content plus 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', }, },}Storage adapters covers the options on both.
Image processing
Section titled “Image processing”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.