Statemate reads three levels of configuration:
| File | Purpose |
|---|---|
mate.yaml in the repository |
The configuration itself, committed and shared |
~/.config/statemate/config.yaml |
Machine-local overrides, not committed |
.mate.yaml in a source directory |
Settings scoped to one source |
The repository config may be YAML or TOML: mate.yaml, mate.yml and
mate.toml are looked for in that order, and the same goes for .mate.yaml in a
source. The local config is YAML only: config.yaml, or config.yml. The
directory containing the config file is the source directory, and relative
paths in sources, var_files and include resolve against it. Other paths,
such as age.identity and secrets_cache, should be absolute or start with
~/.
Unknown keys are ignored without a warning, so a misspelt key simply has no
effect. mate doctor and mate check catch most of the mistakes that matter.
statemate does not search parent directories. It uses the first of:
--config/-con the command line$STATEMATE_DIRsource_dirin the local config- the current directory
mate init writes source_dir for you, so after setup mate works from any
directory. mate config source-dir prints the directory in use.
sources: [zsh, nvim, git]
default_source: misc
target_base: "~"
profiles:
work:
extends: base
detection:
hostname: "work-*"
age:
identity: "~/.config/statemate/key.txt"
recipients: ["age1..."]
variables:
email: "you@example.com"
gpg_key: '{{ cmd "gpg --list-secret-keys --with-colons | awk -F: ''/^fpr/{print $10; exit}''" }}'
var_files:
- .matedata/secrets.yaml
packages:
- fd
- ripgrep
include:
- packages.yaml
ignore:
- "*.md"
- .DS_Store
editor: nvim
diff_tool: delta
aur_helper: paru
secrets_cache: "~/.local/state/statemate/secrets.age"| Key | Type | Description |
|---|---|---|
sources |
list | Source directories to scan, relative to the source directory or absolute |
default_source |
string | Source mate add uses when none is given |
target_base |
string | Root to deploy into. Defaults to ~ |
profiles |
map | Profile definitions — see Profiles |
profile |
string | Force a profile, skipping detection |
source_dir |
string | Where to find mate.yaml. Only meaningful in the local config |
editor |
string | Editor for mate edit, with any arguments (code --wait). Overrides $VISUAL and $EDITOR; falls back to vi |
diff_tool |
string | External diff tool for mate diff and the [d]iff answer to a conflict during mate apply, with any arguments. Run as <tool> <old> <new> |
age |
map | Encryption settings — see age |
variables |
map | Values available to templates as .Vars. Values may be templates — see variables |
var_files |
list | YAML/TOML files to load variables from |
packages |
map | Packages to install — see Packages |
hooks |
map | Commands to run when matching files change — see Hooks |
include |
list | Files to merge packages and variables from. Also valid inside a profile |
ignore |
list | gitignore-style patterns excluded from scanning |
aur_helper |
string | AUR helper binary, yay or paru. If unset, yay is used if installed, else paru |
secrets_cache |
string | Path to the encrypted secrets cache |
log_file |
string | Append an audit log of what each run changed to this file — see Logging |
log_level |
string | debug, info, warn or error. Defaults to info |
age:
identity: "~/.config/statemate/key.txt"
identity_command: "op read op://Private/age/key"
recipients:
- age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8pidentity is a path to an age private key file, as written by age-keygen, used
for decryption. Alternatively identity_command runs a command that prints the
key on stdout, which is useful when the key lives in a password manager rather
than on disk. If both are set, identity_command wins. Its output must be the
bare AGE-SECRET-KEY-… line, without the comments age-keygen writes.
recipients are the public keys (age1…) files are encrypted to. Include every
key that needs to read them. SSH keys are not supported as recipients.
Encryption walks through creating a key and storing it.
A variable's value may be a template, which is how a value comes from a shell command:
variables:
hostname_short: '{{ cmd "hostname -s" }}'
gpg_keys:
- '{{ cmd "gpg --card-status | awk ''/Signature key/{print $NF}''" }}'Quote the value: an unquoted { starts a YAML map. Any string containing {{ is
rendered, including list items and nested keys, and so are the values from
include, var_files and profile variables. All the
template functions and variables such as .OS and
.Hostname are available, except bitwarden. .Vars holds the unrendered
values, so one templated variable cannot use another's result.
Variables render whenever templates do, which is most commands, so keep commands
fast. A failing cmd aborts the command you ran. To keep a literal {{ in a
value, write {{ "{{" }}.
Loads variables from YAML or TOML files, merged in order after variables:
var_files:
- .matedata/vars.yaml
- .matedata/secrets.yaml # resolves .matedata/secrets.yaml#enc tooPaths are relative to the source directory, or absolute, or start with ~/. A
missing file is skipped without error, and so is a file that is not .yaml,
.yml or .toml. If the plain path does not exist, statemate looks for the same
path with an #enc suffix and decrypts it, so the config need not change
when you encrypt a var file.
Splits packages and variables across files:
include:
- packages.yaml
- .matedata/secrets.yaml#enc# packages.yaml
packages:
- fd
- ripgrep
variables:
email: "me@example.com"Only packages and variables are read from an included file; other keys are
ignored. Package lists are appended (duplicates dropped) and variables merged, so
an include adds to what mate.yaml already declares. Where both define the same
variable, the include wins. A missing include file is skipped.
An included file may be #enc, and as with var_files the suffix is found
automatically if the plain path does not exist. This is the usual way to keep a
list of secret-bearing variables in the repository.
Profiles accept their own include, merged into that profile only:
profiles:
work:
include: [work-packages.yaml]Every profile's includes are read when the config loads, including inactive
profiles. An #enc include therefore needs a working age identity on every
machine, not only on those that use that profile.
gitignore-style patterns for paths statemate should not treat as managed files:
ignore:
- "*.md"
- "!**/.claude/**/*.md" # but keep these
- .DS_StoreApplies to all sources. For patterns that concern one source only, use ignore in
that source's .mate.yaml, where patterns match the path inside the source.
How patterns differ from git's:
!re-includes a file, with the last matching pattern winning. It cannot re-include anything inside an ignored directory, because statemate does not descend into one.?is a literal character, not a wildcard.- A pattern with a
/in the middle is not anchored:etc/fooalso matchesx/etc/foo. - Top-level patterns match
<source>/<path>, so a leading/anchors at the source name:/zsh/notes.txt.
.git, .matescripts, and .mate.yaml (.yml, .toml) are never deployed,
so they need no pattern. .mateignore files are no longer supported.
A profile bundles the sources, variables, and packages for a kind of machine.
profiles:
base:
sources: [common, shell]
variables:
email: "me@example.com"
work:
extends: base
detection:
hostname: ["work-laptop", "work-*"]
variables:
email: "me@company.com"
packages:
- awscli
- slack
arch:
extends: base
detection:
mode: and
os: linux
command: "test -f /etc/arch-release"
packages:
- keydThe first matching profile wins. Profiles are sorted by how deep they sit in an
extends chain, deepest first, and alphabetically within the same depth. So
every profile that extends something is tried before every profile that does
not, which makes OS-level profiles natural fallbacks.
| Field | Matches |
|---|---|
hostname |
The hostname. A pattern containing * is a glob; anything else must match exactly. A list matches any entry |
user |
$USER (or $USERNAME), with the same rules as hostname |
os |
linux, darwin, … (Go's GOOS). A single value |
arch |
amd64, arm64, … (Go's GOARCH). A single value |
command |
A shell command, run with sh -c; matches when it exits 0 |
mode |
or (default, where any field matches) or and (all must match) |
A profile with no detection block, or an empty one, is never auto-detected;
select it with --profile or profile: in the config. This is the normal shape
for a base profile that exists only to be extended.
When no profile matches, only the top-level sources and variables apply, and
#profile: files are not filtered at all. Make
sure every machine matches some profile.
extends names a single parent, and chains may be several levels deep. Along the
chain:
- sources are added: top-level
sources, then each ancestor's, then the profile's own, without duplicates - variables are merged, the child winning where both define one
- packages are added, like sources
- detection is not inherited
Inheritance is respected everywhere profiles are used: file filtering by
#profile:, script and hook filtering, and source
resolution.
In precedence order:
--profile/-pon the command lineprofile:in the local config ormate.yaml$STATEMATE_PROFILE- Automatic detection
mate profile shows which profile is active, how it was chosen, and which sources
it resolves to.
Forcing a profile that is not defined is not an error. It selects no profile's
sources or variables, and only files tagged with that exact name pass the
#profile: filter.
~/.config/statemate/config.yaml (or $XDG_CONFIG_HOME/statemate/config.yaml) holds
machine-specific settings that should not be committed:
source_dir: "~/dotfiles"
profile: work
editor: nvimsource_dir is what lets mate run from any directory. Without it, statemate
looks for mate.yaml in the current directory.
Only a subset of keys is honoured here: sources, default_source,
target_base, profile, editor, age, variables, var_files,
packages, profiles, diff_tool, hooks, log_file and log_level. Others,
including include, ignore, aur_helper and secrets_cache, are ignored.
Each key replaces the repository's value; nothing is merged. A local
variables: with one entry drops all of the repository's top-level variables,
and a local profiles: replaces every profile. The exception is hooks, which
are merged by name: a local hook replaces a repository hook of the same name, and
the rest are kept. See Hooks.
A local config that fails to parse is ignored without a warning. Run
mate doctor if local settings seem to have no effect.
A .mate.yaml inside a source directory configures that source alone:
# arch/.mate.yaml
profile: arch
owner: root
group: root
perm: "644"
targets:
etc: /etc
ignore:
- "*.md"
packages:
pacman: [keyd]
generate:
- target: .config/app/generated.conf
mode: "600"
content: |
key = {{ .Vars.api_key }}| Key | Description |
|---|---|
profile |
Default #profile: for every file in the source that has none of its own |
target_base |
Deploy this source relative to a different root. Disables targets |
targets |
Map a top-level subdirectory to a path, or a list of paths |
ignore |
Patterns as for ignore, matched against the path inside this source |
owner |
Default owner for files and directories in the source |
group |
Default group for files and directories |
perm |
Default mode for files, octal string. Directories are not affected |
packages |
Packages this source needs |
include |
Files to read packages and variables from — see below |
generate |
Files created from inline content — see below |
hooks |
Hooks matching only this source's files — see Hooks |
Attributes in file and directory names take precedence over owner, group
and perm.
profile here only tags files. The source's packages, generate entries and
hooks are not filtered by it; to keep a whole source off a machine, list it under
that machine's profile sources instead.
.mate.yaml is itself rendered as a template before it is parsed,
so {{ .Vars.workspace }} works inside it, and so do loops:
# ssh/.mate.yaml: one private key file per name in the sshKeys variable
generate:
{{- range $key := (var "sshKeys") }}
- target: ".ssh/keys.d/id_{{ $key }}"
mode: "0600"
content: |
{{ indent 6 (bitwarden $key "ssh" "private") }}
{{- end }}A .mate.yaml that fails to parse or render is an error that names the file,
rather than being skipped.
Works like the top-level include, with paths relative to the
source directory:
# infra/.mate.yaml
include:
- home-ops/vars.yamlIncluded packages count as this source's packages. Included variables apply to
every template while the source is active, this .mate.yaml included, and win
over profile variables of the same name. While statemate reads the include list,
variables from the include are still unset and bitwarden returns an empty
string, so a template that loops over an included variable must tolerate it being
empty. #enc is not supported here. An included file inside the source is
deployed like any other file unless it is listed under ignore.
Maps a subdirectory of the source onto another path, which is how system files
are managed. Only the first component of a file's path is looked up, so etc
maps the whole etc/ subtree; a key such as etc/keyd never matches. Values may
start with ~ and may use template variables. A relative value is taken relative
to the global target_base, normally your home directory. See
Managing System Files.
targets:
etc: /etcarch_root/
.mate.yaml
etc/
issue → /etc/issue
keyd/default.conf → /etc/keyd/default.conf
A value can also be a list, which deploys the subdirectory to every path in it.
Each copy is tracked separately, so mate status reports drift in each one:
targets:
.claude.shared:
- .claude.personal # ~/.claude.personal
- .claude.work # ~/.claude.workFiles outside the mapped directories still deploy to the source's normal target
base. targets is ignored if the same .mate.yaml sets target_base.
Directories outside your writable tree are created with sudo as needed.
Creates a file from content in the config rather than from a file in the repository:
generate:
- target: .config/app/config.toml
mode: "600"
profile: work
content: |
token = "{{ bitwarden "app" "field" "token" }}"| Field | Description |
|---|---|
target |
Path to write, relative to the source's target base, absolute, or starting with ~ |
mode |
File mode, octal string. Default 0644 |
profile |
Only generate under this profile (or one extending it) |
content |
The file content |
content is rendered as part of the whole .mate.yaml, before YAML parsing, so
multi-line values must be indented to stay inside the block scalar. That is what
indent 6 does in the example above. targets and ignore do not apply to
generated files.
Useful for short files that would otherwise need their own source file, and for content assembled from variables or secrets.
| Variable | Effect |
|---|---|
STATEMATE_DIR |
Where to find mate.yaml. Overrides source_dir and the current directory, but not --config |
STATEMATE_PROFILE |
Selects a profile, below config but above detection |
STATEMATE_KEY_FILE |
Path to the age private key. Overrides age.identity and age.identity_command in either config |
STATEMATE_LOG_FILE |
Log file. Overrides log_file in either config, but not --log-file |
STATEMATE_LOG_LEVEL |
Log level. Overrides log_level in either config, but not --log-level |
XDG_CONFIG_HOME |
Where the local config lives. Default ~/.config |
XDG_DATA_HOME |
Where state.db lives. Default ~/.local/share |
XDG_STATE_HOME |
Where the secrets cache lives. Default ~/.local/state |
VISUAL, EDITOR |
Editor for mate edit, if editor: is unset |
USER, USERNAME |
The user for profile detection and .Username |
NO_COLOR |
Set to any value to turn off colored output, like --no-color. Color is also off when stdout is not a terminal |
STATEMATE_DIR is handy for working against a second repository without touching
your config:
STATEMATE_DIR=~/other-dotfiles mate statusScripts also receive their own environment variables, and hooks theirs.