Skip to content

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.

ts
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),
})
ini
APP_NAME="My App"
APP_URL=http://localhost:3000
APP_DEBUG=true

Config 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:

FileHelperImport from
app.tsdefineAppConfig@holo-js/config
database.tsdefineDatabaseConfig@holo-js/db
redis.tsdefineRedisConfig@holo-js/kernel
queue.tsdefineQueueConfig@holo-js/queue
cache.tsdefineCacheConfig@holo-js/cache
storage.tsdefineStorageConfig@holo-js/storage
media.tsdefineMediaConfig@holo-js/media/config
mail.tsdefineMailConfig@holo-js/mail
notifications.tsdefineNotificationsConfig@holo-js/notifications
broadcast.tsdefineBroadcastConfig@holo-js/broadcast
auth.tsdefineAuthConfig@holo-js/auth
session.tsdefineSessionConfig@holo-js/session
security.tsdefineSecurityConfig@holo-js/security
cors.tsdefineCorsConfig@holo-js/security
any other namedefineConfig@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:

OptionWhat it doesDefault
nameThe app name'Holo'
keyThe secret key Holo uses for signing and encryption. See Generate the app key''
urlThe public URL of the app, used when Holo builds absolute URLs such as file links'http://localhost:3000'
timezoneThe server timezone, as an IANA name like Europe/Berlin'UTC'
debugShow detailed errors. Turn off in productiontrue
env'development', 'production', or 'test''development'
pathsWhere Holo looks for models, migrations, jobs, and so on. See Project Structurethe server/ folders
pluginsPackage 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:

ts
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) accepts true, false, 1, 0, yes, no, on, and off. See env() 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.

EnvironmentFiles, 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:

  1. The --env option, for example npx holo migrate --env=test.
  2. APP_ENV in the real environment, for example in your hosting provider's settings.
  3. APP_ENV in .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.

ts
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. useConfig accepts 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:

ts
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:

ts
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.

ts
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 withSet
A URLurl, such as redis://user:pass@host:6379/0
A Redis Clusterclusters: an array of { host, port } nodes. Cluster mode only supports db: 0
A Unix socketsocketPath, or a host that starts with / or unix://
Host and porthost 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:

bash
npx holo key:generate

The 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.

bash
npx holo config:cache

This writes .holo-js/generated/config-cache.json. To remove it:

bash
npx holo config:clear

How the cache behaves:

  • It is used only when the app runs in the production environment, 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 as APP_KEY. Changing .env has no effect on those values until you run config:cache again. Config modules that contain functions, such as app with an exception reporter, security with rate-limit callbacks, and database when it contains functions, are loaded from their files, but the env() calls they make while loading are frozen the same way. A changed .env has no effect on them until you run config:cache again. A function that calls env() 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. Read env() in the module's own code while it loads. Keep the cache file out of version control; the scaffolded .gitignore already ignores .holo-js/generated.
  • It does not notice edits to config/ files. Run config:cache again after changing config, or config:clear to 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.

ts
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.

ts
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.

ts
import { defineSecurityConfig } from '@holo-js/security'

export default defineSecurityConfig({
  csrf: true,
})

See Security.

defineCorsConfig() ​

Wraps config/cors.ts. Import it from @holo-js/security.

ts
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.

ts
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.

ts
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().

ts
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.

ts
import { registerConfigNormalizer } from '@holo-js/config'

registerConfigNormalizer({
  name: 'audit',
  normalize: (value: { readonly table?: string } | undefined) => ({
    table: value?.table ?? 'audit_logs',
  }),
})

Next steps ​

Released under the MIT License.