Declarative environment variables — any source, any shape, any command. Declare your environments, tags, and overrides in one YAML file. envoke composes values from literals, commands, shell scripts, and templates; resolves template dependencies topologically; and renders the result as shell exports, JSON, a Kubernetes ConfigMap, or anything else a Jinja template can express. Then hand the resolved variables to a command:
envoke exec prod -- psql # exec with resolved vars overlaid envoke render prod --output .env # write a .env file envoke render prod --format json # render as JSON envoke render prod --format k8s-secret # render as a Kubernetes Secret manifest envoke render prod --template custom.j2 # render any shape you want
envoke is a composer and renderer for environment variables, not a secret store. The niche it fills is the intersection of multi-source composition (literals, commands, shell, templates), per-environment / per-tag / per-override variation, and deterministic output to any shape. Pair it with your secret store of choice — fnox, SOPS, 1Password CLI, or Vault — and let envoke handle the composition:
# envoke.yaml DB_PASS: envs: prod: sh: op read "op://prod/db/password" # fetched via 1Password CLI local: literal: devpassword
envoke doesn't hook into the shell like direnv or other .env loaders, but produces structured output and renders flexibly.
It also doesn't encrypt or cache, but is composable with tools that do, via the sh and cmd sources.
With mise (recommended)
mise use -g github:glennib/envoke
cargo install envoke-cli
cargo binstall envoke-cli
mise use -g cargo:envoke-cli
cargo install --git https://github.com/glennib/envoke envoke-cli
Pre-built binaries are available on the releases page for:
- Linux (x86_64, aarch64)
- macOS (x86_64, Apple Silicon)
- Windows (x86_64)
Create an envoke.yaml:
variables: DB_HOST: default: literal: localhost envs: prod: literal: db.example.com DB_USER: default: literal: app DB_PASS: envs: local: literal: devpassword prod: sh: vault kv get -field=password secret/db DB_URL: default: template: "postgresql://{{ DB_USER }}:{{ DB_PASS | urlencode }}@{{ DB_HOST }}/mydb"
Generate variables for an environment:
$ envoke render local # @generated by `envoke render local` at 2025年06月15日T10:30:00+02:00 # Do not edit manually. Modify envoke.yaml instead. DB_HOST='localhost' DB_PASS='devpassword' DB_URL='postgresql://app:devpassword@localhost/mydb' DB_USER='app'
Note: Output is sorted alphabetically by variable name. All output includes an
@generatedheader with the invocation command and timestamp. Examples below omit this header for brevity.
Or hand them straight to a command — no shell dance required:
envoke exec local -- psql envoke exec prod -- kubectl apply -f manifest.yaml
Alternatively, source them into your shell:
eval "$(envoke render local --format shell-export)"
Or write them to a file:
envoke render local --output .envTip:
renderhas aliasrandexechas aliasx, soenvoke r local,envoke x prod -- psql, etc. also work.
The exec subcommand runs a subprocess with the resolved variables
overlaid on envoke's own environment. Everything after -- is passed
verbatim to the child:
envoke exec prod -- psql envoke exec prod -- sh -c 'echo "$DATABASE_URL"' envoke exec local -- npm run dev
Overlay semantics. The child inherits envoke's process environment
(PATH, HOME, TERM, SSH_AUTH_SOCK, ...) and the resolved variables are
layered on top — any inherited variable with the same name as a resolved one is
replaced. Variables not declared in envoke.yaml pass through unchanged.
Process model. On Unix, envoke replaces itself with the target process via
execvp — the child keeps envoke's PID, TTY, and signal disposition, so
Ctrl-C and SIGTERM behave exactly as if you had invoked the command
directly. On other platforms, envoke spawns the child and forwards its exit
code.
Subcommand separation. Output-shaping flags (--output, --template,
--format) live on render; the trailing -- <command> lives on
exec. Pick the subcommand that matches your intent.
If your project uses mise to manage tools, the
envoke-env plugin activates envoke
on shell entry — no manual eval or sourcing needed.
# mise.toml [tools] "github:glennib/envoke" = "2.0.0" [plugins] envoke = "https://github.com/glennib/envoke-env#v2.0.0" [env] _.envoke = { fallback_environment = "local", tools = true }
Put the target environment name in .envoke-env (gitignored):
staging
When mise activates, the plugin runs envoke render staging against your
envoke.yaml and injects the resolved variables. Switch environments with
echo prod > .envoke-env; the plugin watches the file and re-evaluates
on the next activation. Tags and overrides can be added on subsequent lines
(tag:vault, override:read-replica).
See the envoke-env README for caching, the fallback environment, and other configuration options.
The config file (default: envoke.yaml) has a single top-level key variables
that maps variable names to their definitions.
Each variable can have:
| Field | Description |
|---|---|
description |
Optional. Rendered as a # comment above the variable in output. |
tags |
Optional. List of tags for conditional inclusion. Variable is only included when at least one of its tags is passed via --tag. Untagged variables are always included. |
default |
Optional. Fallback source used when the target environment has no entry in envs. |
envs |
Map of environment names to sources. |
overrides |
Optional. Map of override names to alternative source definitions (each with its own default/envs). Activated via --override. |
A variable must have either an envs entry matching the target environment or a
default. If neither exists, resolution fails with an error.
Each source specifies exactly one of the following fields:
A fixed string value.
DB_HOST: default: literal: localhost
Run a command and capture its stdout (trimmed). The value is a list where the first element is the executable and the rest are arguments.
GIT_SHA: default: cmd: [git, rev-parse, --short, HEAD]
Run a shell script via sh -c and capture its stdout (trimmed).
TIMESTAMP: default: sh: date -u +%Y-%m-%dT%H:%M:%SZ
A minijinja template string, compatible
with Jinja2. Reference other variables
with {{ VAR_NAME }}. Dependencies are automatically detected and resolved first
via topological sorting.
DB_URL: default: template: "postgresql://{{ DB_USER }}:{{ DB_PASS }}@{{ DB_HOST }}/{{ DB_NAME }}"
A meta object is available in variable templates with the following fields:
| Field | Description |
|---|---|
meta.environment |
The target environment name passed to envoke. |
API_URL: default: template: "https://{{ meta.environment }}.example.com/api"
$ envoke render staging
API_URL='https://staging.example.com/api'All minijinja built-in filters
are available (upper, lower, replace, trim, default, join, etc.), plus
the following additional filters:
urlencode-- percent-encodes special characters for use in URLs.shell_escape-- escapes single quotes for shell safety ('->'\'').dotenv_escape-- encodes a value as a portable.envtoken with delimiters included (single-quoted when safe, else double-quoted with conservative escapes).
CONN_STRING: default: template: "postgresql://{{ USER | urlencode }}:{{ PASS | urlencode }}@localhost/db" APP_NAME_LOWER: default: template: "{{ APP_NAME | lower }}"
Omit this variable from the output. Useful for conditionally excluding a variable in certain environments while including it in others.
DEBUG_TOKEN: default: skip envs: local: literal: debug-token-value
envoke selects the source for each variable by checking the envs map for the
target environment. If no match is found, it falls back to default. This lets
you define shared defaults and override them per environment:
LOG_LEVEL: default: literal: info envs: local: literal: debug prod: literal: warn
Tags gate variables behind explicit opt-in. The typical use case: your config
includes a VAULT_SECRET whose value is fetched by an expensive sh: vault kv get ... command. You don't want that command to run every time someone runs
envoke local during day-to-day development — only when they actually need the
secret. Tag it with vault, and the variable is included only when --tag vault is passed.
Untagged variables are always included. Tagged variables are only included
when at least one of their tags is passed via --tag. This keeps expensive
resolvers (vault lookups, cloud API calls, slow shell scripts) out of the hot
path.
variables: DB_HOST: default: literal: localhost VAULT_SECRET: tags: [vault] envs: prod: sh: vault kv get -field=secret secret/app local: literal: dev-secret OAUTH_CLIENT_ID: tags: [oauth] envs: prod: sh: vault kv get -field=client_id secret/oauth local: literal: local-client-id
# Without --tag, only untagged variables are included: $ envoke render local DB_HOST='localhost' # Include vault-tagged variables (and all untagged ones): $ envoke render local --tag vault DB_HOST='localhost' VAULT_SECRET='dev-secret' # Include everything: $ envoke render local --tag vault --tag oauth DB_HOST='localhost' OAUTH_CLIENT_ID='local-client-id' VAULT_SECRET='dev-secret'
Variables without tags are always included regardless of which --tag flags are
passed. Tagged variables require explicit opt-in.
Overrides let you point a single variable at a different source without
duplicating an entire environment. Classic example: you have a prod
environment, and occasionally you want DATABASE_HOST to point at a
read-replica instead of the primary — but everything else (port, credentials,
cache strategy) should stay identical. Creating a whole prod-read-replica
environment duplicates ten other variables for the sake of one. Instead,
declare a read-replica override on DATABASE_HOST and activate it with
--override read-replica:
envoke exec prod -- psql # primary envoke exec prod --override read-replica -- psql # same env, replica host
Overrides are the third dimension alongside environments and tags. A variable
can declare named overrides, each with its own default/envs sources.
Activate them with --override:
variables: DATABASE_HOST: default: literal: localhost envs: prod: literal: 172.10.0.1 overrides: read-replica: default: literal: localhost-ro envs: prod: literal: 172.10.0.2 CACHE_STRATEGY: envs: prod: literal: lru overrides: aggressive-cache: envs: prod: literal: lfu-with-prefetch DATABASE_PORT: default: literal: "5432" # No overrides -- unaffected by --override flag
# Base values: $ envoke render prod CACHE_STRATEGY='lru' DATABASE_HOST='172.10.0.1' DATABASE_PORT='5432' # Activate an override: $ envoke render prod --override read-replica CACHE_STRATEGY='lru' DATABASE_HOST='172.10.0.2' DATABASE_PORT='5432' # Multiple overrides on disjoint variables: $ envoke render prod --override read-replica --override aggressive-cache CACHE_STRATEGY='lfu-with-prefetch' DATABASE_HOST='172.10.0.2' DATABASE_PORT='5432'
When an override is active for a variable, the source is selected using a 4-level fallback chain:
- Override
envs[environment] - Override
default - Base
envs[environment] - Base
default
Variables without a matching override definition are unaffected and use the normal base fallback. If multiple active overrides are defined on the same variable, envoke reports an error. Unknown override names (not defined on any variable) produce a warning on stderr.
envoke [GLOBAL OPTIONS] <SUBCOMMAND>
| Subcommand | Alias | Purpose |
|---|---|---|
render <ENV> |
r |
Resolve variables and print them (or write to a file). |
exec <ENV> -- <COMMAND>... |
x |
Resolve variables and exec a command with them overlaid. |
meta <WHAT> |
— | Enumerate names of a config dimension: environments, tags, overrides, or all (prefixed). |
schema |
— | Print the JSON Schema for envoke.yaml. |
completions <SHELL> |
— | Print shell completions (bash, zsh, fish, elvish, powershell). |
Usable before or after the subcommand.
| Option | Description |
|---|---|
-c, --config <PATH> |
Path to config file. Default: envoke.yaml. |
-t, --tag <TAG> |
Only include tagged variables with a matching tag. Repeatable. Untagged variables are always included. |
--all-tags |
Include every tagged variable regardless of its tags. Conflicts with --tag. |
-O, --override <NAME> |
Activate a named override for source selection. Repeatable. Per variable, at most one active override may be defined. |
--no-parallel |
Resolve cmd: and sh: sources serially instead of in parallel. |
-q, --quiet |
Suppress informational messages on stderr. |
Global repeatables and the subcommand boundary.
--tagand--overrideare repeatable globals. Specifying them on both sides of the subcommand is a footgun — the occurrences after the subcommand replace (not append to) any occurrences before it. For example,envoke --tag a render prod --tag bresults intags = ["b"], not["a", "b"]. Pick one side.
| Option | Description |
|---|---|
<ENV> |
Target environment name (e.g. local, prod). Can also be set via the ENVOKE_ENV environment variable. |
-o, --output <PATH> |
Write output to a file instead of stdout. |
-f, --format <FORMAT> |
Select a built-in output preset: dotenv (default), shell-export, json, yaml, k8s-secret, github-actions, terraform-tfvars. See Output formats. Conflicts with --template. |
--template <PATH> |
Use a custom output template file instead of a preset. See Custom templates. |
| Option | Description |
|---|---|
<ENV> |
Target environment name. Can also be set via the ENVOKE_ENV environment variable. |
-- <COMMAND>... |
Command to exec with resolved variables overlaid. See Running commands. The -- separator is required. |
| Variable | Description |
|---|---|
ENVOKE_ENV |
Fallback for the <ENV> positional on render and exec. |
Generate a JSON Schema for editor autocompletion and validation:
envoke schema > envoke-schema.jsonUse it in your envoke.yaml with a schema comment for editors that support it:
# yaml-language-server: $schema=envoke-schema.json variables: # ...
Alternatively, point directly at the hosted schema without writing a local file:
# yaml-language-server: $schema=https://raw.githubusercontent.com/glennib/envoke/refs/heads/main/envoke.schema.json variables: # ...
- Parse the YAML config file.
- Filter out variables excluded by
--tagflags (if any). - For each remaining variable, select the source matching the target environment
(or the default), applying the override fallback chain if
--overrideflags are active. - Extract template dependencies and topologically sort all variables using Kahn's algorithm.
- Resolve values in dependency order -- literals are used as-is, commands and shell scripts are executed, templates are rendered with already-resolved values.
- Render output using a built-in or custom Jinja2 template (see
Custom templates). The default template produces an
@generatedheader followed by sortedVAR='value'lines in the.envdotenv format.
Circular dependencies and references to undefined variables are detected before any resolution begins and reported as errors.
--format <FORMAT> selects a curated built-in preset. The defaults cover the
shapes most projects need; for anything else, use --template.
| Format | Output shape | Typical use |
|---|---|---|
dotenv (default) |
KEY='value' when safe, else KEY="value" with conservative escapes (\\, \", \$, \n). $ never expands at the consumer. |
.env files consumed by dotenvy (mise, Rust), godotenv (Docker Compose), python-dotenv, node dotenv |
shell-export |
export KEY='value' |
Source into a POSIX shell for children to inherit: source <(envoke render local --format shell-export) |
json |
Compact JSON object | Feeding structured tools; pipe through jq . for pretty output |
yaml |
YAML mapping (block style) | Human-readable config files, yq pipelines |
k8s-secret |
Kubernetes Secret manifest with stringData: |
envoke render prod --format k8s-secret | kubectl apply -f - |
github-actions |
Heredoc blocks for $GITHUB_ENV |
- run: envoke render prod --format github-actions >> "$GITHUB_ENV" |
terraform-tfvars |
HCL KEY = "value" |
envoke render prod --format terraform-tfvars > prod.auto.tfvars |
Notes.
--format jsonoutput is also valid YAML 1.2, so use it when you want compact structured output.k8s-secretderivesmetadata.namefrom the environment (lowercased,_replaced with-). For exotic env names, post-process or use--template.- Some
.envparsers (e.g.dotenvx) expand$VARinside double-quoted values. A value likepa$wordmay not round-trip through those.
If none of the presets fit, supply your own
minijinja (Jinja2-compatible)
template via --template:
envoke render local --template my-template.j2The template receives the following variables:
| Name | Type | Description |
|---|---|---|
variables |
map of name -> {value, description} |
Rich access: {{ variables.DB_URL.value }}. Iteration: {% for name, var in variables | items %}. Sorted alphabetically. |
v |
map of name -> value string | Flat shorthand: {{ v.DATABASE_URL }}. |
meta.timestamp |
string | RFC 3339 timestamp of invocation. |
meta.invocation |
string | Full CLI invocation as a single string. |
meta.invocation_args |
list of strings | CLI args as individual elements. |
meta.environment |
string | Target environment name. |
meta.config_file |
string | Path to the config file used. |
All minijinja built-in filters
are available (upper, lower, replace, trim, default, join, length,
first, last, sort, unique, tojson, etc.), plus these additional filters:
shell_escape-- escapes single quotes for shell safety ('->'\'').dotenv_escape-- encodes a value as a portable.envtoken with delimiters included (single-quoted when safe, else double-quoted with conservative escapes\\,\",\$,\n;$is never expanded at the consumer).urlencode-- percent-encodes special characters.
All filters are available in both variable templates (the template source type)
and custom output templates.
{
{% for name, var in variables | items %} "{{ name }}": "{{ var.value }}"{% if not loop.last %},{% endif %}
{% endfor %}}envoke render local --template json.j2Note: This simplified example does not escape JSON special characters (
",\, newlines) in values. For production use, consider a template that handles escaping.
# Generated for {{ meta.environment }}
{% for name, var in variables | items -%}
{{ name }}={{ v[name] }}
{% endfor -%}Note: This simplified example does not quote or escape values. Values containing
=,#, or whitespace may not parse correctly in all.envimplementations.
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ meta.environment | lower }}-env
labels:
app: myapp
environment: {{ meta.environment | lower }}
generated-by: envoke
data:
{% for name, var in variables | items %} {{ name }}: "{{ var.value }}"
{% endfor %}This example uses lower and items filters to generate a Kubernetes-compatible
manifest directly from your envoke config.
Generate completions for your shell:
# Bash envoke completions bash > ~/.local/share/bash-completion/completions/envoke # Zsh envoke completions zsh > ~/.zfunc/_envoke # Fish envoke completions fish > ~/.config/fish/completions/envoke.fish
This project uses mise as a task runner. After installing mise:
mise install # Install tool dependencies mise run build # Build release binary mise run test # Run tests (via cargo-nextest) mise run clippy # Run lints mise run fmt # Format code mise run ci # Run all checks (fmt, clippy, test, build)
Run a single test:
cargo nextest run -E 'test(test_name)'envoke uses tracing for diagnostic output. Set the
RUST_LOG environment variable to see debug messages on stderr:
RUST_LOG=debug envoke render localThis is useful for troubleshooting tag filtering, override fallback chains, and source resolution order.
MIT OR Apache-2.0