Skip to main content
rbs has a first-class story for environment variables: plain .env files supply values, per-environment overlay files specialize them, and a committed .env.schema declares which variables exist, their types, and which are secret. The environment is resolved when a package loads and validated before anything builds — a missing DATABASE_URL fails at analysis time with the file that should have set it, not as a runtime crash inside a running service.

The files

The value files are ordinary KEY=value dotenv files, so humans and other tooling can edit them. .env.schema is written in the build language — that is what makes conditionals, load(), and schema inheritance work. Both a workspace-root set and a per-package set are read: a package’s env files live next to its BUILD.rbs.

Declaring variables

The env module is predeclared in every .rbs file — like glob — so no load() is needed. A schema declares each variable with a type and constraints:
env.var() accepts:

Composable schemas

env.schema() returns a value, so a shared standard can live in one module and be inherited everywhere — one place defines what every Go service in the repo looks like:
extends takes a list, so a package can compose several standards. Declaring the same variable in two schemas is an error unless the extending schema restates it explicitly — overriding is allowed, shadowing by accident is not.

Declaring environments

rbs hardcodes no environment names — dev, staging, prod are not built in. The workspace-root .env.schema declares whatever environments you want:
Select an environment with -e on any command, or the RBS_ENV variable:
An undeclared -e name is a hard error that lists the valid names — never a silent fallback to an empty environment.

Precedence

For a file-sourced variable, lowest to highest:
  1. env.var(default = ...) in the schema
  2. workspace-root .env
  3. workspace-root .env.<name> (an extends chain applies the base environment’s file first)
  4. package .env
  5. package .env.<name>
  6. .env.local — workspace root, then package
  7. a literal override = {...} on the target
rbs env explain shows you exactly which layer won for any variable.

Using the environment in targets

env = ... can be set on any rule. The recommended shape is an allow-list of exactly the variables the target reads:
Naming a variable the schema does not declare is an analysis-time error listing the declared names.
The allow-list is not just documentation — it is cache hygiene. Only the variables a target consumes enter its cache key, so adding an unrelated line to .env rebuilds nothing. Under env.all() the target’s key depends on every declared variable in scope, so any edit re-runs it.

Auto-injection

A package that declares its own .env.schema gives its targets the package environment automatically — no env attribute needed:
Auto-injection applies only to packages with their own schema, only to targets that set no explicit env attribute, and only to actions that actually execute something. An explicit allow-list always wins — and narrows the target’s cache key, so prefer it as a package matures.

Value sources

A variable’s value normally comes from the files. The schema can point it somewhere else instead:
  • env.infra(ref) resolves from applied infrastructure state, so your service reads the same connection string your infra rules created.
  • env.host() is the only way a host machine’s variable reaches a build — it is explicit, declared, and hashed into the cache key, so differing host values produce different keys rather than false cache hits.
  • env.command([...]) runs a command and uses its output.
  • env.managed() declares a value the platform delivers to a branch node, so a developer can clone a branch and build without any setup.
  • env.define_source(name, impl) registers a custom resolver (a secret manager, for example), used as source = env.source("vault", key = "prod/db").
A variable with an explicit source may not also be set in an env file — two sources of truth for one value is the bug this prevents, so it is an error. The one exception is env.managed(): it is a platform-supplied default, and any file layer that sets the variable wins.

Secrets

Marking a variable secret = True changes how it is handled everywhere:
  • The real value still reaches the process that needs it — redaction applies where values are displayed or persisted, never where they execute.
  • The value is hashed into the cache key, so rotating a secret correctly invalidates the actions that consume it — but what the cache persists is a digest, never the plaintext.
  • rbs env print, diff, and explain show a digest. --reveal shows the value and is refused when stdout is not a terminal, because revealing into a pipe or a log file is how secrets end up committed.
  • A secret may not carry a default — a committed default defeats the point, and it is an analysis-time error.
  • Remote execution refuses to run an action that consumes secret variables, naming them, because the command environment would cross the wire. The build stops rather than leaking; run secret-consuming targets locally.

The rbs env CLI

1

Validate: rbs env check

Validates every package that declares a schema — missing required variables, type and enum violations — and exits non-zero on failure. This is the gate to run in CI before building.
2

Inspect: rbs env print / environments

print shows the fully resolved environment, secrets redacted:
environments lists every declared environment, marks the selected one, and reports how many declared variables have no value in each — the per-environment difference, and what breaks a deploy:
3

Debug: rbs env explain / diff

Layered dotenv precedence is guesswork without tooling. explain shows which file, line, or source won for a variable — and what it shadowed:
diff shows what actually differs between two environments:
4

Bootstrap: rbs env template

Generates a .env.example from the schema — every declared variable with its type, docs, and defaults — on stdout:

How resolution fits the build

The environment for a package is resolved when the package loads, before its build file runs — so env.vars() and env.all() see final values, and a missing required variable fails immediately rather than inside a running service. Validation happens at analysis time, before any action executes. Every env file that contributes is a declared, content-hashed input: changing .env.prod invalidates exactly the actions that read a variable it defines. A .env.local override changes the cache key rather than breaking reproducibility — it can never produce a false cache hit against a teammate’s or CI’s result.