Skip to content

Environments & Secrets

This is the job you used to give direnv: set environment variables when you enter a project, unset them when you leave. mise does it from the same mise.toml that pins your tools — no second config file, no .envrc.

mise.toml
[env]
NODE_ENV = "development"
PORT = "3000"
DATABASE_URL = "postgres://localhost/app_dev"

cd into the project and these are exported; cd out and they’re gone. Plain strings cover most of what you need.

Three modifiers handle the edge cases:

mise.toml
[env]
LOG_LEVEL = { default = "info" } # use this UNLESS already set in the shell
API_TOKEN = { required = true } # error if unset — fail fast, not at runtime
CI = false # actively UNSET an inherited variable
  • default — a fallback that an existing shell value overrides.
  • required — mise errors when you enter the dir if the var isn’t set elsewhere.
  • false — removes the variable (handy to scrub an inherited CI/DEBUG).
mise.toml
[env]
_.path = "./bin" # relative → resolved from config_root
# _.path = ["./bin", "./node_modules/.bin"]

Prepends project-local binaries to PATH without touching your global setup. Relative paths are anchored at the config file’s directory, not your cwd.

_.file — load dotenv / JSON / YAML / TOML

Section titled “_.file — load dotenv / JSON / YAML / TOML”
mise.toml
[env]
_.file = ".env" # dotenv by default

The format is inferred from the extension — .env, .json, .yaml, .toml all work. Use the table form for options:

mise.toml
[env]
_.file = { path = ".env.secret", redact = true } # hide values in `mise env`
_.file = { path = ".env.tools", tools = true } # load AFTER tools install
  • redact = true — values are masked in mise env / logs.
  • tools = true — defers loading until tools are installed, so the file can reference tool-provided binaries.

_.source — run a shell script and import its exports

Section titled “_.source — run a shell script and import its exports”
mise.toml
[env]
_.source = "./scripts/env.sh" # bash; its `export`s become mise env

For when an env value has to be computed in shell. This executes code, so it’s trust-gated — mise won’t run it until you mise trust.

[env] entries within a block are unordered; when one value must reference another, use the array-of-tables form so blocks apply in sequence:

mise.toml
[[env]]
_.file = ".env"
[[env]]
DATABASE_URL = "{{env.DB_HOST}}/app" # DB_HOST from the .env loaded above

mise renders config through Tera. The default way to reference another variable is {{env.X}}not $X:

mise.toml
[env]
PROJECT = "{{config_root | basename}}" # the project dir name
CACHE_DIR = "{{config_root}}/.cache" # absolute path to this config
GIT_SHA = "{{ exec(command='git rev-parse --short HEAD') }}"
EDITOR = "{{ get_env(name='EDITOR', default='vim') }}"

Useful building blocks: vars env, cwd, config_root, mise_bin, mise_env; functions exec(command=…), get_env(name=…, default=…), arch(), os(), num_cpus(); filters like basename, dirname, kebabcase.

One project, several environments — dev, ci, prod. Set MISE_ENV (or -E/--env) and mise loads the matching files on top of the base:

Terminal window
MISE_ENV=production mise run deploy # also loads mise.production.toml
mise -E ci run test
mise.production.toml
[env]
NODE_ENV = "production"
LOG_LEVEL = "warn"
DATABASE_URL = { required = true } # no localhost default in prod

MISE_ENV accepts a comma list (MISE_ENV=ci,prod) — later wins. For a given environment the files merge highest-to-lowest, key by key:

PrecedenceFileCommit?Purpose
highestmise.<env>.local.tomlgitignoreyour machine, this env
mise.local.tomlgitignoreyour machine, all envs (per-dev overrides)
mise.<env>.tomlcommitshared, this env
lowestmise.tomlcommitshared base
Layering for MISE_ENV=production
Rendering diagram…

mise can create and activate a project venv as part of entering the directory — no source .venv/bin/activate, ever:

mise.toml
[tools]
python = "3.13"
[env]
_.python.venv = { path = ".venv", create = true } # create if missing, then activate

cd in and the venv is active; pip/python resolve inside it. Combined with the [tools] pin, a new dev gets the right Python and an isolated venv from a single mise install.

mise can load encrypted secrets so the ciphertext is safe to commit and the plaintext only exists in memory. Both built-in methods are experimental:

mise.toml
[settings]
experimental = true

The decryption key — your age private key — stays out of the repo (default ~/.config/mise/age.txt). Commit only ciphertext.

  1. Install the tools and generate a key:

    Terminal window
    mise use -g sops age
    age-keygen -o ~/.config/mise/age.txt # prints the public key
  2. Encrypt a secrets file in place with your public key:

    Terminal window
    sops encrypt -i --age age1xxxxxxxx... .env.json
  3. Load it — mise auto-decrypts via the age key on entry:

    mise.toml
    [env]
    _.file = { path = ".env.json", redact = true }

Key lookup order: MISE_SOPS_AGE_KEY*_FILESOPS_AGE_KEY*~/.config/mise/age.txt.

For one-off secrets, encrypt a value straight into mise.toml:

Terminal window
mise set --age-encrypt --prompt DB_PASSWORD
mise.toml
[env]
DB_PASSWORD = { age = { value = "-----BEGIN AGE…", format = "raw" } }

View the decrypted environment with mise env (or mise env --redacted --values to confirm masking works).

Keep secret values out of logs and mise env output with a top-level glob list, or per-file redact:

mise.toml
[settings]
redactions = ["*_TOKEN", "*_SECRET", "DB_PASSWORD"]

Per-developer age keys work for small teams. For shared/remote secret backends (1Password, AWS Secrets Manager, KMS), jdx’s separate fnox manager is the 2026 answer — it integrates with mise without putting any private key on disk.

You can now pin tools and shape the environment around them. Next, turn the chores into real tasks: The Task Runner.