Configuration¶
envhaul reads a YAML file, envhaul.yaml by default.
Finding the config file¶
envhaul uses the first match of:
--config/-c- the
ENVHAUL_CONFIGenvironment variable envhaul.yaml,envhaul.yml,.envhaul.yamlor.envhaul.ymlin the current directory or a parent, stopping at the repository root (the first directory containing.git)
Use one file per environment, for example envhaul.yaml for development and
envhaul.staging.yaml for staging:
Editor support¶
Add this first line to get completion and validation in editors that use the YAML language server (VS Code, JetBrains, Neovim, Zed):
# yaml-language-server: $schema=https://raw.githubusercontent.com/davidtucker/envhaul/main/schema/envhaul.schema.json
Top-level keys¶
| Key | Required | Description |
|---|---|---|
version |
no | Schema version. Only 1 exists; it is the default. |
name |
no | Environment name, shown in generated file headers. |
description |
no | Free text. |
aws_profile |
no | Default AWS profile for references without @profile. Overridden by --profile. When unset, the SDK default applies (AWS_PROFILE, then default). |
aws_region |
no | Region for every request. Overridden by --region. When unset, each profile's own region from ~/.aws/config is used. |
ssm_prefix |
no | Joined onto SSM paths that don't start with /. Must start with /. |
secret_prefix |
no | Prepended to Secrets Manager ids that aren't ARNs, e.g. myapp/dev/. |
vault |
no | HashiCorp Vault / OpenBao settings. |
onepassword |
no | 1Password settings. |
gcp |
no | Google Cloud Secret Manager settings. |
azure |
no | Azure Key Vault settings. |
components |
yes | Map of component name to settings. Each component writes one file. |
Source settings¶
Sources other than AWS each have an optional block of default settings and named profiles:
vault:
address: https://vault.example.com
profiles:
prod:
address: https://vault.prod.example.com
token_env: VAULT_PROD_TOKEN
gcp:
project: my-dev-project
- Defaults apply to every reference to that source.
@nameon a reference picks a profile. The profile's settings override the defaults, and anything it doesn't set falls back to them.@namewith no matching profile is used as the source's natural scope: a Vault namespace, a 1Password account, a GCP project or an Azure tenant. So{{gcp:db-password@my-other-project}}works without any profile.
Each source's page lists its settings. validate rejects unknown settings
and profile names with file:line:col errors.
Components¶
components:
api:
description: Backend API # optional, shown in shell completion
env_file: services/api/.env # relative to the config file
vars:
KEY: value
| Key | Required | Description |
|---|---|---|
env_file |
yes | Output path. Relative paths resolve against the config file's directory, not the current directory. Two components can't write the same file. |
vars |
yes | Variables, written in the order listed. |
description |
no | Free text. |
Component names can't contain spaces or slashes.
Variables¶
Variable names must match [A-Za-z_][A-Za-z0-9_]*. Values can be:
- inline:
ENV: dev - a reference:
API_KEY: "{{ssm:api/API_KEY}}" - a template mixing both:
DB_URL: "postgresql://{{secret:db#user}}:{{secret:db#pass|urlencode}}@host/db"
See References and pipes for the syntax.
Values are taken literally
envhaul keeps every scalar exactly as written, so 1.10, 0755, yes and
1e3 reach your .env unchanged instead of becoming 1.1, 493, true
or 1000. An empty value, ~ or null becomes an empty string. Quote
values that contain {{, # or :.
Generated files¶
Each env file starts with a header naming the config it came from, followed by the variables in config order:
# Generated by envhaul from envhaul.yaml (dev). Do not edit; changes are overwritten.
# Regenerate with: envhaul sync api
APP_ENV=development
DATABASE_URL=postgresql://admin:p%40ss@localhost/acme
GREETING='hello world'
- envhaul owns these files: every
syncrewrites them completely. Put local-only values in the config, or in a separate file your tooling loads (such as.env.local). - Files are written atomically (a temp file, then rename) with mode
0600, and are left untouched when the content hasn't changed. - Values are written bare when they only contain safe characters, in single
quotes when they contain no
'or newline, and in double quotes with\\,\",\$,\nand\rescapes otherwise. Docker Compose reads every form back exactly.$is escaped inside double quotes because Compose, shells and Vite expand variables there; python-dotenv and Nodedotenvkeep that backslash, so a value with a$that also contains a'or a newline reaches them with\$. - Some loaders expand variables even in single-quoted values: python-dotenv
expands
${NAME}, and Vite expands$NAMEand${NAME}. If a secret can contain those, turn interpolation off in the loader (python-dotenv:interpolate=False). syncwarns when a generated file isn't ignored by git.
Missing values¶
A reference to a parameter or secret that doesn't exist (or a #key that a
JSON secret doesn't have) is written as an empty string with a warning. This
keeps optional integrations from blocking everyone else. To change that:
- add
|default:valueto the reference to provide a fallback, or - run with
--strictto fail instead (useful in CI).
Errors such as expired credentials or denied access always fail, and nothing is written.
Full example¶
# yaml-language-server: $schema=https://raw.githubusercontent.com/davidtucker/envhaul/main/schema/envhaul.schema.json
#
# Example envhaul configuration for a project with an API, a web frontend and
# infrastructure code. Each component becomes one .env file.
#
# envhaul -c examples/envhaul.yaml validate # check this file (offline)
# envhaul -c examples/envhaul.yaml list # show where every value comes from
# envhaul -c examples/envhaul.yaml sync # fetch values and write the .env files
version: 1
name: dev
description: Local development environment
aws_profile: acme-dev # default profile for references without @profile
aws_region: us-east-1
ssm_prefix: /acme/dev # relative SSM paths resolve under this prefix
# Settings for sources other than AWS (all optional).
vault:
address: https://vault.acme.example
onepassword:
account: acme.1password.com
gcp:
project: acme-dev
azure:
vault: acme-dev-kv
components:
api:
description: Backend API
env_file: services/api/.env
vars:
# Inline values live in version control.
APP_ENV: development
LOG_LEVEL: debug
CORS_ORIGINS: "http://localhost:5173,http://127.0.0.1:5173"
CACHE_TTL_SECONDS: "300"
# SSM Parameter Store. "api/STRIPE_KEY" resolves to /acme/dev/api/STRIPE_KEY.
STRIPE_SECRET_KEY: "{{ssm:api/STRIPE_SECRET_KEY}}"
# Another AWS account: shared third-party keys live in acme-shared.
OPENAI_API_KEY: "{{ssm:api/OPENAI_API_KEY@acme-shared}}"
SENTRY_DSN: "{{ssm:api/SENTRY_DSN@acme-shared}}"
# Secrets Manager: pick keys from a JSON secret and URL-encode the password.
DATABASE_URL: "postgresql://{{secret:acme/dev/db#username}}:{{secret:acme/dev/db#password|urlencode}}@localhost:5432/acme"
# Optional values: fall back when the parameter does not exist.
FEATURE_FLAGS: "{{ssm:api/FEATURE_FLAGS|default:none}}"
web:
description: Vite frontend
env_file: web/.env
vars:
VITE_API_BASE_URL: "http://localhost:8000"
VITE_SENTRY_DSN: "{{ssm:api/SENTRY_DSN@acme-shared}}" # fetched once, used twice
infrastructure:
description: CDK app
env_file: infrastructure/.env
vars:
DEPLOY_ENV: dev
AWS_REGION: us-east-1
# Absolute paths ignore ssm_prefix.
HOSTED_ZONE_ID: "{{ssm:/acme/global/route53/HOSTED_ZONE_ID}}"
GITHUB_TOKEN: "{{secret:acme/ci/github#token|trim}}"
tools:
description: Local developer tooling, pulling from several secret stores
env_file: tools/.env
vars:
SIGNING_KEY: "{{vault:payments/keys#signing}}" # HashiCorp Vault
GITHUB_TOKEN: "{{op:Engineering/GitHub/token}}" # 1Password
MAPS_API_KEY: "{{gcp:maps-api-key}}" # Google Cloud
STORAGE_KEY: "{{azkv:storage-account-key}}" # Azure Key Vault