#Configurations
Alepha provides two primitives for configuration: $env for environment variables and $atom for runtime state.
#Environment Variables with $env
$env reads environment variables with schema validation, type coercion, and defaults. Import it from "alepha".
1import { $env, z } from "alepha"; 2 3class App { 4 env = $env( 5 z.object({ 6 DATABASE_URL: z.text(), 7 PORT: z.integer().default(3000), 8 DEBUG: z.boolean().optional(), 9 }),10 );11 12 connect() {13 console.log(this.env.DATABASE_URL); // string, guaranteed to exist14 console.log(this.env.PORT); // number, defaults to 300015 console.log(this.env.DEBUG); // boolean | undefined16 }17}
The schema must be a z.object(...). Each property maps to an environment variable name.
Alepha validates values at instantiation time and throws if required variables are missing.
#How env values are resolved
Alepha.create() merges process.env with the env key of the state object passed to create():
1const alepha = Alepha.create({2 env: {3 DATABASE_URL: "postgres://localhost/mydb",4 PORT: "8080",5 },6});
Values passed to Alepha.create({ env }) take precedence over process.env. Variables from .env files loaded before the process starts (e.g. via alepha dev) are available automatically through process.env.
A Cloudflare Worker receives its vars and secrets on the request's env binding, not on process.env. alepha.loadEnv(env) lifts them into alepha.env at runtime: only string values are taken (a D1, R2 or KV binding is skipped), a value already set wins, and the parsed $env cache is cleared so the next read sees them. The entry alepha build generates for Workers already does this.
#Variable interpolation
String values support $VAR interpolation using other variables from the same schema:
1class Config { 2 env = $env( 3 z.object({ 4 HOST: z.text({ default: "localhost" }), 5 PORT: z.integer().default(5432), 6 DB_NAME: z.text({ default: "mydb" }), 7 DATABASE_URL: z.text({ default: "postgres://$HOST:$PORT/$DB_NAME" }), 8 }), 9 );10}
Substitution is transitive: a value may reference a key whose own value references another one. A value that must keep a literal $KEY escapes it as $$KEY: with PORT declared, a password set to pa$$PORTss reads pa$PORTss rather than pa5432ss. Only keys declared in the same schema are substituted; an unknown $NAME stays as written.
#Reading a variable under another name
aliases lets a variable be read from other names when it is not set itself.
Hosts hand values over under names of their choosing - a port they allocated
arrives as PORT, a database they provisioned as POSTGRES_URL - and this is
how an app accepts them without renaming its own configuration:
1import { $env, z } from "alepha"; 2 3class Config { 4 env = $env( 5 z.object({ 6 DATABASE_URL: z.text({ aliases: ["POSTGRES_URL"] }), 7 SERVER_PORT: z 8 .integer() 9 .meta({ aliases: ["PORT"] })10 .default(3000),11 }),12 );13}
The declared key always wins. Aliases are tried in order and only when the key
itself is absent from the environment, and the value found is coerced and
validated as the key it stands in for - so PORT=8080 yields the number 8080
on SERVER_PORT, and PORT=nonsense fails validation the same way
SERVER_PORT=nonsense would. Defaults still apply when neither is set.
An alias is only ever read. It is not a key of its own: it stays out of the
parsed result, out of alepha gen env's variable list (it is mentioned against
the key it feeds) and out of the deploy manifest, so the declared name remains
the single one the rest of the app - and the deploy target - refers to.
alepha/server uses this for SERVER_PORT, which is why an app deployed to a
host that injects PORT binds the right port with no configuration.
#Environment caching
Alepha caches parsed env results per schema. Multiple services using the same z.object(...) reference will share the same parsed output.
#Declassifying a variable that is not secret
Every environment variable is treated as a secret. That is already what
happens on deploy - every declared key is pushed to the target as an encrypted
binding - so there is nothing to do for a DATABASE_URL or an API key.
The annotation is the opt-out, for the handful of variables that genuinely are not sensitive:
1import { $env, z } from "alepha"; 2 3class Payments { 4 env = $env( 5 z.object({ 6 STRIPE_SECRET_KEY: z.text(), // secret, like everything else 7 PUBLIC_URL: z.text({ secret: false }), // declassified: safe in plaintext 8 }), 9 );10}
secret: true is accepted and is the default, so writing it documents intent
without changing behaviour. .meta({ secret: false }) is equivalent to the
option - z.text({ ... }) forwards unknown options to .meta().
The default runs this way round on purpose. The annotation is easy to forget,
and forgetting it must never be what exposes a value: a missed secret: false
costs you an unnecessarily encrypted log level, while a missed secret: true
under the opposite default would leak a key.
Three things read it:
alepha gen envlabels the declassified variables in the generated template, so whoever fills it in can see at a glance which ones are safe to commit - everything unlabelled belongs in a secret store:txt# (public) #PUBLIC_URL= # Stripe API key #STRIPE_SECRET_KEY=alepha buildrecords them undervariablesindist/manifest.json, and every other declared key undersecrets. The two lists are disjoint: each key lands in exactly one, with the description its schema declared.alepha deploypushes a declassified key to Cloudflare as aplain_textbinding instead of an encryptedsecret_textone. That makes it readable in the dashboard and - the actual point - editable there, which a write-only secret is not. A key the app never declassified is still encrypted, so this only ever loosens what an author asked to loosen.Only keys the artifact itself vouched for are eligible: a key injected by an orchestrator through
.env.<env>.local, or listed ininfra().secrets.keysinalepha.config.ts, is not onvariablesand stays a secret.
#State Management with $atom
$atom defines a named, typed, validated piece of global state. Use it for application-level configuration and shared data.
#Defining an atom
1import { $atom, z } from "alepha"; 2 3const appConfig = $atom({ 4 name: "app.config", 5 schema: z.object({ 6 theme: z.enum(["light", "dark"]), 7 language: z.text({ default: "en" }), 8 }), 9 default: { theme: "light", language: "en" },10});
The name uniquely identifies the atom in the state store. The schema defines the shape and validation. The default provides the initial value.
Recommended naming convention for name is dot-separated, e.g. "app.config", "user.settings", etc.
If the schema itself is optional (wrapped with .optional(), e.g. z.object({...}).optional()), the default is optional too. Otherwise - even when every field inside the object is optional - default is required.
Beyond name / schema / default, an atom takes a few options that decide where its value may travel. On the server, what the current request wrote to the store is serialized into the HTML so the browser starts from the same state (SSR hydration), and a value set at boot never reaches the browser; these options shape that.
| Option | Effect |
|---|---|
description |
Shown in the devtools and generated docs |
persist |
"cookie", "localStorage" or "sessionStorage": the browser keeps the value across reloads |
serverOnly |
The value never goes into the SSR payload, and the devtools show the atom without its value |
personal |
The value belongs to the visitor: it goes into a per-request <script id="__ssr_private">, never the shared payload, so a cached page cannot hand it to the next visitor. Default for cookie-bound atoms |
serialize |
(value) => value: shapes the copy written into the HTML, to drop fields the browser has no business seeing. The server keeps the full value |
Four things to know before reaching for them:
serverOnlyandpersistcannot be combined -$atom()throws at call time.serializecannot remove a field the schema requires: the browser validates the result againstschema.- Cookie persistence is unsigned and unencrypted, and anything client-persisted is attacker-writable. Never persist roles, permissions, or entitlements in an atom; treat a persisted value as user input.
serverOnlyonly covers the SSR payload and the devtools: an$actionthat returns the value still returns it.
#Reading and writing atoms
Use alepha.store.get() and alepha.store.set() (or alepha.set() as shorthand):
1const alepha = Alepha.create(); 2 3// Read 4const config = alepha.store.get(appConfig); 5console.log(config.theme); // "light" 6 7// Write 8alepha.store.set(appConfig, { theme: "dark", language: "fr" }); 9 10// Shorthand write on the container11alepha.set(appConfig, { theme: "dark", language: "fr" });12 13// Update from the current value14alepha.store.mut(appConfig, (current) => ({ ...current, theme: "light" }));
#Reading atoms with $store
$store creates a reactive getter that always returns the current atom value:
1import { $atom, $store, z } from "alepha"; 2 3const count = $atom({ 4 name: "count", 5 schema: z.object({ value: z.number() }), 6 default: { value: 0 }, 7}); 8 9class Counter {10 count = $store(count);11 12 current() {13 return this.count.value; // always reads current state14 }15}
Under the hood, $store registers the atom and replaces the property with a getter that reads from the state store. When the state changes, the next property access returns the updated value:
1const alepha = Alepha.create();2const counter = alepha.inject(Counter);3 4console.log(counter.count.value); // 05 6alepha.store.set(count, { value: 42 });7console.log(counter.count.value); // 42
#State mutation events
alepha.store.watch(atom, (value, prevValue) => ...) observes one atom outside React, and returns an unsubscribe function. Underneath, every store.set() call emits a "state:mutated" event:
1alepha.events.on("state:mutated", ({ key, value, prevValue }) => {2 console.log(`State "${key}" changed from`, prevValue, "to", value);3});
Atoms are not only about configuration. They power SSR hydration, React integration, and devtools.
For a value derived from one or more atoms, $computed({ name, deps, get }) declares a read-only value recomputed on every read (alepha.store.get(computed)), never stored or hydrated. See State Management.