QUESTPIE
Ship

Configuration

A QUESTPIE app is configured by a handful of files, each owning one concern. Which file owns which setting, what resolves from the environment on its own, and what a setting you never write falls back to.

View markdown

You have a database URL, a signing secret, an email adapter and a list of locales. Each one goes somewhere different. Here is the map.

The files

They all sit under src/questpie/server/. Codegen reads them and writes .generated/, which is what actually boots.

FileOwnsFactory
questpie.config.tsDatabase, adapters, secret, storageruntimeConfig()
config/app.tsLocales, default access, global hooks, per-request contextappConfig()
config/auth.tsBetter Auth optionsauthConfig()
config/<name>.tsOne plugin's config, such as config/admin.tsthe plugin's own
modules.tsPre-built modules you depend onnone, a plain array
env.tsEnv var schema and validationenv()

Only questpie.config.ts is required. A scaffolded project ships that, modules.ts, and two or three files under config/. It ships no config/app.ts at all.

The one you must write

src/questpie/server/questpie.config.ts
import { ConsoleAdapter } from "questpie/adapters/console";
import { runtimeConfig } from "questpie/app";

import env from "./env"; // declared and validated in env.ts

export default runtimeConfig({
	app: { url: env.APP_URL },
	db: { url: env.DATABASE_URL },
	email: { adapter: new ConsoleAdapter() },
});

Then build the app surface.

questpie generate

runtimeConfig() holds infrastructure and nothing else. Collections, globals, routes and jobs come from their own directories. There is no key here for them.

`email.adapter` is not optional

The mailer starts with every app. Leave email out and startup throws QUESTPIE: 'email.adapter' is required before the first request lands. Use ConsoleAdapter in development.

What resolves without you

Leave out app, db, secret or storage and runtimeConfig() reads the environment. Your value wins, then the QUESTPIE_* name, then the standard one.

FieldQUESTPIE CloudStandardIf neither
app.urlQUESTPIE_APP_URLAPP_URLhttp://localhost:3000
dbQUESTPIE_DBDATABASE_URLthrows
secretQUESTPIE_SECRETBETTER_AUTH_SECRETundefined
storageQUESTPIE_STORAGE_*nonelocal ./uploads

db is the one with no default. If none of the three has it, runtimeConfig() throws where you call it. On QUESTPIE Cloud the file collapses to what nothing can guess.

export default runtimeConfig({
	email: { adapter: new ConsoleAdapter() },
});

The rest of the slots

Every other key picks an adapter or flips a switch. All of them are optional.

KeyPicks
queueThe job backend. Required once you declare a job.
searchThe index. Postgres full-text when you skip it.
kvThe key-value store. In-memory when you skip it.
realtimeThe live transport. Always on. This tunes it.
storageWhere uploaded bytes land.
logger, observabilityLog format, traces and metrics.
executorSandboxed code execution. Off unless you set it.
autoMigrate, autoSeedWhether boot runs migrations and seeds.

Runtime options has the full table, the four shapes db accepts, and the storage keys.

config/app.ts

One file, four app-wide concerns. Every key is optional and the file itself is optional.

KeyDecides
localeWhich content locales exist and which one is the default.
accessThe rule a collection falls back to when it declares none.
hooksCallbacks that run across every collection or global.
contextWhat each request carries, resolved once, before anything.

context is the one worth learning first. It runs once per HTTP request and its return travels flat into every access rule, hook, route and getContext() call.

src/questpie/server/config/app.ts
import { appConfig } from "questpie/app";

export default appConfig({
	access: { read: true },
	context: async ({ request }) => ({
		tenantId: request.headers.get("x-tenant-id"),
	}),
});

Calls inside the resolver run in system mode, so access rules are bypassed. The resolver is the trusted derivation step.

`access` replaces, it does not merge

Set the key and a module's whole map goes. Write read: true alone and create, update and delete fall back to "require a session", not to what the module wanted.

App config covers all four keys.

config/auth.ts

The whole file is Better Auth options. Anything Better Auth accepts goes here.

src/questpie/server/config/auth.ts
import { admin, bearer } from "better-auth/plugins";
import { authConfig } from "questpie/app";

export default authConfig({
	plugins: [admin(), bearer()],
	emailAndPassword: { enabled: true, requireEmailVerification: false },
});

baseURL comes from app.url and secret from runtimeConfig(), so you do not repeat them. Setting either here still wins, because your options are spread last.

Your session type is built from the whole module tree's auth config intersected with this file. A module that adds a Better Auth plugin widens AppSessionUser for you. If a field is missing, run questpie generate before you cast.

modules.ts

A plain array of the pre-built modules you depend on.

src/questpie/server/modules.ts
import { adminModule } from "@questpie/admin/modules/admin";
import { openApiModule } from "@questpie/openapi";

export default [adminModule, openApiModule] as const;

Codegen reads this file first, in its own pass, so each module's file conventions are registered before discovery runs. The tree is walked dependencies first, and two modules with the same name collapse to the last one. See Modules.

Plugins bring their own file

config/app.ts and config/auth.ts are not special. Each config/<name>.ts becomes config.<name>, and a plugin claims one with a single discover pattern. That is how @questpie/admin gets config/admin.ts. Plugin config files shows how to claim one.

Where each topic lives

TopicPage
Every runtimeConfig() key, db, storageRuntime options
Locales, default access, global hooks, contextApp config
Claiming a config/<name>.ts from a pluginPlugin config files
Declaring and validating env varsEnvironment
What the generator reads and writesCodegen

Next

Environment is where env.APP_URL and env.DATABASE_URL in the example above come from.

On this page