envchain is a tiny helper for Go services that backfills environment variables from one or more providers — without overwriting anything that’s already set. It also provides a small helper, GetEnv, for reading environment variables with defaults.
- Dotenv: read a local
.envfile - HashiCorp Vault (KV v2): fetch secrets over HTTP using a Vault token
Order matters and establishes precedence: existing process env ➜ first provider ➜ second provider ➜ ...
Anything already set in the process wins. Missing keys are filled by the first provider you pass; still-missing keys are filled by the next provider, and so on.
Local dev:
-
Put a
.envfile beside your app. -
Optionally set
VAULT_ADDRand authenticate to backfill missing secrets from Vault:VAULT_ADDR=https://vault.byu.edu vault login -method=oidc -path=byu-sso
-
Install the CLI:
go install github.com/stuft2/envchain/cmd/envchain@latest
-
Create a sample
.env:cat > .env <<'EOF' APP_NAME=envchain-demo PORT=8080 EOF
-
Run a command with backfilled env vars:
envchain -- env | grep -E '^(APP_NAME|PORT)='
-
Verify output:
APP_NAME=envchain-demo PORT=8080
Optional Vault check:
envchain -vault-path "kvv2/<service>/dev/env-vars" -- env | grep '^YOUR_KEY='CLI usage:
envchain [flags] -- <command> [args...]Flags:
-dotenv(default.env): path to a dotenv file to backfill from. Pass an empty string to skip it.-vault-path: KV v2 path to load from HashiCorp Vault. Leave empty to skip Vault.-verbose: emit debug logs detailing how each provider resolves environment variables.
Environment variables such as VAULT_ADDR, VAULT_TOKEN, and VAULT_NAMESPACE still control Vault behaviour.
CI/Prod: rely on process env only. If you don’t set VAULT_ADDR, the Vault provider is inert, and a missing .env is ignored.
Construct the providers you want and pass them to envchain.Inject in precedence order:
package main
import (
"log"
"github.com/stuft2/envchain"
"github.com/stuft2/envchain/providers/dotenv"
"github.com/stuft2/envchain/providers/vault"
)
func main() {
// Precedence: keep existing env, then .env, then Vault.
if err := envchain.Inject(dotenv.NewProvider(".env"), vault.NewProvider("kvv2/byuapi-persons-v4/dev/env-vars")); err != nil {
// could make provider errors fatal, but we're assuming that deployed environments
// will always have config and secrets injected before server start.
log.Printf("env injection warnings: %v", err)
}
// ... retrieve env vars with os.GetEnv or envchain.GetEnv
}You can include any number of providers; only unset keys are written.
The Vault provider is active when these are satisfied:
- VAULT_ADDR — base URL for Vault (e.g., https://vault.byu.edu or https://vault.byu.edu/v1)
- A token:
- VAULT_TOKEN, or
- ~/.vault-token (created by vault login)
- Secret path you pass to vault.NewProvider(...) (KV v2 path, e.g.
kvv2/<service-name>/dev/env-vars) - Optional: VAULT_NAMESPACE → sent as X-Vault-Namespace
Timeouts: Vault HTTP requests use a 10s timeout.
Context: The Vault provider uses a background context by default. To override:
p := vault.NewProvider("/app/web")
p.Context = ctx // set deadlines, cancellation, etc.
if err := envchain.Inject(p); err != nil { /* ... */ }Or pass one shared context across all context-aware providers:
if err := envchain.InjectWithContext(ctx, dotenv.NewProvider(".env"), vault.NewProvider("/app/web")); err != nil {
// handle joined provider errors
}Instead of manually checking for missing values, use GetEnv:
// GetEnv returns an EnvContainer with the looked-up value and ok state.
func GetEnv(key string) EnvContainer
// WithDefault returns the same container if the env key was set.
// If unset, it returns a container with the provided default value.
func (c EnvContainer) WithDefault(def string) EnvContainer {
// ...
}This lets you simplify configuration code:
package main
import (
"fmt"
"os"
"github.com/stuft2/envchain"
)
func main() {
// Example: PORT will default to 8080 if not set.
port := envchain.GetEnv("PORT").WithDefault("8080").asString()
addr := envchain.GetEnv("ADDR").WithDefault(":http").asString()
fmt.Println("Starting server on", addr, "port", port)
// Example with empty string (treated as set):
_ = os.Setenv("DEBUG", "")
debug := envchain.GetEnv("DEBUG").WithDefault("false").asString()
fmt.Println("Debug mode =", debug)
}- joho/godotenv — parse
.envfiles.
CI runs on push and pull request via .github/workflows/ci.yml.
Run the same checks locally:
task fmt
task lint
task testSymptom: vault: VAULT_ADDR is required to inject environment variables
Cause: -vault-path is set, but VAULT_ADDR is missing.
Fix: export VAULT_ADDR (for example, https://vault.byu.edu) or remove -vault-path.
Symptom: vault: VAULT_ADDR set but no token found (VAULT_TOKEN or ~/.vault-token)
Cause: Vault address is configured, but auth token is unavailable.
Fix: set VAULT_TOKEN or run vault login so ~/.vault-token exists.
Symptom: vault: invalid VAULT_ADDR "...": ...
Cause: VAULT_ADDR is malformed (missing scheme, invalid host, or invalid URL).
Fix: use a valid URL such as https://vault.byu.edu (or https://vault.byu.edu/v1).
Symptom: Usage: envchain [flags] -- command [args...]
Cause: command was not passed after --.
Fix: provide a command after separator, for example envchain -- env.
Symptom: envchain: failed to execute "..."
Cause: the command after -- is missing from PATH or not executable.
Fix: verify the executable name and run which <command> to confirm availability.
Symptom: expected value from .env/Vault is not applied
Cause: existing process env takes precedence over providers.
Fix: unset the key before running, for example:
export PORT=3000
envchain -- env | grep '^PORT=' # PORT=3000 (existing env wins)
unset PORT
envchain -- env | grep '^PORT=' # PORT from .env or VaultVerbose mode note: -verbose logs provider flow and key names, not secret values. Secret-safe diagnostics and redaction guarantees will continue to improve as diagnostics features evolve.
- Usability feature docs:
docs/README.md