Guide

Environment Variables

Environment variables let you feed configuration and secrets into your signals and beacons without exporting everything into the Station process. Define a variable once — globally or scoped to specific targets — require it for a run, and change it from the dashboard while Station is running. It's the same mental model as Vercel's environments.

Variables live in a pluggable store and are injected into each run's process.env over the private IPC channel — never the spawn environment — so secret values are not exposed via /proc/<pid>/environ to other processes on the host.


Requiring a variable

Declare what a signal or beacon needs with .env(). Before a run is dispatched, the runner checks each key against the env store and the host process.env. If a key is missing, a signal run fails fast with a clear error and a beacon is marked errored instead of being spawned — no wasted child process, no half-configured run.

import { signal, z } from "station-signal"; export const charge = signal("charge")  .input(z.object({ amount: z.number() }))  .env("STRIPE_API_KEY")            // required — the run fails fast if unset  .run(async (input) => {    const stripe = new Stripe(process.env.STRIPE_API_KEY!);    await stripe.charges.create({ amount: input.amount });  });

Beacons take the same method. A missing required variable keeps the beacon down (errored) rather than crash-looping; defining the variable and restarting it clears the error.

import { beacon } from "station-beacon"; export const priceFeed = beacon("price-feed")  .env("EXCHANGE_API_KEY")  .restart("always")  .run(async (ctx) => { /* ... */ });
A run failed for a missing variable is terminal — it is not retried, because retrying without the value can't succeed. Define the variable, then trigger again. If the env store is briefly unreachable (a database blip), the run is left pending and retried instead of being failed with a misleading "not defined" error.

Global vs. scoped

A variable with no targets is global — injected into every signal and beacon run. A variable scoped to specific targets is injected only into those, and overrides a global variable of the same key. This is how you keep one default and specialise it for a single job:

DefinitionApplies to
DB_URL (no targets)Every signal and beacon.
DB_URL scoped to signal reportsOnly reports — and it wins over the global DB_URL there.

Two variables may share a key only if their scopes can never both apply to one target, so resolution is always deterministic. The store rejects a definition that would make a key ambiguous.


Secrets

Mark a variable secret and its value becomes write-only: the API and dashboard return value: null, and the real value is still injected at run time. A secret can't be downgraded to non-secret — once hidden, it stays hidden. Rotating a secret is a normal value edit; the stored scope and secret flag are preserved.

A handful of keys are reserved and rejected: PATH, NODE_OPTIONS, NODE_PATH, LD_PRELOAD, LD_LIBRARY_PATH, the DYLD_* loader variables, and any STATION_* / __STATION internal. They change how the child process executes rather than what your handler reads, so managing them through the store is disallowed — and the runner re-checks them at the child boundary as defense-in-depth.

Storage

Variables live behind an EnvStorageAdapter. The default is a JSON file at <dataDir>/station-env.json (fsync'd, 0o600, no native dependencies) — fine for a single-process deployment. For multi-process or multi-replica setups, pass a durable adapter via envStorage. Each ships in the /env sub-path of the corresponding adapter package:

// station.config.tsimport { defineConfig } from "station-kit";import { EnvPostgresAdapter } from "station-adapter-postgres/env"; export default defineConfig({  signalsDir: "./signals",  envStorage: new EnvPostgresAdapter({ connectionString: process.env.DATABASE_URL }),});

For tests, MemoryEnvStorage ships in station-env itself, alongside the EnvStore that wraps any adapter with validation, secret masking, and resolution.


HTTP API

Variables live under /api/v1/env. Reads require the read scope and redact secret values; mutations require admin.

Create

# Global secret  injected into every signal and beacon.curl -X POST http://localhost:4400/api/v1/env \  -H "Authorization: Bearer $STATION_KEY" \  -H "Content-Type: application/json" \  -d '{ "key": "STRIPE_API_KEY", "value": "sk_live_...", "secret": true }' # Scoped to a single signal  overrides a global of the same key there.curl -X POST http://localhost:4400/api/v1/env \  -H "Authorization: Bearer $STATION_KEY" \  -H "Content-Type: application/json" \  -d '{    "key": "DB_URL",    "value": "postgres://...",    "targets": [{ "kind": "signal", "name": "charge" }]  }'

List, edit, delete

EndpointDescription
GET /api/v1/envList variables. Secret values come back as null.
GET /api/v1/env/:idSingle variable by ID (secret redacted).
POST /api/v1/envCreate. Body: key, value, secret?, targets?. Rejects invalid or reserved keys and conflicting scopes with a 400.
PATCH /api/v1/env/:idPartial update: value, secret, targets. The key is immutable. Omitted fields are left unchanged.
DELETE /api/v1/env/:idRemove a variable. Runs already dispatched are unaffected.

Dashboard

The Environment page lists every variable, lets you add one (choosing Global or specific targets and toggling Secret), edit a value in place, and delete. It also flags any variable a signal or beacon requires via .env() that isn't defined in the store — so you can see a missing configuration before a run fails. A value set in the Station host environment also satisfies a requirement; the dashboard can only see the store, so it says so.

Changes take effect on the next run — there is no need to restart the Station process.