> ## Documentation Index
> Fetch the complete documentation index at: https://docs.reasonos.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Environments and env files

> Declare, layer, and validate environment variables with .env files and a composable .env.schema

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

```
.env.schema        # RBS language. Committed. Declares environments + variables.
.env               # base values for every environment. Committed.
.env.<name>        # overlay for one declared environment. Committed.
.env.local         # personal overrides. Gitignored. Ignored in strict/CI mode.
```

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:

```python theme={null}
# services/api/.env.schema
env.schema(
    vars = {
        "PORT":         env.var(type = "int", default = "8080"),
        "LOG_LEVEL":    env.var(type = "enum",
                                values = ["debug", "info", "warn", "error"],
                                default = "info"),
        "DATABASE_URL": env.var(type = "url", required = True, secret = True),
    },
)
```

`env.var()` accepts:

| Argument   | Meaning                                                                           |
| ---------- | --------------------------------------------------------------------------------- |
| `type`     | `"string"` (default), `"int"`, `"bool"`, `"url"`, or `"enum"`                     |
| `values`   | Allowed values, for `type = "enum"`                                               |
| `default`  | Value used when no file sets it (always a string)                                 |
| `required` | Fail analysis if nothing sets it                                                  |
| `secret`   | Redact everywhere it is displayed or persisted (see [Secrets](#secrets))          |
| `doc`      | Human-readable description; shows up in `rbs env template`                        |
| `source`   | Resolve from somewhere other than the files (see [Value sources](#value-sources)) |

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

```python theme={null}
# rules/env/go_service.rbs
GO_SERVICE_ENV = env.schema(
    vars = {
        "PORT":      env.var(type = "int", default = "8080"),
        "LOG_LEVEL": env.var(type = "enum", values = ["debug", "info", "warn", "error"],
                             default = "info"),
    },
)
```

```python theme={null}
# services/api/.env.schema
load("//rules/env/go_service.rbs", "GO_SERVICE_ENV")

env.schema(
    extends = [GO_SERVICE_ENV],
    vars = {
        # A full language: requiredness can depend on the selected environment.
        "WORKOS_API_KEY": env.var(secret = True, required = env.name() == "prod"),
    },
)
```

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

```python theme={null}
# .env.schema (workspace root)
env.environment("dev")
env.environment("staging", extends = "dev")   # staging layers on dev's overlay
env.environment("prod")

env.files(pattern = ".env.{env}")             # the default; override freely,
                                              # e.g. "config/{env}.env"
```

Select an environment with `-e` on any command, or the `RBS_ENV` variable:

```bash theme={null}
rbs build //... -e prod
RBS_ENV=staging rbs run //services/api:server
```

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:

```python theme={null}
go_binary(
    name = "server",
    srcs = ["main.go"],
    env = env.vars(["DATABASE_URL", "PORT"]),
)

go_binary(
    name = "worker",
    env = env.vars(["DATABASE_URL"], override = {"GOMAXPROCS": "4"}),
)

vite_dev(
    name = "dev",
    env = env.all(),          # every declared variable — the explicit escape hatch
)
```

Naming a variable the schema does not declare is an **analysis-time error** listing the
declared names.

<Tip>
  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.
</Tip>

### Auto-injection

A package that declares its **own** `.env.schema` gives its targets the package
environment automatically — no `env` attribute needed:

```python theme={null}
# services/api/BUILD.rbs — picks up services/api/.env.schema by itself
go_binary(name = "server", srcs = glob(["**/*.go"]))
```

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:

```python theme={null}
"DATABASE_URL": env.var(secret = True, source = env.infra("maindb.connection_string")),
"HOME_REGION":  env.var(source = env.host()),
"BUILD_SHA":    env.var(source = env.command(["git", "rev-parse", "HEAD"])),
```

* `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

<Steps>
  <Step title="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.

    ```bash theme={null}
    rbs env check -e prod
    rbs env check --strict     # also ignore .env.local and fail on variables
                               # set in a file but declared in no schema
    ```
  </Step>

  <Step title="Inspect: rbs env print / environments">
    `print` shows the fully resolved environment, secrets redacted:

    ```bash theme={null}
    rbs env print -e prod
    rbs env print -e prod --format=json      # dotenv (default), export, or json
    rbs env print --package services/api     # resolve for one package
    ```

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

    ```bash theme={null}
    rbs env environments
    ```
  </Step>

  <Step title="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:

    ```bash theme={null}
    rbs env explain DATABASE_URL -e prod
    ```

    `diff` shows what actually differs between two environments:

    ```bash theme={null}
    rbs env diff dev prod
    ```
  </Step>

  <Step title="Bootstrap: rbs env template">
    Generates a `.env.example` from the schema — every declared variable with its type,
    docs, and defaults — on stdout:

    ```bash theme={null}
    rbs env template -e prod > .env.example
    ```
  </Step>
</Steps>

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