Manual Config

Config

elements man config Read as markdown

Config in Elements is a build primitive. It's expressed in JSOC, a language that extends JSON to make configuration easier to write across different environments. The compiler reads your config at build time, resolves environment values, and emits plain JSON. Anything that depends on a config value rebuilds when the value changes. Missing env variables, invalid types, and other config mistakes surface as build errors at the call site, not as runtime failures during a deploy.

A project has one config file: config.jsoc at the project root. It holds three things: the project's package dependencies, the import map, and app config (database, session, email, deploy, and so on). There is no package.json and no tsconfig.json.

Build-Time Evaluation

The compiler evaluates config.jsoc against the current environment values and emits the result as a JSON literal. The runtime never sees JSOC and never reads environment variables directly. Three things follow from this.

Missing required values fail the build. env!("DB_HOST") with the variable unset is a build diagnostic, not a runtime crash.

Wrong types fail the build. env<number>("SMTP_PORT", 587) with SMTP_PORT=abc errors at the call site.

Config references are constant-folded. A reference to a config value becomes a literal in the emitted code.

Config is part of the build output. When a config value changes, every file that imports or references that value is rebuilt and the affected modules are reloaded.

env(...) is the build-time channel: it reads an environment variable while the config compiles and folds the value into the output, so changing it triggers a rebuild. It is not the same as process.env. Runtime code that must read a live environment variable (one that can differ per process without a rebuild) reads process.env directly, and that value is never folded into the build. Use env(...) for anything config-shaped; reach for process.env only when a value has to stay out of the build.

Environments

Your local development environment is called development. The default deploy environment is called production. You can add others; staging is common.

An environment is a configured thing, not a free-form label you invent at the command line. development and production exist out of the box; any other name works only once it has its own config/env/<name>.env and, for deploys, a deploy.env.<name> block in config.jsoc. Commands that take an environment (elements deploy production, elements deploy staging) select one of these configured environments. Passing a name that isn't configured is an error, not a request to create a new one.

The environment name ties two things together. It selects the env file that supplies values (production loads config/env/production.env), and it names the deploy target that holds the machines (production reads deploy.env.production). Everything that serves live traffic runs under production, so its secrets go in production.env and its machines go in deploy.env.production. Add a new environment only when it has its own machines and its own env file, like staging; you don't make one per app.

Tests aren't a separate environment. They run inside every environment, against that environment's config. There's no test environment to keep in sync with development or production.

JSOC

JSOC is JSON with a few extensions that make it pleasant to write configuration in:

  • // line comments and /* ... */ block comments.
  • Unquoted keys: { name: "value" }.
  • Trailing commas in arrays and objects.
  • Single or double quoted strings.
  • The env(...) function for environment values.

Everything else is JSON.

{
  // line comment
  /* block comment */
  database: {
    name: "my_app",
    host: "localhost",
  },
  build: {
    typescript: {
      lib: ["es2022", "dom",],
    },
  },
}

The env() Function

env(...) reads a value from the environment at build time. The result is folded into the emitted config as a literal. There are three forms:

env("KEY", "default")          // optional, falls back to the default if unset
env!("KEY")                    // required, build error if unset
env<number>("KEY", 5433)       // type-coerced (number, boolean, string)

Each form guards against a common config mistake. env!("KEY") says the value must be present at build time. env<T>("KEY", default) says the value must coerce to the given type, with targets of number, boolean, or string. Booleans parse true/false/1/0/yes/no/empty. The plain env("KEY", default) form takes a fallback when the variable is unset.

Env Files

Environment values come from two places. OS environment variables provide the initial values. Files under config/env/ override them for the active environment:

config/env/
  development.env       # used when ENV=development (default)
  production.env        # used when ENV=production
  staging.env           # used when ENV=staging

Format is KEY=value per line. Comments start with #. No quoting required.

Saving an env file triggers an automatic rebuild. JSOC files that reference the changed value re-evaluate, files that depend on those config values rebuild, and the project server reloads the affected modules.

Commit development.env. It holds dev-safe values that every contributor can share. Don't commit production.env. Production secrets live there, and the file is gitignored by default.

At a Glance

config.jsoc:

{
  // Package dependencies. The package manager resolves these into
  // package.lock: don't edit package.lock by hand.
  package: {
    dependencies: {
      "@elements/app": "1.0.0",
      "@elements/style": "1.0.0",
    },
  },

  // Import map. `#config` resolves to this file; `#app/*` resolves to
  // the app source tree, so `import home from "#app/pages/home"` works
  // from anywhere without relative paths.
  resolve: {
    imports: {
      "#config": "./config.jsoc",
      "#app/*": "./app/*",
    },
  },

  // Database connection. Empty/omitted fields fall back to the bundled
  // Postgres cluster (127.0.0.1:5433, user postgres, no password).
  database: {
    name: "my_app",
    host: env("DB_HOST", "127.0.0.1"),
    port: env<number>("DB_PORT", 5433),
    user: env("DB_USER", "postgres"),
    password: env("DB_PASSWORD", ""),
  },

  // Email. `live: false` writes messages to the logger instead of sending.
  email: {
    live: env<boolean>("EMAIL_LIVE", false),
    from: "dev@example.com",
    host: env("SMTP_HOST"),
    port: env<number>("SMTP_PORT", 587),
    user: env("SMTP_USER"),
    password: env("SMTP_PASSWORD"),
    startTls: true,
  },

  // Session policy.
  session: {
    expires: "30d",
  },

  // How the app listens. The base for every environment, including the one you
  // run locally. A deploy environment overrides it under deploy.env.<name>.
  server: {
    port: env<number>("PORT", 4000),
    address: env("ADDRESS", "127.0.0.1"),
  },

  deploy: {
    domain: "example.com",
    ssh: { user: "ubuntu", identityFile: "~/.ssh/id_ed25519" },
    env: {
      production: {
        machines: [
          { name: "production1", publicIp: "203.0.113.10", privateIp: "10.0.0.10" },
        ],
      },
    },
  },
}

config/env/development.env:

DB_HOST=localhost

config/env/production.env:

DB_HOST=127.0.0.1

Config Blocks

  • package: the project's dependency lists (dependencies, devDependencies, …). Resolved into package.lock.

  • resolve.imports: the subpath import map (#config, #app/*, and any you add).

  • database: connection settings: name, host, port, user, password, appUser, appPassword. Empty fields fall back to the bundled cluster. Full topic: elements man database.

  • session: expires ("close" | a duration like "30d" or "1y"), autoRenew, domain, reaperIntervalSeconds. Full topic: elements man session.

  • email: flat SMTP settings: live, log, logFormat, from, replyTo, host, port, user, password, secure, startTls, timeout. Full topic: elements man email.

  • server: port (default 4000) and address (default 127.0.0.1). This is how the app listens, and it is the base for every environment, so it is the field to change when the port your app runs on locally is already taken. deploy and deploy.env.<name> override it in that order.

  • deploy: SSH, domains, machines, load balancer, services. Also port and address when a deployed environment needs something other than the server values. Full topic: elements man deploy.

  • build: assetUrl, assetPath, sourceMap, and typescript (a flat mirror of tsconfig's compilerOptions).

  • migrations: allowed, ignoreStaleMigrations. Full topic: elements man migrations.

  • workers: number of job/test worker processes to spawn.

Importing Config

You import config the same way everywhere:

import config from "#config";

let dbName = config.database.name;

#config is an import-map entry in config.jsoc that resolves to the config file. Server config cannot reach the browser: database credentials, session policy, SMTP passwords. Elements will not ship them to the browser bundle. Referencing a server-only config value from browser-reachable code is a compile error at the call site.

When browser-reachable code genuinely needs config, split it with a target-conditional import-map entry. Keep server config in config.jsoc, put a browser-safe subset in a second file, and point #config at each per target:

resolve: {
  imports: {
    "#config": {
      "browser": "./config.browser.jsoc",
      "default": "./config.jsoc",
    },
  },
}

The same import config from "#config" statement then resolves to the browser-safe file under the browser target and the full file under the server target. There's no shared block. A value needed on both sides is declared in both files.

process.env Constant Folding

process.env.X === "value" comparisons are constant-folded at build time. The compiler reads env values directly from the active environment's env file and folds the comparison to a literal, then eliminates the dead branch from the bundle. This is independent of JSOC; you don't have to expose a value through config.jsoc for process.env.X to fold.

if (process.env.ENV === "production") {
  trackAnalytics(event);
}

In a development build this becomes nothing (the whole if is dropped). In a production build it becomes the trackAnalytics(event); call.

The same works for custom env variables:

if (process.env.FEATURE_BETA_CHECKOUT === "true") {
  return betaCheckoutFlow();
}

return defaultCheckoutFlow();

When FEATURE_BETA_CHECKOUT is unset in the active environment, the beta branch and any imports it pulls in are removed entirely from the bundle.

The folding handles ===, ==, !==, != against string literals, nested && and || chains where every operand is foldable, and boolean literals. Use this for environment-conditional code that shouldn't ship to a target. Anything imported only inside a folded-false branch is shaken from the bundle.

App and Asset URLs

getAppUrl() returns the app's external base URL for the active environment, and getAssetUrl() returns the asset prefix (a CDN if configured, the app URL otherwise). Both are server-only and read from the running app's config: no env-var threading.

import { getAppUrl, getAssetUrl } from "@elements/app";

let verifyUrl = `${getAppUrl()}/verify?token=${id}`;       // e.g. https://example.com/verify?token=...
let logoUrl   = `${getAssetUrl()}/logo.png`;               // e.g. https://cdn.example.com/logo.png

Resolution for getAppUrl():

  1. If a domain is set for the active env (via deploy.env[env].domain or deploy.domain), the URL is <scheme>://<domain>: scheme is https when ssl is on (the default when a domain is set) and http when ssl is explicitly off. No port: the deploy load balancer fronts the box.
  2. If no domain is set and the active env is development, the URL is http://localhost:<port> from the env-resolved backend port (deploy.env.<env>.port > deploy.port > server.port > 4000).
  3. If no domain is set on a non-development env, the URL falls back to http://localhost: set a domain or rely on an upstream load balancer.

Resolution for getAssetUrl():

  1. build.assetUrl (top-level in config.jsoc, env-substituted via env("ASSET_URL")) when set.
  2. Otherwise getAppUrl().

build.assetUrl is the right home for a CDN URL: set ASSET_URL=https://cdn.example.com in config/env/production.env and ASSET_URL= (empty) in development.env, then build.assetUrl: env("ASSET_URL") in config.jsoc. The empty development value falls back to getAppUrl() so assets resolve to the development server.

The active environment is determined by getEnv() (also exported from @elements/app), which reads process.env.ENV set by the project server when it spawns each program.

Related

  • Database: elements man database. The database block and the bundled Postgres cluster.
  • Session: elements man session. The session block and lifetime policy.
  • Email: elements man email. The email block for SMTP and development-mode logging.
  • Deploy: elements man deploy. The deploy block for SSH and the machine list.