Skip to content

XDG and POSIX Compliance

The XDG Base Directory Specification defines a set of well-known environment variables that applications should use to locate user-specific files, rather than scattering them across $HOME. The practical outcome is a $HOME directory with a handful of dotfiles instead of dozens, and a predictable directory hierarchy that backup tools, sync clients, and dotfiles managers can reason about.

mise is XDG-native by default — unlike many CLI tools, it does not require overrides to respect XDG_CONFIG_HOME, XDG_DATA_HOME, XDG_CACHE_HOME, and XDG_STATE_HOME. This eliminates a class of toolchain-specific boilerplate that was previously necessary for proto (PROTO_HOME), volta (VOLTA_HOME), and similar managers.

XDG variable definitions

Variable Default Holds
XDG_CONFIG_HOME ~/.config User-specific config files — read/write by user, not programs
XDG_DATA_HOME ~/.local/share User-specific data files — application-generated, persistent
XDG_STATE_HOME ~/.local/state Persistent but non-critical state: history, logs, layout
XDG_CACHE_HOME ~/.cache Non-essential cached data — safe to delete at any time
XDG_RUNTIME_DIR Set by PAM/systemd Runtime files: sockets, fifos, named pipes — 0700, cleared on logout

Zsh does not read XDG_CONFIG_HOME by default — it always looks in $HOME for .zshenv, .zprofile, .zshrc, .zlogin. The bridge is ZDOTDIR: when set, zsh looks for its dotfiles in that directory instead of $HOME. The challenge is that ZDOTDIR must be set before zsh reads any user dotfile — which means it must be set in a file that zsh reads unconditionally: /etc/zshenv or ~/.zshenv (still in $HOME, read before ZDOTDIR takes effect). The ~/.zshenv bootstrap solves this.

The bootstrap file (~/.zshenv)

This is the only file that lives in $HOME. Its sole responsibility is to set ZDOTDIR and then immediately hand off to the XDG-located .zshenv. It contains nothing else — no PATH manipulation, no aliases, no conditionals beyond the ZDOTDIR redirect. Keep this file under ten lines.

# ~/.zshenv
# ─────────────────────────────────────────────────────────────────────
# Bootstrap only. Redirects zsh to the XDG config directory.
# All further configuration lives in $ZDOTDIR.
# ─────────────────────────────────────────────────────────────────────

# Establish XDG base directories with POSIX-compliant defaults.
# Use := assignment: set only if unset or empty (idempotent).
export XDG_CONFIG_HOME="${XDG_CONFIG_HOME:=$HOME/.config}"
export XDG_DATA_HOME="${XDG_DATA_HOME:=$HOME/.local/share}"
export XDG_STATE_HOME="${XDG_STATE_HOME:=$HOME/.local/state}"
export XDG_CACHE_HOME="${XDG_CACHE_HOME:=$HOME/.cache}"

# Point zsh at its XDG config directory.
# Subsequent files (.zshrc, .zprofile, etc.) are resolved from here.
export ZDOTDIR="${XDG_CONFIG_HOME}/zsh"

System-level alternative

On systems where you have root access (e.g., a personal workstation or managed team machine), you can set ZDOTDIR in /etc/zsh/zshenv instead, which eliminates the need for a ~/.zshenv bootstrap file entirely. The per-user bootstrap approach shown here works without root and is suitable for both personal machines and shared/managed environments.

Directory structure

The full XDG-compliant zsh config tree:

~/.zshenv                           # Bootstrap only — sets ZDOTDIR

~/.config/zsh/                      # $ZDOTDIR — all zsh config lives here
├── .zshenv                         # Env vars, XDG tool redirects, PATH seeds
├── .zprofile                       # Login-shell PATH finalization
├── .zshrc                          # Interactive shell settings, sources conf.d/
├── .zlogout                        # Cleanup on login shell exit
└── conf.d/                         # Modular fragments, loaded in sorted order
    ├── 10-path.zsh
    ├── 20-completion.zsh
    ├── 25-tool-cache.zsh
    ├── 30-history.zsh
    ├── 40-options.zsh
    ├── 50-keybinds.zsh
    ├── 60-aliases.zsh
    ├── 61-git-extensions.zsh
    ├── 62-ruby-aliases.zsh
    ├── 63-python-aliases.zsh
    ├── 64-js-aliases.zsh
    ├── 66-data-functions.zsh
    ├── 67-devloop.zsh
    ├── 68-diagnostics.zsh
    ├── 70-tools.zsh                # mise, direnv, rv, ssh-agent (Tier 1)
    └── 80-functions.zsh

~/.config/mise/                     # mise global config
└── config.toml                     # User-scope tool/env/task defaults

~/.local/share/mise/                # mise state — installs, shims, plugins
├── shims/                          # Shims used by non-interactive subprocesses
└── installs/                       # Installed tool versions

~/.local/state/zsh/                 # Runtime state — NOT in config directory
└── history

~/.cache/zsh/                       # Caches — safe to delete
└── zcompdump

Why number the conf.d fragments?

Numeric prefixes enforce deterministic load order without relying on filesystem inode ordering (which varies by OS and filesystem). The gaps (10, 20, 30...) leave room to insert new fragments without renaming existing ones. Use two-digit prefixes — the sort order of 10 vs. 9 is alphabetic, so 09 sorts before 10 correctly, but single digits do not.