homie.toml lives at the root of your user environment repo. It is the
only configuration Homie reads. Everything else — your home/ tree and
scripts/ — is on disk, where you can see and version it.
Minimal valid file:
[user]
name = "Scout Homes"
email = "scout@homie.sh"That’s it. Every other table is optional. The sections below describe each one in order.
[user]#
Identity. Both fields are required.
[user]
name = "Scout Homes"
email = "scout@homie.sh"Templates see these as {{ .Name }} and {{ .Email }}. Scripts see
neither — pass identity in via [vars] if scripts need it.
[profile]#
Selects which kind of machine you’re configuring. Affects rendering and tag membership; nothing else.
[profile]
name = "personal" # personal | work | devcontainer | ...
default_shell = "zsh"profile.name becomes an active tag automatically — so a template can
branch on {{ if hasTag "work" }} and a script can read $HM_TAGS.
Both fields default to empty if omitted. Convention is personal,
work, devcontainer, or whatever short label distinguishes the
machines you actually use.
[packages]#
Native packages to install via the detected package manager (apt on
Ubuntu/Debian, dnf on Fedora, brew on macOS, pkg on Termux). Idempotent
— each package is checked with dpkg -s / rpm -q / brew list before
install.
[packages]
all = ["git", "zsh", "neovim", "tmux", "ripgrep", "fd", "fzf"]
fedora = ["util-linux-user"]
ubuntu = ["fd-find"]
debian = ["fd-find"]
macos = ["coreutils", "firefox/cask"]
termux = ["openssh"]all runs on every platform. Per-platform keys (fedora, ubuntu,
debian, macos, termux) merge on top — useful for the
rename-on-this-platform case (fd vs fd-find) or for platform-specific
tools.
On macOS, native packages install through Homebrew. A GUI app (a Homebrew
cask) is named with a /cask suffix — firefox/cask installs with
brew install --cask firefox; a bare name is a formula. A typo’d suffix
is reported by hm doctor before any install runs.
brew is optional. macOS ships no system package manager, so if you only
manage dotfiles (no [packages]), you never need it — hm apply and
hm doctor won’t complain. If brew isn’t on PATH when packages are
declared, the native phase warns and skips instead of failing; install brew
(or add a scripts/pre-*.sh that does) to have those packages applied.
On Termux (the Android terminal app), native packages install through
pkg, Termux’s wrapper over its own apt repos. Termux runs unprivileged
with no root and no sudo, so installs never escalate. Everything else —
dotfiles, templates, scripts, externals — works as it does everywhere
else against Termux’s $HOME.
On unsupported distros, the package phase prints a friendly notice and
skips. The rest of hm apply continues normally.
Tag-keyed package lists#
Sub-tables of the form [packages."tag:<name>"] contribute only when
the matching tag is active for the current host. Useful when a work
laptop and a personal laptop share a base set but each needs its own
extras.
[packages]
fedora = ["git", "zsh", "neovim"] # base, always
[packages."tag:work"]
fedora = ["kubectl", "helm", "terraform"]
[packages."tag:personal"]
fedora = ["steam", "tailscale"]Resolution: the final install set is [packages] plus every
[packages."tag:X"] sub-table where X is in the active tag set
(auto-detected, profile-derived, or [tags].extra). Each sub-table
honors the same per-distro split as the base — [packages."tag:work"].fedora
and [packages."tag:work"].ubuntu both work.
Order is deterministic: base all, base <distro>, then each matching
block in alphabetical key order (its all, then its <distro>).
Duplicates across these sources are removed on insertion, so a package
named in both base and a tag sub-table installs exactly once. Tags with
no matching sub-table contribute nothing — they aren’t an error.
Requiring several tags (AND)#
Chain tag: segments with . to require all of them — the same
.-delimited convention the home.tag-X.tag-Y/ and scripts.tag-X.tag-Y/
trees use:
# snap is Ubuntu-only and AWS is a personal-machine thing:
[packages."tag:personal.tag:ubuntu".snap]
all = ["aws-cli/classic"]
# desktop apps only on personal desktops:
[packages."tag:personal.tag:desktop".snap]
all = ["gimp", "spotify"]
[packages."tag:work.tag:ubuntu".flatpak]
all = ["us.zoom.Zoom"]A chained block applies only when every listed tag is active. Tag order
doesn’t matter (tag:personal.tag:ubuntu and tag:ubuntu.tag:personal
are the same block). Single-tag [packages."tag:X"] is just the one-tag
form of the same rule. Nested backends (.snap, .flatpak, .brew)
work under a chained key exactly as under a single-tag one.
A malformed key — a segment that isn’t tag:<name>, an empty tag:, a
trailing . — is a hard error at load, not a silent no-op. hm doctor
lists which AND-blocks were active for the current host.
[externals]#
External git repos to keep on disk — zsh/tmux/nvim plugins, themes,
editor distributions. Each entry is keyed by its destination path;
hm apply clones it when missing and updates it in place when present,
replacing the hand-rolled clone-or-pull script this usually takes.
[externals."~/.zsh/plugins/zsh-autosuggestions"]
repo = "https://github.com/zsh-users/zsh-autosuggestions"
ref = "v0.7.1" # pinned: checked out and held; never auto-moves
[externals."~/.tmux/plugins/tpm"]
repo = "https://github.com/tmux-plugins/tpm"
# no ref: track the remote default branch, fast-forward on each apply
# tag-gated, exactly like [packages."tag:X"] (AND across tags)
[externals."tag:desktop"."~/.config/some-theme"]
repo = "https://github.com/example/some-theme"repo(required) — the clone URL.ref(optional) — a branch, tag, or commit to pin. A pinned checkout is detached at the ref and held there until you change the value. Prefer pinning: an unpinned plugin that follows upstreamHEADon every apply can break the shell you’d use to debug it.- No
ref— track the remote default branch. Each apply fast-forwards; a checkout with local commits or edits fails the fast-forward and surfaces as a phase error instead of being clobbered.
Destinations must start with ~/ or $HOME/ or be absolute. The same
no-surprises rules as the rest of Homie apply: a destination that exists
but isn’t a git checkout is an error (your data is never overwritten),
and so is a checkout whose origin doesn’t match repo.
When two entries claim the same destination, the one requiring more
tags wins (a plain entry counts as zero) — the same more-specific-wins
rule as the home/ trees. Two active entries at equal specificity with
different settings are an error. In a per-host overlay,
an entry replaces the base entry for the same destination outright —
handy for pinning a different ref on one machine.
Keep externals destinations out of the directories home/ manages:
the home phase owns those paths and the two will fight over them.
Skip the phase with hm apply --skip-externals.
[tags]#
User-defined tags layered on top of the auto-detected ones. Tags are how templates and scripts branch on machine type without hard-coding distro checks.
[tags]
extra = ["laptop"]Active tags on every run are the union of:
- Detected: the platform (
ubuntu,debian,fedora,macos,termux), the arch (amd64,arm64), the short hostname (sohasTag "coach"works with no config), pluscontainerandrootwhen those apply. - Profile:
profile.name, if set. - Extra: everything in
tags.extra.
Duplicates are deduped; the resulting list is sorted, exposed to
templates as {{ .Tags }}, and to scripts as $HM_TAGS (space-joined).
[vars]#
Free-form string key/value pairs. Use these for anything Homie’s core schema doesn’t cover.
[vars]
WORK_EMAIL = "scout@work.example.com"
EDITOR = "nvim"
DOTFILES = "https://github.com/scouthomes/dotfiles"Vars are exposed two ways:
- In templates as
{{ .Vars.WORK_EMAIL }}. To make a var optional, use{{ if hasKey .Vars "X" }}{{ .Vars.X }}{{ end }}—missingkey=errorapplies, so referencing an undefined var fails the render. - In scripts as environment variables:
$WORK_EMAIL,$EDITOR, etc., exported into everyscripts/*.shsubprocess.
Keys are case-sensitive. Convention is UPPER_SNAKE since they
double as shell env vars.
What hm init writes#
A fresh hm init produces something like:
[user]
name = "Scout Homes"
email = "scout@homie.sh"
[profile]
name = "personal"
default_shell = "zsh"
[packages]
all = ["git", "zsh", "neovim", "tmux", "ripgrep", "fd", "fzf"]
[vars]
EDITOR = "nvim"From there, add per-distro overrides, tags, and vars as your environment
demands. The schema is intentionally small — anything more dynamic
belongs in scripts/.
Non-native backends#
Beyond the native package manager, [packages] accepts sub-tables for
non-native managers. v1 ships flatpak, brew, and snap; the namespace
is reserved for cargo, npm, pip, etc. to follow.
[packages.brew] is Homebrew as a Linux backend — handy on immutable
distros (Universal Blue, Bazzite) where dnf is discouraged. On macOS, brew
is the native manager instead, so list those packages under
[packages].macos, not here.
[packages.flatpak]
all = ["md.obsidian.Obsidian"]
fedora = ["org.localsend.localsend_app"]
[packages.brew]
all = ["fd", "ripgrep", "bat"]
[packages.snap]
all = ["gimp", "spotify"]Each backend mirrors the base shape — all, distro keys, and tag-keyed
sub-tables — and follows the same resolution and dedup rules. Combined
with tag-keyed packages:
[packages."tag:work".flatpak]
all = ["us.zoom.Zoom"]Backends are opt-in by tool presence. If the backend’s CLI tool
isn’t on PATH, hm apply logs a warning and skips that phase — it
doesn’t fail. Setting up a flatpak remote or installing brew belongs in
scripts/pre-*.sh so it runs before the backend’s install step.
The Flatpak backend installs from the flathub remote. References from
flathub-beta, GNOME nightly, or a custom remote aren’t supported by
[packages.flatpak]; install those via scripts/*.sh.
The Snap backend installs with snap install. Snaps that need
unconfined (classic) confinement — common for developer tools like the
AWS CLI or editors — carry a /classic suffix on the package name;
/devmode and /jailmode work the same way. A bare name installs under
default (strict) confinement.
[packages.snap]
all = ["gimp"] # strict confinement
[packages."tag:work".snap]
all = ["aws-cli/classic", "code/classic"]An unrecognized suffix (e.g. foo/bogus) is a hard error. The suffix
only expresses confinement — non-default channels or tracks (--channel,
--channel=22/stable) aren’t expressible here; install those from a
scripts/*.sh. Installing snapd itself, or removing a conflicting
distro package first, also belongs in scripts/pre-*.sh.
Unknown backend names (a typo, or one that doesn’t exist yet) decode
with a warning rather than hard-failing the load — hm doctor and
hm apply surface them so the file stays forward-compatible with
newer hm binaries.
The hm apply lifecycle becomes:
detect → pre-scripts → packages → backends → link → render → scripts,
where “backends” iterates whatever non-native backends you declared,
in alphabetical order. Backends run after native packages so a brew or
flatpak installed by [packages] is available before its own phase
fires.
Per-host overlay#
When the same repo serves multiple machines, ship a hosts/<short-hostname>.toml
alongside homie.toml. If the file matching the current host exists, it’s
deep-merged onto the base at load time:
dotfiles/
homie.toml # base, applies everywhere
hosts/
coach.toml # profile=personal + laptop packages
uceap-dev01.toml # profile=work + work vars and packagesMerge rules:
[user]and[profile]scalars in the overlay replace the base when set non-empty.[packages].*arrays append to the base (overlap is deduped, order preserved).[tags].extraappends.[vars]override per-key (base keys not mentioned by the overlay survive).
The hostname used for the lookup is the short form — everything before
the first dot — so coach.lan matches hosts/coach.toml. If
os.Hostname() fails or returns something that looks unsafe (a path
separator), no overlay is loaded and hm doctor surfaces a warning.
Validation runs after the merge, so an overlay can legitimately fill
in required [user] fields if you’d rather not commit them to the base.
Unknown fields#
Unknown TOML keys are recorded as warnings, not errors. This lets you
add new fields for a newer hm binary without breaking older clients on
the same repo. Run hm status to see warnings without applying.
Required-field violations (missing user.name or user.email) are
hard errors — hm apply refuses to proceed.
