composable.env
v0.6.0
Published
Composable environment management: build .env files for every service from reusable components, profiles, and contracts.
Downloads
1,384
Maintainers
Readme
composable.env
Build
.envfiles for every service from reusable components, profiles, and contracts.
Like CSS for environment variables. Define once, compose everywhere, validate against contracts. Encrypt secrets inline — no external service needed.
npm install composable.envTable of contents
- Install
- Quick start
- Core concepts
- Vault — encrypted secrets
- CLI reference
- Monorepo / Turborepo setup
- Programmatic API
- Directory structure
- How it works
Install
# Global (recommended for standalone use)
npm install -g composable.env
# Local dev dependency (recommended for monorepos)
npm install -D composable.envRequires Node.js 18+. The CLI command is ce (alias: cenv).
Vault (optional)
If you want to encrypt secrets in .env.shared, install the vault dependencies:
npm install age-encryption sops-age @noble/curves @scure/baseThe vault is completely optional — composable.env works fully without it. See Vault — encrypted secrets for details.
Quick start
1. Scaffold the project
ce init --examplesThis creates the env/ directory structure with example components, profiles, contracts, and shared values.
2. See what was created
env/
components/
database.env # Database variables by environment
redis.env # Redis variables by environment
profiles/
default.json # Lists all components (required)
production.json # Production overrides
staging.json # Staging — extends production
contracts/
api.contract.json # What the API service needs
.env.shared # Team values (committed)
.env.local # Personal overrides (gitignored)3. Build environment files
# Build for local development (default profile)
ce build
# Build for production
ce build --profile production
# Build for staging
ce build --profile stagingEach contract generates a .ce.{profile} file at the service's location:
apps/api/.ce.production
apps/web/.ce.production
apps/worker/.ce.production4. Run a command with the environment loaded
ce run -- npm start
# With a specific profile
ce run --profile staging -- npm start
# Profile as trailing arg (auto-detected)
ce run -- npm start productionIf the .ce.* file doesn't exist yet, ce run auto-builds it.
Core concepts
Components
INI files with named sections. Each section maps to a profile name.
# env/components/database.env
NAMESPACE=DATABASE
[default]
HOST=localhost
PORT=5432
NAME=myapp_dev
USER=postgres
PASSWORD=postgres
[production]
HOST=${DATABASE_PROD_HOST}
PORT=5432
NAME=myapp
USER=${DATABASE_PROD_USER}
PASSWORD=${DATABASE_PROD_PASSWORD}
[staging]
HOST=${DATABASE_STAGING_HOST}
NAME=myapp_stagingNAMESPACE=DATABASEprefixes all variables:HOSTbecomesDATABASE_HOST[default]section is required — provides local development values[production]and[staging]sections use${VAR}references resolved from.env.sharedor.env.local- Sections layer on top of
[default]: production gets[default]+[production]
Profiles
JSON files that name an environment. default.json is required and lists all component names.
// env/profiles/default.json
{
"name": "Default",
"description": "Local development",
"components": ["database", "redis", "auth"]
}Profiles can extend other profiles:
// env/profiles/staging.json
{
"name": "Staging",
"extends": "production",
"components": {
"database": "staging"
}
}Staging inherits everything from production but overrides database to use the [staging] section.
Contracts
JSON files that declare what a service needs from the variable pool. The build fails atomically if any required variable is missing.
// env/contracts/api.contract.json
{
"name": "api",
"location": "apps/api",
"required": {
"DATABASE_URL": "postgresql://${DATABASE_USER}:${DATABASE_PASSWORD}@${DATABASE_HOST}:${DATABASE_PORT}/${DATABASE_NAME}",
"REDIS_URL": "REDIS_URL",
"JWT_SECRET": "AUTH_JWT_SECRET"
},
"secret": {
"STORAGE_ACCESS_KEY": "STORAGE_S3_ACCESS_KEY"
},
"optional": {
"LOG_LEVEL": "LOG_LEVEL",
"CORS_ORIGIN": "CORS_ORIGIN"
},
"defaults": {
"LOG_LEVEL": "info",
"CORS_ORIGIN": "http://localhost:3000"
}
}| Field | Purpose |
|-------|---------|
| name | Service identifier |
| location | Where to write the .ce.{profile} file |
| required | Variables the service must have — build fails if missing |
| secret | Same as required, but flagged as sensitive |
| optional | Variables used if available, skipped if not |
| defaults | Fallback values for optional variables |
The left side is the app variable name (what the service sees). The right side is either a pool variable name or a template with ${VAR} substitutions.
Contracts also support TypeScript (.contract.ts) when a transpiler like tsx or jiti is available.
Shared & local values
| File | Purpose | Committed? |
|------|---------|-----------|
| env/.env.shared | Team-wide values | Yes |
| env/.env.local | Personal overrides | Never (gitignored) |
.env.local always takes precedence over .env.shared. Both are applied after components.
# env/.env.shared — committed, shared across the team
DATABASE_PROD_HOST=db.example.com
DATABASE_PROD_USER=app_prod
REDIS_PROD_HOST=redis.example.com
# env/.env.local — gitignored, personal overrides
DATABASE_PORT=5433
LOG_LEVEL=debugVault — encrypted secrets
Optional feature. Requires additional dependencies:
npm install age-encryption sops-age @noble/curves @scure/base
The vault encrypts secret values directly in .env.shared so the file can be committed safely. Keys stay plaintext, values get encrypted inline. No external service needed.
Encryption uses age — a modern, audited encryption tool. Team access is managed via public keys, and your existing SSH key works automatically.
Set up the vault
# With CODEOWNERS protection (recommended)
ce vault init --github your-username
# Without CODEOWNERS
ce vault initThis creates env/.recipients and adds your public key. If you have ~/.ssh/id_ed25519, it's used automatically. Otherwise, a new age keypair is generated at ~/.config/composable.env/identity.
The --github flag also creates .github/CODEOWNERS to protect env/.recipients — preventing unauthorized changes to the recipient list via GitHub branch protection. If omitted, the GitHub CLI (gh) is checked for your username automatically.
Store a secret
ce vault set DATABASE_PROD_PASSWORD "s3cret-p@ssw0rd"The value is encrypted and written to .env.shared:
DATABASE_PROD_PASSWORD=CENV_ENC[LS0tLS1CRUdJTi...]Now you can safely commit .env.shared.
Read a secret
ce vault get DATABASE_PROD_PASSWORD
# → s3cret-p@ssw0rdList encrypted keys
ce vault lsAdd a team member
# From GitHub (fetches their SSH public keys)
ce vault add --github alice
# From a raw age public key
ce vault add --key "age1abc123..." --comment "Bob"Adding a recipient automatically re-encrypts all existing secrets so the new team member can decrypt them.
Remove a team member
ce vault remove aliceRemoves the recipient and re-encrypts all secrets without their key.
List recipients
ce vault recipientsHow it works during build
When ce build encounters CENV_ENC[...] values in .env.shared, it decrypts them transparently before building. No extra flags needed — if you can decrypt (you have a matching key), it just works.
Identity resolution order
CE_AGE_KEYenvironment variable (raw age secret key — for CI)~/.config/composable.env/identity(age identity file)~/.ssh/id_ed25519(auto-converted to age format)~/.ssh/id_rsa(auto-converted to age format)
Legacy:
CENV_AGE_KEYis also accepted as a fallback.
CI / deployment
Set CE_AGE_KEY as a secret in your CI environment:
# GitHub Actions example
env:
CE_AGE_KEY: ${{ secrets.CE_AGE_KEY }}To get the age secret key for CI, generate a dedicated key:
npx age-encryption -g # prints identity + recipient
# Add the recipient: ce vault add --key "age1..."
# Set AGE-SECRET-KEY-1... as CE_AGE_KEY in CICLI reference
| Command | Description |
|---------|-------------|
| ce init [--examples] | Scaffold the env/ directory structure |
| ce build [--profile name] | Build .ce.* files from a profile |
| ce list | List available profiles |
| ce run [--profile name] -- <cmd> | Load env and run a command (auto-builds) |
| ce script <name> -c <cmd> | Inject a single profile-aware script |
| ce scripts -c <cmd> [--actions dev,build,start] | Generate per-app scripts from contracts |
| ce scripts:sync | Regenerate scripts from ce.json |
| ce vault init [--github <user>] | Initialize the vault (optionally set up CODEOWNERS) |
| ce vault set <KEY> <VALUE> | Encrypt and store a secret |
| ce vault get <KEY> | Decrypt and print a secret |
| ce vault ls | List encrypted keys |
| ce vault add --github <user> | Add recipient from GitHub SSH keys |
| ce vault add --key <key> | Add recipient from raw public key |
| ce vault remove <identifier> | Remove a recipient |
| ce vault recipients | List all recipients |
| ce start [profile] | Build env + launch Zellij dev dashboard |
| ce start --layout <name> | Use a custom layout template |
| ce start --dry-run | Generate layout without launching |
| ce uninstall [--all] [--dry-run] | Remove all composable.env artifacts |
Profile resolution
The --profile flag accepts a profile name. You can also:
- Pass the profile as the last positional argument:
ce run -- npm start production - Set the
CE_PROFILEenvironment variable (legacy:CENV_PROFILE) - Default is
"default"if nothing is specified
Execution — Zellij dev dashboard
Optional feature. Requires Zellij installed on your machine.
ce start launches a Zellij terminal dashboard where every service runs in its own pane — one command to get a full dev environment running.
Add dev commands to contracts
// env/contracts/api.contract.json
{
"name": "api",
"location": "apps/api",
"required": { ... },
"dev": {
"command": "pnpm dev",
"label": "API Server"
}
}| Field | Purpose |
|-------|---------|
| command | Shell command to run in the pane |
| cwd | Working directory (defaults to location) |
| label | Pane display name (defaults to uppercase name) |
Contracts without a dev field are skipped — they get env files but no pane.
Launch
ce start # default profile
ce start production # named profile
ce start --dry-run # generate layout, don't launchThis builds the env files, generates a Zellij KDL layout from your contracts, and opens a session with a pane per service.
Custom layouts
Drop a .kdl.template in env/execution/ for full control:
// env/execution/default.kdl.template
layout {
tab name="Services" {
pane name="API" {
command "bash"
args "-c" "cd ${PROJECT_ROOT}/apps/api && pnpm dev"
}
pane name="Web" {
command "bash"
args "-c" "cd ${PROJECT_ROOT}/apps/web && pnpm dev"
}
}
}Templates use the same ${VAR} substitution as the rest of composable.env. ${PROJECT_ROOT} and ${PROFILE} are always available.
Generated .kdl files are gitignored. Template files (.kdl.template) are committed.
Monorepo / Turborepo setup
Install
# At the monorepo root
npm install -D composable.envInitialize
ce initThis auto-detects turbo.json and adds env/** and .ce.* to globalDependencies.
Generate per-app scripts
ce scripts -c turbo --actions dev,build,startThis reads your contracts and generates scripts in package.json:
{
"scripts": {
"env:build:api": "ce build --profile",
"dev:api": "ce run --profile -- turbo dev --filter=api",
"build:api": "ce run --profile -- turbo build --filter=api",
"start:api": "ce run --profile -- turbo start --filter=api",
"dev": "ce run --profile default -- turbo dev",
"build": "ce run --profile default -- turbo build",
"start": "ce run --profile default -- turbo start"
}
}Usage:
# Build env for production, then start the api
pnpm env:build:api production
pnpm start:api production
# Or just dev everything locally
pnpm devThe script config is saved to ce.json. Regenerate anytime with ce scripts:sync.
Service .env loading
Each app reads its .ce.{profile} file. In Next.js:
// next.config.js
const { config } = require('dotenv');
config({ path: `.ce.${process.env.CURRENT_ENV || 'default'}` });In Express / Node:
require('dotenv').config({
path: `.ce.${process.env.CURRENT_ENV || 'default'}`
});Programmatic API
import { EnvironmentBuilder } from 'composable.env';
// Build from a profile
const builder = new EnvironmentBuilder(
process.cwd(), // configDir (where env/ lives)
'.env', // outputPath
'production' // envName (for .ce.{profile} files)
);
const result = await builder.buildFromProfile('production');
if (!result.success) {
console.error(result.errors);
process.exit(1);
}
// Vault API (requires vault dependencies installed)
const { Vault } = await import('composable.env/vault');
const vault = new Vault(process.cwd());
await vault.init();
await vault.setSecret('API_KEY', 'sk-123');
const value = await vault.getSecret('API_KEY');
await vault.addGitHubRecipient('alice');Directory structure
your-project/
env/
components/ # Reusable variable definitions (INI with sections)
database.env
redis.env
auth.env
profiles/ # Named environment compositions (JSON)
default.json # Required — lists all component names
production.json
staging.json
contracts/ # Per-service variable requirements (JSON or TS)
api.contract.json
web.contract.json
worker.contract.json
execution/ # Zellij layout templates (optional)
default.kdl.template
.env.shared # Team values — committed (secrets encrypted via vault)
.env.local # Personal overrides — gitignored
.recipients # Vault recipient public keys — committed
ce.json # Script config (if using ce scripts)
.ce-managed.json # Tracks what ce manages in package.jsonHow it works
- Load
default.jsonto get all component names - Resolve profile inheritance chain (e.g.,
stagingextendsproduction) - Compose each component's sections in order:
[default]+[production]+[staging] - Layer
.env.shared(team values) then decryptCENV_ENC[...]values if present - Layer
.env.local(personal overrides — always wins) - Resolve
${VAR}substitutions with multi-pass chaining - Validate against all contracts — fail atomically if any required variable is missing
- Write one
.ce.{profile}file per contract at the service'slocation
Uninstall
# See what would be removed
ce uninstall --dry-run
# Remove all generated files and managed keys
ce uninstall
# Also remove the env/ directory
ce uninstall --allLicense
MIT
