Use when working with environment variables in Stacks - the typed env proxy with auto-coercion, .env file loading, X25519 and AES-256-GCM encryption/decryption of env values, runtime/platform detection, CI provider detection, or the env CLI commands. Covers @stacksjs/env, config/env.ts, and .env files.
Install
npx skillscat add stacksjs/stacks/stacks-env Install via the SkillsCat registry.
Stacks Environment
Key Paths
- Core package:
storage/framework/core/env/src/ - Environment config:
config/env.ts - Environment file:
.env - Example:
.env.example - Type definitions: core/env/src/types.ts and storage/framework/types/env.d.ts
Source Files
env/src/
├── index.ts # main exports
├── runtime.ts # typed/coerced environment proxy
├── utils.ts # runtime/platform/environment detection
├── plugin.ts # Bun plugin for auto .env loading
├── parser.ts # .env file parser with encryption support
├── cli.ts # CLI commands (get, set, encrypt, decrypt, rotate)
├── crypto.ts # X25519 + HKDF-SHA-256 + AES-256-GCM encryption
└── types.ts # StacksEnv interface (100+ typed vars)Typed Environment Proxy
import { env } from '@stacksjs/env'
env.APP_NAME // string
env.APP_ENV // configured environment label, including long and short forms
env.APP_KEY // string
env.APP_URL // string
env.DEBUG // boolean (auto-coerced)
env.PORT // number (auto-coerced)
env.DB_CONNECTION // declared connection name; availability comes from capabilityRegistry
env.DB_HOST // string
env.DB_PORT // number
env.DB_DATABASE // string
env.DB_USERNAME // string
env.DB_PASSWORD // string
env.AWS_ACCESS_KEY_ID // string
env.STRIPE_SECRET_KEY // string
env.MAIL_MAILER // string
env.QUEUE_DRIVER // string
env.REDIS_HOST // stringAll values may be undefined. The env proxy coerces true/false case-insensitively.
Numeric variables declared by the framework or schema.number are numbers;
recognized numeric-setting suffixes are also coerced. IDs/codes and values with
leading zeros remain strings unless declared numeric. Arbitrary numeric-looking
text is not always a number.
StacksEnv Type (100+ typed variables)
The typed interface covers framework variables without a catch-all index
signature. Declare an application variable in config/env.ts rather than reading
undeclared names through a dynamic env key.
Adding your own variables
Declare them in config/env.ts. That is the whole step:
// config/env.ts
import { defineEnv } from '@stacksjs/env'
import { schema } from '@stacksjs/validation/runtime'
export default defineEnv({
STRIPE_WEBHOOK_SECRET: { validation: schema.string(), default: '' },
BILLING_RETRIES: { validation: schema.number(), default: 3 },
DEPLOY_TARGET: { validation: schema.enum(['staging', 'production']), default: 'staging' },
})env.BILLING_RETRIES is a number and env.DEPLOY_TARGET is'staging' | 'production' everywhere, from the validator - nothing is
generated, so a variable that exists only in your deploy secrets is typed
exactly like one in your local .env.
storage/framework/types/env.d.ts reads the schema and extends StacksEnv.
Do NOT write a declare module '@stacksjs/env' block of your own: the
framework already has one, and a second augmentation listing your keys again
typechecks while letting a key be typed without being validated or defaulted -
which is the failure config/env.ts exists to prevent.
Runtime Detection
import { isBun, isNode, runtime, runtimeInfo, platform, isWindows, isMacOS, isLinux, hasTTY, hasWindow, isCI, isDebug, isMinimal, isColorSupported, provider, providerInfo } from '@stacksjs/env'
isBun // true
isNode // Node-compatible process detection; may also be true under Bun
runtime // 'bun'
platform // 'darwin'
isWindows // false
isMacOS // true
isLinux // false
hasTTY // true (terminal)
isCI // false
isDebug // boolean
isColorSupported // trueCI Provider Detection
provider // 'github-actions' | 'gitlab-ci' | 'circleci' | 'travis' | 'jenkins' | 'vercel' | 'netlify' | 'heroku' | 'aws' | 'azure' | 'cloudflare' | 'railway' | 'render' | ...
providerInfo // { name: string, ... }Remote integration environments
Gate anything that transmits off the box - error reporting, analytics, log
shipping - through @stacksjs/env, rather than inventing a policy per adapter.
Without a shared gate, every developer machine and CI run reports into the same
project as production, and a local startup failure reads as a live incident
(stacksjs/stacks#2792).
import { initializeIntegration } from '@stacksjs/env'
// The callback does not run when the gate is closed, so constructing a client,
// registering hooks or starting a flush timer inside it is safe.
const result = initializeIntegration(config.bughq, ({ environment }) =>
installBugHq({ key: config.bughq.key, environment }))
if (!result.initialized)
log.debug(`BugHQ not installed: ${result.gate.reason}`)An application declares only environments. Do not add a siblingenvironment option and do not ask the app to repeat env.APP_ENV: the gate
resolves the effective label once and hands it to the adapter for event
metadata.
Extend EnvironmentGatedIntegration from the adapter's own options type so
the application gets enabled and environments and nothing else. Analytics
is the adapter shipping this today:
// config/analytics.ts
export default {
driver: 'analyticshq',
environments: ['production', 'staging'], // omit to get exactly this
drivers: { analyticshq: { siteId: '...' } },
} satisfies AnalyticsConfig| Configuration | Result |
|---|---|
environments omitted |
REMOTE_TELEMETRY_ENVIRONMENTS, which is production and staging |
environments: [] |
off everywhere, the explicit way to disable without removing credentials |
enabled: false |
off, always; checked before the allowlist so it is the last word |
enabled: true |
does not bypass the allowlist |
APP_ENV unset or malformed |
off, with reason: 'environment-unknown' |
The gate reads APP_ENV only, and reads it at call time. It does not fall back
to NODE_ENV the way appEnv() does, because an absent APP_ENV must not be
guessed into a label an allowlist might admit. Call time rather than import time
matters because a test harness or a CLI resolving --env routinely sets it
after the module graph has loaded (stacksjs/stacks#2581).
prod, stage and dev fold onto production, staging and development.local, development and test are deliberately not folded together:
allowing your own machine must not also allow CI.
The answer is the same in a web or API server, the dashboard, a worker and a
Buddy command, because all of them read the same variable. Browser integrations
receive only the resolved public label, never server credentials. With the
AnalyticsHQ driver that label is the tag's data-environment attribute, which
the tracker attaches to every pageview, custom event and Web Vitals beacon, sostaging traffic is distinguishable without a second site id.
APP_ENV is a configuration label, not proof of deployment identity: a local
command launched with APP_ENV=production qualifies. It prevents accidents; it
is not a security boundary.
.env File Loading
import { loadEnv, autoLoadEnv, envPlugin } from '@stacksjs/env'
const loaded = loadEnv({ path: '.env', overload: false }) // { loaded, errors }
autoLoadEnv() // .env, .env.local, .env.<effectiveEnv>, .env.<effectiveEnv>.local
// Bun plugin (auto-loads .env before app starts)
envPlugin().env Parser
import { parse } from '@stacksjs/env'
const { parsed, errors, skippedEncrypted } = parse(envContent, {
privateKey: '...' // decryption key
})Encryption (X25519 + AES-256-GCM)
import { aesEncrypt, aesDecrypt, generateKeypair, encryptValue, decryptValue, getPrivateKey } from '@stacksjs/env'
// AES-256-GCM symmetric encryption
const encrypted = aesEncrypt(plaintext, password)
const decrypted = aesDecrypt(encrypted, password)
// Versioned ephemeral-static X25519 envelope encryption
const keypair = generateKeypair()
const encrypted = encryptValue(value, keypair.publicKey)
const decrypted = decryptValue(encrypted, keypair.privateKey)CLI Commands
buddy env:get APP_NAME # get single var
buddy env:get --all --format json # all vars as JSON
buddy env:set APP_NAME "My App" # set var
buddy env:encrypt # encrypt .env file
buddy env:decrypt # decrypt .env file
buddy env:keypair # generate encryption keypair
buddy env:rotate # rotate encryption keys
buddy env:check # validate env configuration
buddy env:check --file .env.production # check a specific environment's filegetEnv() fails a whole-file read if any encrypted entry cannot be decrypted.
Restore the environment's matching private key through .env.keys or itsDOTENV_PRIVATE_KEY_<ENVIRONMENT> process secret. A request for one plaintext
key remains readable independently. buddy deploy stops on an incomplete or
unreadable environment instead of deploying a partial map.
Tenant isolation on a shared box
When several projects share one server - one owns it, the rest attach withcloud.attachTo - each still deploys from its own repository with its own.env.<environment>. No project needs another's values.
They leak anyway: a tenant's secrets get pasted into the owner's env file under
a TENANT_ prefix while debugging a deploy, and stay. That is not just untidy.buddy deploy ships the whole env file as every site's .env (ts-cloud
treats site.env as the complete file), so a stray BUGHQ_STRIPE_SECRET_KEY in
the owner's file lands on disk in an unrelated site.
Declare who is attached, in config/cloud.ts's default export:
const config: CloudConfig = {
tenants: ['bughq', 'analyticshq'],
}With that:
buddy deploydrops those keys before shipping, and logs what it droppedbuddy env:checklists them per tenant so they can be deleted at source
Prefixes are never inferred. With no tenants declared nothing is treated
as foreign, because STRIPE_, AWS_ and MEILISEARCH_ are indistinguishable
from a slug prefix by shape alone. Slug punctuation is ignored, soanalytics-hq and analytics_hq both match ANALYTICSHQ_.
The API is partitionTenantEnv(values, { self, tenants }) from@stacksjs/env, plus stripForeignTenantEnv and foreignTenantKeys.
Dashboard environment editor
The dashboard reads and writes .env through guarded/api/dashboard/environment GET and PUT endpoints. The write contract:
- validates uppercase environment keys and duplicate definitions
- limits the file to 1 MB and rejects null bytes
- requires the SHA-256 revision returned by the latest read
- writes through a 0600 temporary file and atomic rename
- stores the previous content under
storage/framework/runtime/dashboard/environment.backup - returns
Cache-Control: no-storebecause the response contains secrets
Structured settings pages should use updateEnvironmentEntries() from the
dashboard environment-file service. It updates only named keys while
preserving comments, ordering, unrelated values, and the same revision and
backup guarantees. Do not implement settings with repeated writeFileSync
calls or raw client-side fetch.
Gotchas
- A tenant's keys in your env file get shipped everywhere.
buddy deploy
sends the entire env file as each site's.env. Declaretenantsinconfig/cloud.tsso they are stripped, then delete them at source - Bun natively loads
.env- no dotenv package needed - The
envproxy auto-coerces strings to booleans/numbers .envshould never be committed - use.env.exampleas template- New encrypted values use ephemeral-static X25519, HKDF-SHA-256, and
AES-256-GCM. Legacy ciphertext remains readable for migration - autoLoadEnv loads least to most specific, including .env..local; effective env resolution is in plugin.ts and existing shell values are preserved unless overloaded
- Runtime detection uses Bun globals and process properties
- CI provider detection checks environment variables specific to each CI system
- The
StacksEnvtype provides autocomplete for 100+ known variables - Every variable is
| undefined- the process may simply not have it set Bun.env.Xis NOT typed, and holds raw strings.Bun.env.DEBUGis'false', which is truthy; useenv.DEBUG, which coerces. A generator used to declareBun.envfrom whichever.envwas on the machine that ran it, typingDEBUGasboolean- it was removed, and nothing read it