envoke
Resolve variable values from a declarative YAML configuration.
envoke reads an envoke.yaml file, resolves variables from multiple sources
(literals, commands, shell scripts, and minijinja
templates) in dependency order, and renders the results as shell-safe
VAR='value' lines — ready to source as environment variables, write to .env
files, or feed into custom output templates.
Installation
From source
From GitHub releases
Pre-built binaries are available on the releases page for:
- Linux (x86_64, aarch64)
- macOS (x86_64, Apple Silicon)
- Windows (x86_64)
Quick start
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:
# @generated by `envoke 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: All output includes an
@generatedheader with the invocation command and timestamp. Examples below omit this header for brevity.
Source them into your shell:
Or write them to a file:
Configuration
The config file (default: envoke.yaml) has a single top-level key variables
that maps variable names to their definitions.
Variable definition
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.
Source types
Each source specifies exactly one of the following fields:
literal
A fixed string value.
DB_HOST:
default:
literal: localhost
cmd
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:
sh
Run a shell script via sh -c and capture its stdout (trimmed).
TIMESTAMP:
default:
sh: date -u +%Y-%m-%dT%H:%M:%SZ
template
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 }}"
The urlencode filter is available for escaping special characters:
CONN_STRING:
default:
template: "postgresql://{{ USER | urlencode }}:{{ PASS | urlencode }}@localhost/db"
skip
Omit this variable from the output. Useful for conditionally excluding a variable in certain environments while including it in others.
DEBUG_TOKEN:
default:
skip: true
envs:
local:
literal: debug-token-value
Environments and defaults
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
Variables can be tagged for conditional inclusion. Tagged variables are only
included when at least one of their tags is passed via --tag. Untagged
variables are always included. This is useful for gating expensive-to-resolve
variables (e.g. vault lookups) or optional components behind explicit opt-in.
variables:
DB_HOST:
default:
literal: localhost
VAULT_SECRET:
tags:
envs:
prod:
sh: vault kv get -field=secret secret/app
local:
literal: dev-secret
OAUTH_CLIENT_ID:
tags:
envs:
prod:
sh: vault kv get -field=client_id secret/oauth
local:
literal: local-client-id
# Without --tag, only untagged variables are included:
DB_HOST='localhost'
# Include vault-tagged variables (and all untagged ones):
DB_HOST='localhost'
VAULT_SECRET='dev-secret'
# Include everything:
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
Overrides add a third dimension for varying values 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:
CACHE_STRATEGY='lru'
DATABASE_HOST='172.10.0.1'
DATABASE_PORT='5432'
# Activate an override:
CACHE_STRATEGY='lru'
DATABASE_HOST='172.10.0.2'
DATABASE_PORT='5432'
# Multiple overrides on disjoint variables:
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.
CLI usage
envoke [OPTIONS] [ENVIRONMENT]
| Option | Description |
|---|---|
ENVIRONMENT |
Target environment name (e.g. local, prod). Required unless --schema is used. |
-c, --config <PATH> |
Path to config file. Default: envoke.yaml. |
-o, --output <PATH> |
Write output to a file instead of stdout. |
-t, --tag <TAG> |
Only include tagged variables with a matching tag. Repeatable. Untagged variables are always included. |
-O, --override <NAME> |
Activate a named override for source selection. Repeatable. Per variable, at most one active override may be defined. |
--prepend-export |
Switches to a built-in template that prefixes each variable with export . Ignored when --template is used. |
--template <PATH> |
Use a custom output template file instead of the built-in format. |
--schema |
Print the JSON Schema for envoke.yaml and exit. |
JSON Schema
Generate a JSON Schema for editor autocompletion and validation:
Use 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:
# ...
How it works
- 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 with shell-safe escaping.
Circular dependencies and references to undefined variables are detected before any resolution begins and reported as errors.
Custom templates
By default, envoke outputs shell VAR='value' lines with an @generated
header. You can supply your own minijinja
(Jinja2-compatible) template via --template:
Template context
The 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. |
Filters
shell_escape-- escapes single quotes for shell safety ('->'\'').
Example: JSON output
{
{% for name, var in variables | items %} "{{ name }}": "{{ var.value }}"{% if not loop.last %},{% endif %}
{% endfor %}}
Example: Docker .env format
# Generated for {{ meta.environment }}
{% for name, var in variables | items -%}
{{ name }}={{ v[name] }}
{% endfor -%}
Development
This project uses mise as a task runner. After installing mise:
Run a single test:
License
MIT OR Apache-2.0