This document describes the environment-variable architecture used by the Ensemble CLI for multiple app environments (or “targets”).
The chosen approach is:
- Config base file:
.env.config(shared defaults) - Config scoped override file:
.env.config.<alias>(per app/environment alias) - Secrets base file:
.env.secrets(shared defaults) - Secrets scoped override file:
.env.secrets.<alias>(per app/environment alias)
Where <alias> matches the --app <alias> value (an app key in ensemble.config.json). The README already describes --app <alias> as the “App alias / environment key”.
- Prevent accidental cross-environment breakage (e.g. pushing dev values to prod).
- Support per-environment differences (e.g.
api_urldiffers between dev and prod). - Keep local configuration readable and easy to reason about.
- Play nicely with existing CLI behavior that already maintains
.env.configfor assets. - Keep secrets out of source control by default with a predictable local structure.
- Replacing runtime secrets management (e.g. Vault/KMS). This design is about how the CLI stores and syncs config, not the ultimate secret storage strategy.
- Introducing a complex file format. Files remain simple
KEY=valuepairs.
- Alias: The value passed to
--app <alias>(e.g.default,dev,prod), corresponding to an app entry inensemble.config.json. - Base config:
.env.config - Alias config:
.env.config.<alias>(example:.env.config.prod) - Effective config: The merged view used by commands (base + alias overrides).
- Base secrets:
.env.secrets - Alias secrets:
.env.secrets.<alias>(example:.env.secrets.prod) - Effective secrets: The merged view used by commands (base + alias overrides).
Both files use the same syntax:
- One entry per line:
KEY=value - Empty lines allowed
- Lines starting with
#are comments - The first
=separates key from value - Whitespace around keys is trimmed
Example:
# shared defaults
api_timeout_ms=30000
api_url=https://dev.ensemble.comAnd an alias override:
# prod overrides
api_url=https://prod.ensemble.comFor the active alias (--app or ensemble.config.json → default):
| Situation | Files used |
|---|---|
| Alias is default and only base files exist | .env.config + .env.secrets |
| Alias is not default | .env.config.<alias> + .env.secrets.<alias> (created on pull if missing) |
| Alias has both scoped files (any alias) | scoped pair wins over base |
No mixing across tiers. Config and secrets always come from the same tier.
Pulling a non-default alias (e.g. ensemble pull --app uat) writes cloud env into .env.config.uat / .env.secrets.uat and leaves base files untouched.
Commands use the resolved pair for the selected --app alias (see resolution rules above).
--app is optional and defaults to ensemble.config.json → default.
| Local state | Push behavior |
|---|---|
| File missing | Ignored — no env push for that side, no cloud wipe |
| File present, empty | Wipe — warn + [y/N] before deleting all cloud keys on that side |
ensemble push --app <alias>pushes the effective env for that alias.- Config and secrets are pushed independently (missing file → that side skipped).
ensemble pull --app <alias>writes cloud env into the scoped target file when in scoped mode (.env.config.<alias>/.env.secrets.<alias>), leaving the base file untouched.- In legacy mode, pull continues to write
.env.config/.env.secrets.
ensemble release userestores snapshot config into the same write target as pull (scoped or base).
The CLI upserts .env.config for asset-related keys after:
ensemble add assetensemble push(asset upload)
Pull writes asset env keys (assets=, per-asset keys) into the resolved config file for the active alias (base or scoped). ensemble add asset still upserts the base .env.config.
- Never default to destructive deletes except when a local env file exists but is empty (explicit wipe semantics above).
--delete-missingis not implemented; local-only keys are not auto-deleted from cloud on push.
Different teams will choose different policies. Recommended defaults:
- Commit
.env.configonly if it contains non-secret shared defaults. - Do not commit
.env.secretsor.env.secrets.<alias>(treat as sensitive). - Prefer
.env.config.example/.env.secrets.examplefor documentation when needed.
At minimum, consider adding these to .gitignore:
.env.config.*
!.env.config.example
.env.secrets
.env.secrets.*
!.env.secrets.exampleIf you do want to commit alias files for non-secret config, use a more selective ignore pattern or separate “public” vs “secret” configs.
.env.config:
api_timeout_ms=30000
api_url=https://dev.ensemble.com.env.config.prod:
api_url=https://prod.ensemble.comensemble push --app defaultuses dev URLensemble push --app produses prod URL
.env.config:
cdn_region=us-east-1.env.config.dev:
assets=https://assets.dev.ensemble.com/.env.config.prod:
assets=https://assets.prod.ensemble.com/- Single-app projects: no change — keep using
.env.config/.env.secrets. - Multi-app projects: add
.env.config.<alias>/.env.secrets.<alias>for per-target overrides; shared defaults stay in the base files. - Existing single-app repos can opt in early by creating a scoped file (e.g.
.env.config.dev).
-
Should
.env.configbe treated as shared defaults only, or also as the “default alias” file?- This doc treats it as shared defaults that apply to all aliases unless overridden.
-
Should asset keys always be alias-scoped, or can some be global?
- Recommended: alias-scoped, since assets are tied to a specific app target.
-
Do we want separate commands for secrets vs config (recommended), or a unified env push/pull that handles both?
- Recommendation: separate surfaces (e.g.
envvssecrets) to make “high risk” operations explicit.
- Recommendation: separate surfaces (e.g.