stacksjs

stacks-env

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.

stacksjs 636 20 Updated 6d ago
GitHub

Install

npx skillscat add stacksjs/stacks/stacks-env

Install via the SkillsCat registry.

SKILL.md

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           // string

All 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 // true

CI 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 sibling
environment 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, so
staging 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 file

getEnv() fails a whole-file read if any encrypted entry cannot be decrypted.
Restore the environment's matching private key through .env.keys or its
DOTENV_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 with
cloud.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 deploy drops those keys before shipping, and logs what it dropped
  • buddy env:check lists 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, so
analytics-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-store because 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. Declare tenants in
    config/cloud.ts so they are stripped, then delete them at source
  • Bun natively loads .env - no dotenv package needed
  • The env proxy auto-coerces strings to booleans/numbers
  • .env should never be committed - use .env.example as 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 StacksEnv type provides autocomplete for 100+ known variables
  • Every variable is | undefined - the process may simply not have it set
  • Bun.env.X is NOT typed, and holds raw strings. Bun.env.DEBUG is 'false', which is truthy; use env.DEBUG, which coerces. A generator used to declare Bun.env from whichever .env was on the machine that ran it, typing DEBUG as boolean - it was removed, and nothing read it