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 wantenvoke 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: devpasswordenvoke 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/envokecargo install envoke-clicargo binstall envoke-climise use -g cargo:envoke-clicargo install --git https://github.com/glennib/envoke envoke-cliPre-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-15T10: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.yamlAlternatively, 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 devOverlay 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: localhostRun 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:%SZA 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-valueenvoke 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: warnTags 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 hostOverrides 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.fishThis 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