Configuration
A Guren app keeps its configuration in config/. Two kinds of file live there:
Configuration
A Guren app keeps its configuration in config/. Two kinds of file live there:
config/env.tsdeclares every environment variable the app reads, with its type and whether it is required. The app validates them once, when it boots.config/<service>.tsfiles (database,cache,mail,session, ...) turn those validated values into service configuration. Each default-exports a config definition.
Both are plain data. Importing them opens no connection and registers nothing; createApp() does that.
// src/app.ts
import { createApp } from '@guren/core'
import cache from '../config/cache.js'
import database from '../config/database.js'
import env from '../config/env.js'
import http from '../config/http.js'
import { registerWebRoutes } from '../routes/web.js'
const app = createApp({
env,
config: [database, http, cache],
routes: registerWebRoutes,
})
export default app
create-guren-app writes this shape, and guren add cache, guren add mail, guren add queue, guren add storage, guren add session and guren add oauth add their definition to the config array once config/env.ts exists (see Apps with service providers).
Declaring the environment
config/env.ts default-exports a defineEnv() schema:
// config/env.ts
import { defineEnv, Env, type InferEnv } from '@guren/core'
const env = defineEnv({
APP_KEY: Env.string().secret().requiredInProduction()
.describe('Signs cookies and encrypts session payloads.'),
APP_URL: Env.url().requiredInProduction(),
PORT: Env.port().default(3333),
DATABASE_URL: Env.string().optional(),
CACHE_STORE: Env.string().default('memory'),
LOG_LEVEL: Env.enum(['debug', 'info', 'warn', 'error']).default('info'),
})
export default env
declare module '@guren/core' {
interface AppEnv extends InferEnv<typeof env> {}
}
The declare module block types the validated values everywhere the app reads them: in definitions, in this.make('env') from a controller, and in a database connection resolver.
Types
| Builder | Accepts | Value |
|---|---|---|
Env.string() |
anything | string |
Env.url() |
what new URL() parses |
string |
Env.number() |
a finite number | number |
Env.port() |
an integer from 1 to 65535 | number |
Env.boolean() |
true, false, 1, 0 (any case) |
boolean |
Env.enum([...]) |
one of the listed strings | the union of them |
Env.custom(schema) |
whatever a synchronous Standard Schema (Zod, Valibot) accepts | the schema's output |
Presence
A variable is required unless it says otherwise:
| Modifier | When the variable is unset |
|---|---|
| (none) | the boot fails |
.optional() |
the value is undefined |
.default(value) |
the value is value |
.requiredInProduction() |
the boot fails when NODE_ENV is production; undefined elsewhere |
A blank value counts as unset. REDIS_URL= in .env reads the same as no line at all, so it takes the default, or fails a required variable. Blank is what a .env.example usually ships and what a hosting dashboard leaves behind for a cleared variable. Chain .allowEmpty() when an empty string is a real value, such as a mail display name someone chose to leave out.
.secret() keeps the value out of error messages and makes guren env:example write the key blank. .describe(text) becomes the comment above the key in .env.example.
When validation fails
The boot stops with every problem at once:
[guren] Invalid environment (2 problems):
APP_KEY required, not set
PORT "80a" is not a port
What you cannot declare
NODE_ENV and every GUREN_* variable stay raw process.env reads, and defineEnv() throws if you list one. Deploy builds replace the exact expression process.env.NODE_ENV at bundle time, which a validated value would bypass, and GUREN_* switches (GUREN_MCP, GUREN_DOCS) are framework gates that must not depend on your schema.
Config definitions
A definition receives the validated env and returns the service's configuration:
// config/cache.ts
import { defineCacheConfig } from '@guren/core'
import { createRedisClient } from '@guren/core/redis'
export default defineCacheConfig((env) => ({
default: env.CACHE_STORE,
stores: {
memory: { driver: 'memory' },
// A function, so the client is built only when CACHE_STORE selects this store.
redis: { driver: 'redis', client: () => createRedisClient({ url: env.REDIS_URL }) },
},
}))
| Helper | Binds | Configuration |
|---|---|---|
defineDatabaseConfig(database, { seedOnBoot }) |
database |
connects the ORM at boot, and seeds when seedOnBoot is true and migrations exist |
defineHttpConfig |
(the HTTP kernel) | hostAuthorization |
defineSessionConfig |
session |
a SessionConfig |
defineCacheConfig |
cache |
a CacheConfig |
defineMailConfig |
mail |
a MailConfig |
defineQueueConfig |
queue |
a QueueConfig |
defineStorageConfig |
storage |
a StorageConfig |
defineOAuthConfig |
oauth |
providers and an optional stateStore |
createApp() registers a ConfigServiceProvider ahead of your providers. In its register() it validates the env and binds every definition's manager; in its boot() it runs the definitions' boot work (the database connection) before any other provider boots. So a provider can resolve cache or mail in its own register(), and the order of the config array does not matter.
A key is configured once. If a definition and a provider both bind cache, the boot fails and names both, rather than one silently winning. When you move a service to a definition, delete its provider.
Name checks belong in the definition. A manager accepts any store name and throws on first use, which may be a queued job hours later, so the cache, queue, mail and storage scaffolds check at boot:
// config/queue.ts
import { defineQueueConfig, MemoryDriver, SyncDriver } from '@guren/core'
const drivers = {
sync: () => new SyncDriver(),
memory: () => new MemoryDriver(),
}
export default defineQueueConfig((env) => {
if (!Object.hasOwn(drivers, env.QUEUE_CONNECTION)) {
throw new Error(
`QUEUE_CONNECTION="${env.QUEUE_CONNECTION}" is not a declared driver. Declare it in config/queue.ts or use one of: ${Object.keys(drivers).join(', ')}.`,
)
}
return { default: env.QUEUE_CONNECTION, drivers }
})
Conditional configuration is ordinary code in the callback:
// config/oauth.ts
import { defineOAuthConfig, type OAuthProviderConfig, createGitHubOAuthProviderConfig } from '@guren/core'
export default defineOAuthConfig((env) => {
const providers: Record<string, OAuthProviderConfig> = {}
if (env.OAUTH_GITHUB_CLIENT_ID && env.OAUTH_GITHUB_CLIENT_SECRET && env.OAUTH_GITHUB_REDIRECT_URI) {
providers.github = createGitHubOAuthProviderConfig({
clientId: env.OAUTH_GITHUB_CLIENT_ID,
clientSecret: env.OAUTH_GITHUB_CLIENT_SECRET,
redirectUri: env.OAUTH_GITHUB_REDIRECT_URI,
})
}
return { providers }
})
The database connection
config/database.ts keeps its named exports, because guren db:migrate and guren db:seed import them outside a running app. Its connection resolver receives the validated env when the app boots, and parses the schema itself when the CLI calls it:
// config/database.ts
import { createPostgresDatabase, defineDatabaseConfig } from '@guren/core'
import env from './env.js'
const database = createPostgresDatabase({
migrationsFolder: new URL('../db/migrations', import.meta.url),
seedersFolder: new URL('../db/seeders', import.meta.url),
connectionString: (context) => (context?.env ?? env.parse(undefined, { mode: 'report' }).values).DATABASE_URL
?? 'postgres://guren:guren@localhost:54322/guren',
})
export const { getDatabase, migrateDatabase, closeDatabase, configureOrm, seedDatabase } = database
export default defineDatabaseConfig(database, { seedOnBoot: process.env.NODE_ENV !== 'production' })
mode: 'report' returns the values it could validate instead of throwing, so guren db:migrate in production does not demand APP_KEY just to reach the database.
Keeping .env.example in step
config/env.ts is the list; .env.example should name the same keys.
bunx guren env:example
appends every declared key that .env.example lacks, with its default (blank for a secret) and its description as a comment. Lines already there are left as you wrote them.
bunx guren check --env
fails when the two disagree in either direction, which makes it a CI gate. Plain guren check runs the same comparison, and also reports a config/<name>.ts that createApp({ config }) never lists.
Reading the environment in code
Read validated values from the container rather than process.env:
import { Controller } from '@guren/core'
export default class AdminController extends Controller {
async index() {
const { ADMIN_EMAIL } = this.make('env')
// ...
}
}
In a scaffolded app, the guren/no-unvalidated-env-read lint rule reports a process.env.X read under app/, config/, routes/, src/ and modules/ when X is not NODE_ENV or a GUREN_* variable. bin/ and drizzle.config.ts run before any app exists and are left alone. Where a raw read is deliberate, disable the line and say why:
// oxlint-disable-next-line guren/no-unvalidated-env-read -- CI switch, not app config
const secureCookies = process.env.NODE_ENV === 'production' && !process.env.CI
Cloudflare Workers
On Workers, wrangler vars and secrets arrive on the entrypoint's env argument and are not guaranteed to reach process.env. @guren/plugin-cloudflare binds that argument before the app boots, and the schema reads a key from it first, then from process.env. The same config/env.ts works locally and on Workers with no changes.
Tests
TestApp.fromApp(app) boots your real src/app.ts, definitions included, so a feature test runs against the configuration production uses.
To try one variable without touching process.env, pass the schema and the overrides to TestApp.create(). envSource is read ahead of process.env, and '' makes a variable unset:
import { TestApp } from '@guren/testing'
import env from '../config/env.js'
const app = await TestApp.create({
env,
envSource: { CACHE_STORE: 'memory', APP_URL: '' },
providers: [ReportProvider],
})
An invalid override makes create() reject with an EnvValidationError.
Apps with service providers
An app created before config definitions configures services in providers (CacheProvider, MailProvider, a SessionProvider reading config/session.ts). Those keep working, and nothing requires moving them. bunx guren doctor --next finds them and prints each definition to write, with the values the provider read and the variables to declare. When you move one:
- Add
config/env.tsand declare the variables the provider reads. - Write
config/<service>.tswith the matchingdefine*Confighelper, readingenvinstead ofprocess.env. - Add it to
createApp({ config })and delete the provider fromproviders, in the same change. - Run
bunx guren env:exampleandbunx guren check.
Once config/env.ts exists and no provider binds the key, guren add <service> writes the definition form for the next service you add.