Configuration
One object configures the whole server. Pass it to TrokkyExpress.create() or to startServer(), directly or from a trokky.config.ts file.
import { defineConfig } from '@trokky/trokky/express'
export default defineConfig({ schemas, storage: { data: { adapter: 'filesystem-data', options: { contentDir: './data/content' } }, media: { adapter: 'filesystem-media', options: { mediaDir: './data/media' } }, },})schemas and storage are the only required keys. Everything else has a default, applied by withDefaults() before the server starts.
Defaults below marked (dev) and (prod) differ by environment. The environment is config.env, falling back to NODE_ENV, falling back to 'development'.
Read this before the tables
Section titled “Read this before the tables”Some keys are read only by startServer(). If you build the Express app yourself and call TrokkyExpress.create() + mount(), these are parsed, defaulted, and then ignored:
server.portserver.trustProxyserver.lifecyclemailhooksroutes
There is no warning. Use startServer() if you need any of them — see Deployment shapes.
Top level
Section titled “Top level”| Key | Default | What it does |
|---|---|---|
schemas | — | Required. Your content schemas. See Schemas |
storage | — | Required. Data and media adapters |
env | NODE_ENV, then 'development' | Drives every environment-dependent default below |
media | see below | Image processing, upload limits, serving |
security | see below | Authentication, tokens, rate limiting, hashing |
server | see below | HTTP surface: paths, CORS, body parsing, static files |
studio | see below | The admin UI |
i18n | see below | Locales for content and UI |
features | unset | Auto-thumbnail and auto-slug field injection |
oauth | unset | Google sign-in |
oauth2 | unset | OAuth2 authorization server, used by CLI login and SSO |
captcha | unset | Turnstile, hCaptcha or reCAPTCHA on auth endpoints |
mail | unset | Outbound system email. startServer only |
hooks | unset | Event handlers and outbound webhooks. startServer only |
routes | unset | Custom Express routes. startServer only |
storage
Section titled “storage”Both data and media are required, each with an adapter name and an options object. defineConfig() throws if either adapter is missing.
The adapter must also be imported for its side effect, and the per-adapter options are documented in Storage adapters.
| Key | Accepted values |
|---|---|
storage.data.adapter | 'filesystem-data', 'postgres-data' |
storage.media.adapter | 'filesystem-media' |
The TypeScript union also lists cloudflare-d1, dynamodb, cloudflare-r2 and s3. Those adapters are not implemented; naming one compiles and then fails at startup.
| Key | Default | What it does |
|---|---|---|
media.processor | 'sharp' | Image engine. 'none', 'sharp', 'cloudflare-images', 'imagekit', 'imgix' |
media.variants | [] | Variants generated on upload |
media.upload.maxFileSize | 52428800 (50 MB) | Per-file upload limit in bytes |
media.upload.maxFiles | 10 | Files per upload request |
media.upload.allowedMimeTypes | JPEG, PNG, WebP, SVG, MP4, WebM, PDF, plain text | Accepted upload types |
media.serving.mode | 'api' | 'api' serves files through the API router; 'static' serves them from a static path |
media.serving.staticBasePath | '/media' | Path prefix when mode is 'static' |
media.serving.customDomain | unset | Domain used when building media URLs |
media.mediaUrlGenerator | unset | A function taking { _id, filename, mimeType } and returning a URL, or a config object handed to the Studio |
Each entry in media.variants is:
media: { variants: [ { name: 'thumbnail', width: 300, height: 200, format: 'webp', quality: 80, fit: 'cover' }, { name: 'hero', width: 1200, height: 800, format: 'webp', quality: 90, fit: 'cover' }, ],}name is required. format is one of jpeg, png, webp, avif; fit is one of cover, contain, fill, inside, outside.
Setting media at all replaces the defaulted sub-objects it contains, so a media block that sets only processor keeps the default upload and serving values, but one that sets upload: { maxFiles: 3 } drops the default maxFileSize and allowedMimeTypes. Spell out the whole upload object when you override it.
media.upload.maxFileSize is enforced separately from server.parsing.json.limit. Raising one without the other gives you a body-parser rejection instead of a media validation error.
security
Section titled “security”| Key | Default | What it does |
|---|---|---|
security.enabled | true | Master switch for authentication |
security.jwtSecret | 'dev-secret-change-in-production' (dev), unset (prod) | JWT signing key. Unset falls through to TROKKY_JWT_SECRET, then a random per-process secret |
security.tokens.accessTokenTtl | '2h' | Access token lifetime |
security.tokens.refreshTokenTtl | '7d' | Refresh token lifetime |
security.tokens.rememberMeTtl | '30d' | Lifetime when “remember me” is used |
security.validation.input | true | Validate request bodies |
security.validation.schemas | true | Validate documents against their schema |
security.validation.permissions | true | Enforce per-user permissions |
security.rateLimit.enabled | true | Rate limiting |
security.rateLimit.windowMs | 900000 (15 min) | Rate limit window |
security.rateLimit.maxRequests | 1000 (dev), 100 (prod) | Requests per window |
security.rateLimit.skipSuccessfulRequests | false | Count only failed requests |
security.cryptoOptions.adapterType | 'auto' | 'node' for bcrypt, 'webcrypto' for PBKDF2, 'auto' to detect |
security.cryptoOptions.saltRounds | 12 | bcrypt cost factor |
security.cryptoOptions.pbkdf2Iterations | 100000 | PBKDF2 iterations for the WebCrypto adapter |
security.adminUser | unset | Account created at startup if absent |
security.passkey | unset | WebAuthn configuration |
An unset jwtSecret in production is the trap: the server starts, mints tokens with a random secret, and invalidates every session on restart. Deployment shapes explains the consequence in full.
security.adminUser requires username, email, password, firstName and lastName; role defaults to 'admin'. Creation runs on every boot and is a no-op once the user exists, so leaving it in a production config means a known password in your repository forever.
The rate limit defaults are more permissive in development on purpose. A production instance behind a proxy counts requests per the address Express resolves, which is why the proxy trust setting matters — see Auth.
server
Section titled “server”| Key | Default | What it does |
|---|---|---|
server.basePath | '' | API base path. startServer treats an empty value as /api |
server.port | 3000 (dev), unset (prod) | Listening port. startServer only; falls back to PORT, then 3000 |
server.cors.origin | ['http://localhost:5173', 'http://localhost:3000'] (dev), false (prod) | Allowed origins. Boolean, string, array, or a function |
server.cors.methods | ['GET','POST','PUT','DELETE','OPTIONS'] | Allowed methods |
server.cors.allowedHeaders | ['Content-Type', 'Authorization'] | Allowed request headers |
server.cors.credentials | true | Allow credentialed requests |
server.cors.maxAge | unset | Preflight cache duration |
server.static.media | unset | { path, directory, maxAge? } serving media from disk instead of through the API |
server.static.assets | unset | { path, directory, maxAge? } serving Studio assets |
server.static.custom | [] | Additional { path, directory, maxAge? } entries |
server.parsing.json.limit | '50mb' | JSON body size limit |
server.parsing.json.strict | false | Express strict JSON parsing |
server.parsing.urlencoded.limit | '50mb' | Form body size limit |
server.parsing.urlencoded.extended | true | Express extended urlencoded parsing |
server.trustProxy | unset | Passed to app.set('trust proxy', …). startServer only |
server.lifecycle | unset | Startup and shutdown hooks. startServer only |
server.trustProxy being startServer-only has a wrinkle worth knowing: mount() calls app.set('trust proxy', 1) unconditionally, so a manually mounted app already trusts the first hop whatever you configured.
server.lifecycle takes four optional functions:
server: { lifecycle: { beforeStart: async (app) => { app.use(myMiddleware) }, afterStart: async (app, port) => { console.log(`up on ${port}`) }, beforeShutdown: async () => { await drainQueue() }, onError: async (error) => { report(error) }, },}beforeStart runs after the Express app is created and before Trokky mounts, which is the only place to install middleware that must sit in front of the API router.
studio
Section titled “studio”| Key | Default | What it does |
|---|---|---|
studio.enabled | true | Serve the admin UI |
studio.path | '/studio' | Mount path. mount()’s studioPath option overrides it |
studio.apiUrl | unset | Absolute API URL for the Studio, for cross-origin setups |
studio.requireAuth | true | Require sign-in |
studio.branding.title | 'Trokky CMS' | Title shown in the UI |
studio.branding.logo | unset | Logo URL |
studio.branding.theme | 'system' | 'light', 'dark' or 'system' |
studio.branding.colors.primary | unset | Primary accent colour |
studio.branding.colors.accent | unset | Secondary accent colour |
studio.structure | unset | Navigation structure. See Singletons for what it does and does not control |
studio.fields | [] | Custom field type registrations |
studio.settings.pageSize | 20 | Documents per page in list views |
studio.settings.enableDrafts | true | Draft workflow. See Drafts |
studio.settings.enableVersioning | false | Document versioning |
studio.settings.autoSave | true | Autosave while editing |
studio.settings.autosaveInterval | 30000 | Autosave interval in milliseconds |
studio.session.refreshBuffer | 300000 (5 min) | Refresh the token this long before expiry |
studio.session.warningBuffer | 600000 (10 min) | Warn the user this long before expiry |
studio.session.checkInterval | 30000 | How often session validity is checked |
studio.session.inactivityTimeout | 1800000 (30 min) | Sign out after this much inactivity |
refreshBuffer must stay below security.tokens.accessTokenTtl or the token expires before the refresh fires. With the defaults — 5 minutes against 2 hours — there is plenty of room; shortening accessTokenTtl to anything near 5 minutes removes it.
| Key | Default | What it does |
|---|---|---|
i18n.defaultLocale | 'en' | Default locale for content and UI |
i18n.supportedLocales | ['en', 'fr'] | Locales offered |
i18n.fallbackLocale | 'en' | Used when a translation is missing |
i18n.detectBrowserLanguage | true | Detect locale from the browser |
i18n.debug | true (dev), false (prod) | Log missing translations |
The supportedLocales default includes fr. If you want English only, say so — leaving it unset offers French in the Studio’s language switcher.
features
Section titled “features”Both blocks inject fields into your schemas at load time.
| Key | Default | What it does |
|---|---|---|
features.autoThumbnail.enabled | unset | Inject a thumbnail field into document schemas |
features.autoThumbnail.fieldName | unset | Name of the injected field |
features.autoThumbnail.skipSingletons | unset | Skip singleton schemas |
features.autoThumbnail.skipSchemas | unset | Schema names to skip |
features.autoThumbnail.maxFileSize | unset | Size limit for thumbnail uploads |
features.autoThumbnail.allowedTypes | unset | MIME types accepted for thumbnails |
features.autoSlug.enabled | unset | Inject a slug field |
features.autoSlug.sourceFields | unset | Fields to derive the slug from, in priority order |
features.autoSlug.unique | unset | Enforce slug uniqueness |
withDefaults() does not fill this block in — it is passed through as written, so an omitted features key means whatever the schema registry does on its own. Set the values you care about explicitly rather than relying on a documented default; the working demo config sets autoThumbnail.fieldName to '_thumbnail', autoSlug.sourceFields to ['title', 'name'], and both enabled flags to true.
Injected fields show up in generated types like any other field.
oauth, oauth2 and captcha
Section titled “oauth, oauth2 and captcha”| Key | What it does |
|---|---|
oauth.google.clientId | Google OAuth client id |
oauth.google.clientSecret | Google OAuth client secret |
oauth.google.redirectUri | Redirect URI registered with Google |
oauth2.enabled | Run Trokky as an OAuth2 authorization server |
oauth2.issuer | Issuer identifier |
oauth2.accessTokenTtl | Access token lifetime in seconds |
oauth2.refreshTokenTtl | Refresh token lifetime in seconds |
oauth2.deviceCodeTtl | Device code lifetime in seconds |
oauth2.authCodeTtl | Authorization code lifetime in seconds |
oauth2.pollingInterval | Device flow polling interval |
oauth2.clients | Registered clients: { id, name, description?, type?, secret?, redirectUris, allowedScopes?, grantTypes? } |
captcha.provider | 'turnstile', 'hcaptcha' or 'recaptcha' |
captcha.siteKey | Public site key |
captcha.secretKey | Server-side secret |
captcha.options.theme | 'light', 'dark' or 'auto' |
captcha.options.size | 'normal', 'compact' or 'invisible' |
captcha.options.language | Widget language; defaults to i18n.defaultLocale or 'auto' |
captcha.protectedEndpoints.login | Require a CAPTCHA on sign-in |
captcha.protectedEndpoints.passwordResetRequest | Require one on reset request |
captcha.protectedEndpoints.passwordResetVerify | Require one on reset verification |
None of the three has defaults. oauth2 is what trokky login uses for the device flow — see The CLI.
Guard these blocks on the credentials being present, so a missing environment variable disables the feature rather than half-configuring it:
oauth: process.env.GOOGLE_CLIENT_ID ? { google: { clientId: process.env.GOOGLE_CLIENT_ID, clientSecret: process.env.GOOGLE_CLIENT_SECRET || '', redirectUri: process.env.GOOGLE_REDIRECT_URI || '', }, } : undefined,Read by startServer() only.
| Key | Default | What it does |
|---|---|---|
mail.adapter | — | Required within the block. A MailAdapter instance |
mail.templateRenderer | built-in renderer | Custom email template renderer |
mail.defaultFrom | 'noreply@localhost' | Sender address |
mail.defaultFromName | unset | Sender display name |
mail.notifications.passwordReset | unset | Email on password reset request |
mail.notifications.passwordChanged | unset | Email when a password changes |
mail.notifications.userCreated | unset | Welcome email on user creation |
mail.notifications.userInvited | unset | Invitation email |
mail.notifications.securityAlerts | unset | Security alerts |
mail.debug | true (dev), false (prod) | Log mail operations |
Links inside those emails are built from STUDIO_URL, falling back to http://localhost:<port>. Set it, or your editors receive password reset links pointing at localhost.
Adapters ship at @trokky/trokky/mail/console, @trokky/trokky/mail/resend and @trokky/trokky/mail/smtp. resend and nodemailer are optional dependencies of @trokky/trokky.
Read by startServer() only. Each key takes a handler receiving an event object.
| Key | Event payload |
|---|---|
document.created | DocumentEvent |
document.updated | DocumentEvent |
document.deleted | DocumentEvent |
document.published | DocumentEvent |
document.unpublished | DocumentEvent |
user.created | UserEvent |
user.updated | UserEvent |
user.deleted | UserEvent |
user.login | UserEvent |
user.logout | UserEvent |
media.uploaded | DocumentEvent |
media.deleted | DocumentEvent |
webhooks | An array of WebhookConfig |
A DocumentEvent carries type, collection, document, previousDocument on updates, user when there is one, and timestamp. A UserEvent carries type, user, metadata and timestamp.
hooks: { 'document.published': async (event) => { await fetch('https://example.com/rebuild', { method: 'POST' }) }, webhooks: [ { url: 'https://example.com/hooks/trokky', events: ['document.published'], secret: process.env.WEBHOOK_SECRET, retry: { maxAttempts: 3, initialDelay: 1000, backoffMultiplier: 2 }, }, ],}Handlers can return a promise. WebhookConfig takes url and events as required, plus optional secret, headers and retry.
routes
Section titled “routes”Read by startServer() only. An array of routes, groups, or both.
A route:
| Key | Default | What it does |
|---|---|---|
path | — | Express-style path |
method | — | GET, POST, PUT, DELETE or PATCH |
handler | — | (req, res, next) => … |
middleware | unset | Middleware for this route |
auth | 'public' | true for any signed-in user, 'admin' for admins, 'public' for none |
description | unset | Documentation only |
A group wraps routes under a shared prefix, with optional middleware and auth applied to all of them:
routes: [ { prefix: '/api/forms', auth: 'public', routes: [ { path: '/contact', method: 'POST', handler: contactHandler }, ], },]Custom routes are mounted after Trokky’s own routers. Express matches in registration order, so a custom path that falls under the API or Studio mount is shadowed by them and your handler never runs.
Validating your config
Section titled “Validating your config”defineConfig() checks four things and throws on each: at least one schema, a storage block, a storage.data.adapter, and a storage.media.adapter. It also throws when env is production and security.jwtSecret is missing.
It runs only when you call it. Wrapping your config in defineConfig() is how you get those five checks; TrokkyExpress.create() and startServer() do not run them for you.