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) => { /* ... */ });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:
| Definition | Applies to |
|---|---|
DB_URL (no targets) | Every signal and beacon. |
DB_URL scoped to signal reports | Only 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.
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-adapter-sqlite/envstation-adapter-postgres/envstation-adapter-mysql/envstation-adapter-redis/env
// 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
| Endpoint | Description |
|---|---|
GET /api/v1/env | List variables. Secret values come back as null. |
GET /api/v1/env/:id | Single variable by ID (secret redacted). |
POST /api/v1/env | Create. Body: key, value, secret?, targets?. Rejects invalid or reserved keys and conflicting scopes with a 400. |
PATCH /api/v1/env/:id | Partial update: value, secret, targets. The key is immutable. Omitted fields are left unchanged. |
DELETE /api/v1/env/:id | Remove 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.