Skip to content

Configuration

Configuration system

A configuration management system enables type checking and automatic value merging in the following priority order (highest wins):

txt
default variables → configuration file variables → environment variables

Environment variables are parsed to extract only keys with specific prefixes (improving security). Keys are split by __ (double underscore) to reconstruct the nested configuration object. Arrays must be passed as JSON strings.

PrefixNamespaceDescription
SERVER__serverServer host, port, domain
DB__dbDatabase connection URL and pool
AUTH__authBetterAuth secret, base URL, Redis
OIDC__oidcOIDC federation settings
BOOTSTRAP__bootstrapInitial admin user credentials
MODULES__modulesFeature module toggles
PLATFORM__platformPlatform-level app configuration

Variables matching a known prefix but no option are logged as warnings at startup, with a did-you-mean hint for misplaced __ — a silently ignored typo is how a feature "mysteriously" stays off. OTEL_* is the one namespace outside this system, because the OpenTelemetry SDK reads those names itself; see Observability.

Configuration files:

  • Development: apps/api/config-example.json
  • Production: /app/config.json (mounted at runtime)

Configuration tiers

Not every option belongs in the same place. The template splits configuration into three tiers, and where a setting lives is a deliberate choice rather than an accident:

TierExamplesSourceChangeable at runtime
Boot infrastructureDB__URL, AUTH__SECRET, SERVER__PORT, OIDC__CLIENT_SECRET, OTEL_*env / file onlyNo — redeploy
Boot behaviourrate limits, MODULES__*, OIDC federationenv / file onlyNo — wired up at startup
Runtime policyapp name, registration, quotas, maintenance mode, audit retentiondatabase, env/file can pin itYes — from Settings > General

Two reasons the first tier stays out of the database:

  • DB__URL is needed to read the database, so storing it there cannot work.
  • Anything wired up at boot (the Fastify rate limiter, BetterAuth, module registration) cannot be swapped without a restart. A toggle that silently requires a redeploy is worse than no toggle.

Runtime policy with an override. Tier-3 settings live in the web_setting table and are edited from the admin UI. An operator who needs them immutable — GitOps, compliance, ephemeral environments — pins them with PLATFORM__* env vars or the platform section of the config file. Pinned fields are returned in lockedFields, and the admin UI renders them read-only with the exact variable name that controls them. Pinned values are never written back to the database, so removing the override restores whatever was last chosen in the UI.

Seeing everything. Every boot-tier option is visible read-only under Settings > System > Runtime configuration: the effective value and which layer supplied it (env, file, or default). Secret values are redacted server-side and never sent to the browser — the view reports only whether one is set. The endpoint (GET /api/v1/config/runtime) requires the platform admin role because it exposes deployment topology.

API

The API is built on top of Fastify with the following plugins already configured:

The API is fully typed and validated through Zod schemas, with a custom type-safe routing system providing full type safety for request/response validation and automatic OpenAPI documentation generation.

Notes:

  • Swagger UI is available at http(s)://<api_domain>/swagger-ui. It exposes two spec sources: Application API (Fastify routes) and Auth API (BetterAuth — select from the top-right dropdown). A standalone Scalar reference UI for auth is also available at /api/v1/auth/reference.
  • A getApiClient function is exported from the shared package, providing a typed fetch client for other apps and packages that consume the API.

Database

Prisma is used as the ORM, providing type-safe database access and migration management. The schema is split across multiple files:

  • prisma/schema.prisma — generator and datasource configuration
  • prisma/auth.prisma — BetterAuth-managed models (user, session, account, org, member, invitation, apiKey, jwks)
  • prisma/audit.prisma — audit log model

The codebase is structured to allow migration to other ORMs (e.g. Drizzle, Mongoose) by replacing the prisma/ folder and updating the resources/**/queries.ts files.

Endpoints

MethodPathAuthDescription
GET/api/v1/healthzPublicStartup probe
GET/api/v1/readyzPublicReadiness probe (checks DB)
GET/api/v1/livezPublicLiveness probe
GET/api/v1/versionPublicCurrent API version
ANY/api/v1/auth/*Public / AuthBetterAuth catch-all
GET/api/v1/auth/referencePublicInteractive OpenAPI reference (Scalar UI)
GET/api/v1/projectsAuthenticatedList own projects (admin: all projects)
GET/api/v1/projects/:idAuthenticatedGet own project by ID (admin: any)
GET/api/v1/projects/:id/service-keysProject adminList API keys owned by the project
POST/api/v1/projects/:id/service-keysProject adminMint a project-owned API key (secret returned once)
DELETE/api/v1/projects/:id/service-keys/:keyIdProject adminRevoke a project-owned API key
POST/api/v1/projectsAuthenticatedCreate project (owner = current user)
PUT/api/v1/projects/:idAuthenticatedUpdate own project (admin: any)
DELETE/api/v1/projects/:idAuthenticatedDelete own project (admin: any)
GET/api/v1/themePublicGet platform theme configuration
PUT/api/v1/themeAdminUpdate platform theme configuration
GET/api/v1/configPublicGet app configuration (e.g. registration)
PUT/api/v1/configAdminUpdate app configuration
GET/api/v1/config/runtimeAdminIntrospect resolved server config (secrets redacted)
GET/api/v1/auditAudit:ReadQuery audit logs (admin: all; org admin: own org)
GET/api/v1/organizations/:organizationId/auditAudit:ReadQuery audit logs scoped to a specific organization
GET/api/v1/admin/organizationsAdminList all organizations with member counts
GET/api/v1/admin/organizations/:idAdminGet organization by ID with members and invitations
GET/api/v1/admin/api-keysAdminList all API keys
GET/api/v1/admin/api-keys/:idAdminGet API key by ID
GET/api/v1/admin/users/:idAdminGet user by ID with organizations, projects, and API keys
PUT/api/v1/api-keys/:idAuthenticatedUpdate own API key (name, permissions, metadata)

Ownership rules: regular users can only read, update, or delete projects they own (ownerId matches their session user ID). Admins bypass ownership checks. The ownerId is set automatically from the session on creation — it is not a caller-supplied field.

Environment variables

Database

VariableDescriptionDefault / Example
DB__URLPrimary PostgreSQL connection URL (read-write). Injected from the CNPG-generated secret in Kubernetes.postgresql://user:pass@host:5432/db
DB__READ_URLOptional read-replica URL (e.g. CNPG's -ro service). Pure read queries (findMany, findUnique, count) are routed here, offloading the primary. Falls back to DB__URL.postgresql://user:pass@host-ro:5432/db (optional)
DB__POOL__MAXMaximum connections in the primary (db) pg.Pool per API pod. Size for (maxReplicas × pool.max) + BetterAuth + headroom < max_connections.15
DB__POOL__RO_MAXMaximum connections in the read-replica (dbRo) pool per API pod. Can be higher than DB__POOL__MAX since replicas handle no write traffic.25

Server

VariableDescriptionDefault / Example
SERVER__HOSTServer listen address127.0.0.1
SERVER__PORTServer listen port8081
SERVER__DOMAINPublic host:port used in Swagger URLs127.0.0.1:8081
SERVER__BASE_PATHBase path prefix for all routes (set to "" on a dedicated API sub-domain)/api
SERVER__RATE_LIMIT__MAXGlobal Fastify rate-limit ceiling per IP per minute1000
SERVER__RATE_LIMIT__AUTH_MAXPer-IP rate limit for routes under /auth/* per minute20
SERVER__TRUST_PROXYTrust X-Forwarded-For behind a reverse proxy — see belowfalse

Set SERVER__TRUST_PROXY whenever the API runs behind an ingress, load balancer or CDN — which is every Helm and Compose deployment in this repo. Both rate limiting and the audit log key off request.ip, and without it that resolves to the proxy's address: the per-IP limit becomes a single bucket shared by every client (so a handful of concurrent users can 429 each other off the login page), and every audit entry records the proxy as the origin.

Accepts true, a hop count (1 = the one proxy in front of the API), or a comma-separated list of trusted addresses / CIDRs — loopback, linklocal and uniquelocal also work. Prefer a hop count or a list: a bare true trusts whatever the client puts in X-Forwarded-For, letting it forge its own address and poison the two things the setting exists to fix.

Auth, OIDC & Bootstrap

VariableDescriptionDefault / Example
AUTH__SECRET256-bit secret for session signing(required in production)
AUTH__BASE_URLPublic API base URLhttp://localhost:8081
AUTH__TRUSTED_ORIGINSComma-separated list of trusted CORS originshttp://localhost:3000
AUTH__REDIS__URLStandalone Redis URL for session secondary storageredis://redis:6379 (optional)
AUTH__REDIS__SENTINEL_URLSComma-separated host:port pairs for Sentinel mode — takes precedence over REDIS__URLredis:26379,redis-2:26379 (optional)
AUTH__REDIS__SENTINEL_MASTERSentinel master name (required with REDIS__SENTINEL_URLS)mymaster
AUTH__REDIS__PASSWORDRedis node password for both standalone and Sentinel modes(optional)
AUTH__REDIS__SENTINEL_PASSWORDSentinel node password — falls back to AUTH__REDIS__PASSWORD when not set(optional)
AUTH__RATE_LIMIT__ENABLEDEnable BetterAuth's per-IP rate limiter (separate from Fastify's). Disable for load testing.true
AUTH__RATE_LIMIT__WINDOWBetterAuth rate-limit window in seconds10
AUTH__RATE_LIMIT__MAXBetterAuth max requests per window per IP (defaults to 100; built-in stricter rules apply to /sign-in* etc.)100
AUTH__EMAIL_PASSWORD__ENABLEDLocal email + password accounts — set false for an SSO-only instance, see belowtrue
OIDC__ENABLEDEnable OIDC federation (e.g. Keycloak)false
OIDC__CLIENT_IDOIDC client IDtemplate-monorepo-ts
OIDC__CLIENT_SECRETOIDC client secret
OIDC__ISSUEROIDC realm issuer URL (internal, used for server-to-server calls)http://keycloak:8080/realms/<realm>
OIDC__PUBLIC_URLOIDC realm URL reachable by the browser (falls back to OIDC__ISSUER when empty)
OIDC__MAP_ROLESSync OIDC realm roles → BetterAuth rolefalse
OIDC__MAP_GROUPSSync OIDC groups → BetterAuth rolefalse
OIDC__MAP_ORG_ROLESSync OIDC org roles → BetterAuth org member rolefalse
OIDC__ORG_ROLE__PREFIXPrefix used to extract org role from OIDC token claimsorg-
OIDC__ORG_ROLE__DEFAULTDefault org member role when none is mappedmember
BOOTSTRAP__EMAILBootstrap admin emailadmin@example.com (optional)
BOOTSTRAP__PASSWORDBootstrap admin password — see the note below(optional)
MODULES__AUDIT__ENABLEDEnable the audit modulefalse
PLATFORM__APP_NAMEPlatform display nameTemplate Monorepo TS
PLATFORM__DOCUMENTATION_URLDocumentation URL shown in Swagger externalDocs
PLATFORM__ENABLE_REGISTRATIONAllow new user self-registrationtrue
PLATFORM__ALLOW_ORGANIZATION_CREATIONAllow users to create organizationstrue
PLATFORM__MAINTENANCE_MODEPut the platform in maintenance mode (read-only)false
PLATFORM__MAX_ORGANIZATIONS_PER_USERMaximum organizations a user can belong to (null = unlimited)null
PLATFORM__MAX_PROJECTS_PER_ORGMaximum projects per organization (null = unlimited)null
PLATFORM__AUDIT_RETENTION_DAYSDays to retain audit log entries (0 = keep forever)0

SSO-only instances

PLATFORM__ENABLE_REGISTRATION=false is not enough to make an instance SSO-only. It blocks POST /sign-up/email and hides the register link, but password sign-in stays open, so every account that already has one — including the bootstrap admin — keeps working, and the login page still leads with a credentials form.

AUTH__EMAIL_PASSWORD__ENABLED=false is the real switch. BetterAuth then rejects sign-in/email as well as sign-up/email, the login page drops the credentials form and offers only the configured providers, and /register redirects to /login.

It requires OIDC__ENABLED=true. Turning off passwords with no identity provider configured leaves no way in at all, so the server refuses to boot rather than coming up healthy with an unusable login page.

BOOTSTRAP__EMAIL still works and is still worth setting: it seeds the admin role, and because keycloak is a trusted provider for account linking, the first verified SSO sign-in with that address adopts the account. BOOTSTRAP__PASSWORD is hashed but can never be used — the server warns about it at startup.

The bootstrap admin password

The bootstrap account holds the platform admin role, and a platform admin skips every organization and project check. A guessable password on it is therefore a full takeover of the instance by anyone who has read this template — which, for a public template, is everyone.

So BOOTSTRAP__PASSWORD is treated like AUTH__SECRET:

  • The example env files ship the placeholder change-me-in-production, not a working credential.
  • In production the API refuses to boot when the value is one of the known placeholders (admin, password, changeme, change-me, change-me-in-production, compared case-insensitively) or is shorter than 12 characters. Outside production it logs a warning instead.
  • make init-env generates a random value when it copies the examples, so the local docker profiles come up without you choosing one. Read it back out of apps/api/.env (or .env.docker) when you need to sign in.

Leaving BOOTSTRAP__PASSWORD empty is always valid — the bootstrap step is skipped entirely and no admin account is created.

Observability

VariableDescriptionDefault
OTEL_SDK_DISABLEDDisable tracing and metrics entirelyfalse
OTEL_SERVICE_NAMEService name reported in traces and metricsapi
OTEL_EXPORTER_OTLP_ENDPOINTCollector OTLP/HTTP endpointhttp://localhost:4318
OTEL_METRICS_PORTPort of the Prometheus scrape endpoint exposed by the API9000

Unlike every other table on this page, these keep their SDK-standard spelling: no __ prefix, no config-file section, no merging. The OpenTelemetry SDK reads its own environment, so any standard variable works — OTEL_RESOURCE_ATTRIBUTES, OTEL_EXPORTER_OTLP_HEADERS, OTEL_TRACES_SAMPLER and so on — not just the four the application reads itself. Re-spelling them as OTEL__* would break that and silently drop anything not explicitly re-mapped.

They are still listed read-only under Settings > System > Runtime configuration, grouped under otel, so "did my endpoint land?" is answered in the same place as for the rest of the boot tier. A value the SDK cannot use (a malformed OTEL_METRICS_PORT, say) is reported as default — the value the process is really running with — and logged at startup.

Notes:

  • OTel is automatically disabled in test environments (NODE_ENV=test).
  • make dev starts only Postgres and Redis, so the SDK exports into the void and still binds OTEL_METRICS_PORT. Set OTEL_SDK_DISABLED=true in apps/api/.env, or start the collector with docker compose -f docker/docker-compose.dev.yml up otel-collector -d.
  • Under Docker Compose, OTEL_SERVICE_NAME and OTEL_EXPORTER_OTLP_ENDPOINT are set in the compose file's environment: block, which takes precedence over apps/api/.env.docker — change them there.
  • Under Helm, OTEL_METRICS_PORT is derived from api.metrics.service.targetPort when api.metrics.enabled is true, so the port the API listens on cannot drift from the one the Service and ServiceMonitor target. Setting it explicitly in api.env still wins.

Logging

VariableDescriptionDefault
LOG_LEVELMinimum log level (trace, debug, info, warn, error, fatal, silent)silent in test, debug in development, info in production
NODE_ENVControls log format and default levelproduction

Logging is provided by the @template-monorepo-ts/logger package (Pino-based). In development, logs are pretty-printed; in production, they are JSON-formatted for machine consumption. OpenTelemetry trace context (traceId, spanId) is automatically injected into every log entry when a span is active.

MCP Server

VariableDescriptionDefault
TMTS_SERVER_URLBase URL of the API server(required)
TMTS_TOKENBearer token for session-based auth
TMTS_API_KEYAPI key for key-based auth
TMTS_TRANSPORTTransport mode: stdio (local) or http (network)stdio
TMTS_HTTP_HOSTHTTP listen host (only when TMTS_TRANSPORT=http)0.0.0.0
TMTS_HTTP_PORTHTTP listen port (only when TMTS_TRANSPORT=http)3100

Web

VariableScopeDescriptionDefault
VITE_API_URLDev (Vite)Browser-side API URL (include base path, e.g. /api)http://localhost:8081/api
VITE_APP_VERSIONDev (Vite)App version display in dev modedev
API_PROXY_TARGETDev (Vite)Vite proxy target for /api (Docker network address)http://localhost:8081
API_URLProd (Docker)API URL injected via envsubst (include base path)http://api:8080/api
APP_VERSIONProd (Docker)App version injected via envsubst (set by CI/CD)dev

Enterprise proxy

If the API server needs to reach external services (Keycloak, OAuth providers) through an HTTP proxy, set the standard proxy environment variables:

VariableDescriptionDefault
HTTP_PROXYProxy URL for HTTP requests (e.g. http://proxy.corp.example.com:3128)
HTTPS_PROXYProxy URL for HTTPS requests (e.g. http://proxy.corp.example.com:3128)
NO_PROXYComma-separated list of hosts/domains to bypass (e.g. localhost,.local)

Bun natively routes all fetch() calls (used by BetterAuth, Keycloak OIDC, and health probes) through the proxy. Internal TCP connections (PostgreSQL, Redis) and the OTel HTTP exporter (which targets a local collector) are unaffected.

Docker Compose — proxy variables are passed through from the host environment automatically (no value assignment needed).

Kubernetes (Helm) — set the variables under global.env in your values file:

yaml
global:
  env:
    HTTP_PROXY: "http://proxy.corp.example.com:3128"
    HTTPS_PROXY: "http://proxy.corp.example.com:3128"
    NO_PROXY: "localhost,127.0.0.1,.cluster.local,.svc"