Configuration
Holo-JS keeps all backend settings in TypeScript files in the config/ folder. Values that change between machines, like database passwords, come from .env files. Both are read on the server only.
import { defineAppConfig, env } from '@holo-js/config'
export default defineAppConfig({
name: env('APP_NAME', 'my-app'),
key: env('APP_KEY'),
url: env('APP_URL', 'http://localhost:3000'),
timezone: env('APP_TIMEZONE', 'UTC'),
debug: env('APP_DEBUG', false),
})APP_NAME="My App"
APP_URL=http://localhost:3000
APP_DEBUG=trueConfig files
Each file in config/ configures one feature. The file name is the config name: config/queue.ts is read with useConfig('queue').
Every file default-exports its settings, wrapped in a define...Config helper that gives you autocomplete and type checking. Import each helper from the package for that feature:
| File | Helper | Import from |
|---|---|---|
app.ts | defineAppConfig | @holo-js/config |
database.ts | defineDatabaseConfig | @holo-js/db |
redis.ts | defineRedisConfig | @holo-js/kernel |
queue.ts | defineQueueConfig | @holo-js/queue |
cache.ts | defineCacheConfig | @holo-js/cache |
storage.ts | defineStorageConfig | @holo-js/storage |
media.ts | defineMediaConfig | @holo-js/media/config |
mail.ts | defineMailConfig | @holo-js/mail |
notifications.ts | defineNotificationsConfig | @holo-js/notifications |
broadcast.ts | defineBroadcastConfig | @holo-js/broadcast |
auth.ts | defineAuthConfig | @holo-js/auth |
session.ts | defineSessionConfig | @holo-js/session |
security.ts | defineSecurityConfig | @holo-js/security |
cors.ts | defineCorsConfig | @holo-js/security |
| any other name | defineConfig | @holo-js/config |
The env helper always comes from @holo-js/config.
Config files can use .ts, .mts, .js, .mjs, .cts, or .cjs. If two files share a name, Holo uses the first in that order.
Each feature's page documents its own options. The Configuration Files reference lists them all.
App settings
config/app.ts holds settings that apply to the whole app:
| Option | What it does | Default |
|---|---|---|
name | The app name | 'Holo' |
key | The secret key Holo uses for signing and encryption. See Generate the app key | '' |
url | The public URL of the app, used when Holo builds absolute URLs such as file links | 'http://localhost:3000' |
timezone | The server timezone, as an IANA name like Europe/Berlin | 'UTC' |
debug | Show detailed errors. Turn off in production | true |
env | 'development', 'production', or 'test' | 'development' |
paths | Where Holo looks for models, migrations, jobs, and so on. See Project Structure | the server/ folders |
plugins | Package names of Holo plugins to load. See Writing Plugins | [] |
Timezone
When the server starts, Holo sets the Node.js process timezone to timezone. That affects local Date methods, date strings without an offset, and Intl.DateTimeFormat without an explicit timeZone.
A few things don't change: Date.toISOString() and JSON dates stay in UTC, dates with an explicit offset keep it, and existing database values are not rewritten. Browsers keep using the visitor's timezone, so pass config('app.timezone') to the client if you need to format dates the same way there.
An invalid timezone name stops the app with an error at startup.
Environment variables
Use env() in config files to read a value from your .env files or the process environment:
import { env } from '@holo-js/config'
import { defineDatabaseConfig } from '@holo-js/db'
export default defineDatabaseConfig({
defaultConnection: 'main',
connections: {
main: {
driver: 'postgres',
host: env('DB_HOST', '127.0.0.1'),
port: env('DB_PORT', 5432),
username: env('DB_USERNAME', 'postgres'),
password: env('DB_PASSWORD'),
database: env('DB_DATABASE', 'my_app'),
},
},
})env() works like this:
env('KEY')returns the value as a string, or''if it isn't set.env('KEY', fallback)returns the fallback when the value is missing or empty.- When the fallback is a number or boolean, the value is converted to that type.
env('DB_PORT', 5432)returns a number.env('APP_DEBUG', false)acceptstrue,false,1,0,yes,no,on, andoff. Seeenv()for what happens to a value that doesn't convert.
Use env() in config/ files, and read config values everywhere else. That keeps every setting in one place.
Which .env files load
Holo picks an environment, then loads the files for it in order. Later files override earlier ones, and real environment variables override all files.
| Environment | Files, in load order |
|---|---|
development | .env, .env.development, .env.local |
production | .env, .env.production, .env.prod, .env.local |
test | .env, .env.test |
If both .env.production and .env.prod exist, Holo uses .env.production, skips .env.prod, and prints a warning.
Holo takes the environment from APP_ENV, checked in this order:
- The
--envoption, for examplenpx holo migrate --env=test. APP_ENVin the real environment, for example in your hosting provider's settings.APP_ENVin.env.
If none of them sets it, the environment is production. New projects write APP_ENV=development to .env, so local commands run in development.
APP_ENV must be development, production, or test. Any other value stops Holo with an error.
WARNING
Only .env is read to find APP_ENV. Setting APP_ENV in .env.local or .env.production does not change which files load.
.env.example
New projects include a .env.example with every key the app reads and no secret values. Commit .env.example. Don't commit .env or the other env files; the generated .gitignore already excludes them.
Reading config in your code
Import useConfig or config from @holo-js/config in any server code: route handlers, server actions, load functions, jobs, listeners, or commands.
import { config, useConfig } from '@holo-js/config'
const app = useConfig('app')
console.log(app.name, app.url)
const timezone = config('app.timezone')useConfig('name')returns a whole config file.config('name.path.to.value')returns one value by dot path.useConfigaccepts dot paths too.
Both are fully typed. config('app.') autocompletes the keys of config/app.ts, and the return type matches the value.
Custom config files
Add your own file to config/ for app-specific settings, such as third-party API keys. Wrap it in defineConfig:
import { defineConfig, env } from '@holo-js/config'
export default defineConfig({
mailgun: {
domain: env('MAILGUN_DOMAIN'),
secret: env('MAILGUN_SECRET'),
},
stripe: {
secret: env('STRIPE_SECRET'),
webhookTokenTtl: env('STRIPE_WEBHOOK_TTL', 300),
},
})Read it like any built-in config:
import { config, useConfig } from '@holo-js/config'
const services = useConfig('services')
const mailgunDomain = services.mailgun.domain
const stripeTtl = config('services.stripe.webhookTokenTtl')Holo generates the types for your custom files, so useConfig('services') and config('services.stripe.webhookTokenTtl') autocomplete, and their return values are typed from your file. The types update while holo dev runs. If they look stale, run npx holo prepare.
Keep config on the server
DANGER
Config files and .env values are for server code only. Never import config/*.ts, useConfig, or config in browser code, and never pass secret values to the client in page props or load results. If the client needs a setting, return only that one non-secret value from a server route.
Shared Redis connections
config/redis.ts defines named Redis connections. Queue, cache, session, rate-limit, and broadcast scaling settings refer to these connections by name, so you configure each Redis server once.
import { env } from '@holo-js/config'
import { defineRedisConfig } from '@holo-js/kernel'
export default defineRedisConfig({
default: env('REDIS_CONNECTION', 'default'),
connections: {
default: {
url: env('REDIS_URL') || undefined,
host: env('REDIS_HOST', '127.0.0.1'),
port: env('REDIS_PORT', 6379),
username: env('REDIS_USERNAME'),
password: env('REDIS_PASSWORD'),
db: env('REDIS_DB', 0),
},
},
})Each connection reaches Redis in one of these ways:
| To connect with | Set |
|---|---|
| A URL | url, such as redis://user:pass@host:6379/0 |
| A Redis Cluster | clusters: an array of { host, port } nodes. Cluster mode only supports db: 0 |
| A Unix socket | socketPath, or a host that starts with / or unix:// |
| Host and port | host and port (the default: 127.0.0.1:6379) |
Set at most one of url, clusters, or a socket path on a connection. Holo stops with an error if you set more than one.
Redis is optional. The file exists in every project, but nothing connects to Redis until a feature is configured to use it.
Generate the app key
APP_KEY is a random secret that Holo uses to:
- sign CSRF tokens
- sign temporary file URLs from storage
- encrypt multi-factor auth secrets
- encrypt social login tokens, unless you set a separate key in
config/auth.ts
New projects get a key automatically, because the prepare script runs holo key:generate when dependencies are installed. To create one yourself:
npx holo key:generateThe command writes a new 32-byte, base64-encoded key to APP_KEY in .env only if the key is missing or empty. It never replaces an existing key.
WARNING
Use a different APP_KEY in each environment and keep it out of git. In production, set it in your hosting provider's environment. If you change the key, existing CSRF tokens and temporary URLs stop working, and secrets encrypted with the old key, such as MFA secrets, can no longer be read.
Config cache
In production, Holo can load config from a cache file instead of evaluating every file in config/ on each start.
npx holo config:cacheThis writes .holo-js/generated/config-cache.json. To remove it:
npx holo config:clearHow the cache behaves:
- It is used only when the app runs in the
productionenvironment, and only if the cache was built for that same environment. - It stores the values your
env()calls returned when you built it, including secrets such asAPP_KEY. Changing.envhas no effect on those values until you runconfig:cacheagain. Config modules that contain functions, such asappwith an exception reporter,securitywith rate-limit callbacks, anddatabasewhen it contains functions, are loaded from their files, but theenv()calls they make while loading are frozen the same way. A changed.envhas no effect on them until you runconfig:cacheagain. A function that callsenv()after startup, such as a reporter's body, still reads the runtime environment, and so does any work a module starts and leaves running while it loads. Readenv()in the module's own code while it loads. Keep the cache file out of version control; the scaffolded.gitignorealready ignores.holo-js/generated. - It does not notice edits to
config/files. Runconfig:cacheagain after changing config, orconfig:clearto go back to reading the files.
Build the cache as a deploy step, after npm run build. New projects include config:cache and config:clear package scripts for this.
Available functions
Defining config
Reading config
Plugins
defineConfig()
Wraps a custom file in config/ so its values keep their types. Export the result as the default export.
import { defineConfig, env } from '@holo-js/config'
export default defineConfig({
mailgun: { domain: env('MAILGUN_DOMAIN') },
})defineAppConfig()
Wraps config/app.ts. It works like defineConfig() and adds the app settings type.
import { defineAppConfig, env } from '@holo-js/config'
export default defineAppConfig({
name: env('APP_NAME', 'Blog'),
})See app.ts.
defineSecurityConfig()
Wraps config/security.ts. Import it from @holo-js/security.
import { defineSecurityConfig } from '@holo-js/security'
export default defineSecurityConfig({
csrf: true,
})See Security.
defineCorsConfig()
Wraps config/cors.ts. Import it from @holo-js/security.
import { defineCorsConfig } from '@holo-js/security'
export default defineCorsConfig({
origins: ['https://app.example.com'],
})See CORS and proxies.
env()
Reads an environment variable. Without a fallback, a missing variable returns an empty string. With a fallback, the value is converted to the fallback's type: booleans accept true, false, 1, 0, yes, no, on, and off, and numbers use Number(). An empty value returns the fallback. A value that doesn't convert throws an error that names the variable.
const port = env('REDIS_PORT', 6379)
const debug = env('APP_DEBUG', false)config()
Returns one config value by dot path, such as config('app.name'). It's typed from your config files.
const mailgunDomain = config('services.mailgun.domain')useConfig()
Returns a whole config file (useConfig('app')) or one value by dot path (useConfig('app.name')). It's the same lookup as config().
const app = useConfig('app')registerConfigNormalizer()
Adds a normalizer that fills in defaults for a config file, which is how plugins add their own typed config. It returns a function that removes the normalizer. Most apps don't need it; see plugins.
import { registerConfigNormalizer } from '@holo-js/config'
registerConfigNormalizer({
name: 'audit',
normalize: (value: { readonly table?: string } | undefined) => ({
table: value?.table ?? 'audit_logs',
}),
})Next steps
- Configuration Files: every option for every config file
- Environment Variables: every env key Holo reads
- Deploying Holo Apps