How I manage my dotfiles
Using GNU Stow in a deliberate order to share macOS configuration without sharing machine identities.
I manage my personal and work Macs from one Git repository. Most of their configuration is identical. Their identities, SSH keys, hosts, packages, and safety rules are not.
The arrangement is simpler than it first sounds: I treat each source directory as a GNU Stow package and apply two packages in order.
- Stow the shared package.
- Stow exactly one machine package over it.
There is no generated configuration tree, copy step, or background sync. Stow links each managed target directly to its source in the repository. When I edit an existing managed file below $HOME, I am editing the correct repository file through that link.
The order of the two Stow operations is the configuration model.
The examples use placeholders. They contain no real account, host, user, or key names.
Why I need layers
Keeping a complete dotfile tree for every Mac creates copies that eventually drift. Using one universal tree creates a different problem: the wrong Git identity or SSH key can become active on the wrong machine.
I need three kinds of configuration:
- Shared files that every Mac receives.
- Private additions and replacements.
- Work additions and replacements.
Secrets are separate. SSH configuration and public keys can live in Git. Private keys cannot.
The repository
The relevant part of the repository looks like this:
dotfiles/
├── _MACHINE # private or work; ignored by Git
├── _app/
│ ├── apply.sh # main entry point
│ └── tools/
│ ├── machine.sh # profile-specific values
│ └── sync-stow.sh # applies the Stow layers
├── _setup/
│ └── steps/
│ └── 80-ssh.sh # restores private keys
├── src/
│ ├── 0-all/ # shared Stow package
│ ├── 1-private/ # personal Stow package
│ └── 2-work/ # work Stow package
├── static/
│ └── packages/
│ └── my-packages.json
└── tests/
└── stow/
Each directory below src/ is already a valid Stow package. Stow links files from those directories directly into my home directory.
The numeric prefixes make the order visible in every directory listing. 0-all is always first. Either 1-private or 2-work follows it.
flowchart LR
Shared["1 · stow src/0-all"] --> Home["$HOME"]
Selection["_MACHINE"] --> Profile{"select one profile"}
Profile --> Private["2 · stow src/1-private"]
Profile --> Work["2 · stow src/2-work"]
Private -->|replace or add| Home
Work -->|replace or add| Home
A machine package may add a new path or replace a path already linked by 0-all. Because the profile is applied second, it wins for the paths it owns. Everything else continues to point at the shared package.
Changes in $HOME land in the repository
Stow does not copy a configuration file into my home directory. It creates a symbolic link back to the package that owns the path:
~/.config/fish/config.fish
└──> ~/dotfiles/src/0-all/dot-config/fish/config.fish
~/.config/fish/machine.fish
└──> ~/dotfiles/src/2-work/dot-config/fish/machine.fish
~/.ssh/config
└──> ~/dotfiles/src/2-work/dot-ssh/config
That means I can change a managed file where an application expects to find it:
$EDITOR ~/.ssh/config
git -C ~/dotfiles diff -- src/2-work/dot-ssh/config
The diff appears in 2-work immediately because both paths refer to the same file. Nothing is copied back. There is no watcher to run and no second sync command to remember.
The active links also encode the owning layer. On my work Mac, ~/.ssh/config points into 2-work; after switching to the private profile, the same target path points into 1-private. A shared file continues to point into 0-all.
This applies to edits of existing linked files. Creating a new sibling inside a real target directory does not automatically add it to the repository, and a program that deletes and recreates a file can replace the symlink with a regular file. I check for that behavior before letting an application manage a Stow-owned path.
The script that applies the layers
This is the complete core of sync-stow.sh:
#!/usr/bin/env bash
set -euo pipefail
repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)"
stow_dir="$repo_root/src"
machine_file="$repo_root/_MACHINE"
target="${HOME:?HOME is not set}"
machine="${MACHINE:-}"
if [[ -z "$machine" && -f "$machine_file" ]]; then
IFS= read -r machine < "$machine_file"
fi
case "$machine" in
private) profile="1-private" ;;
work) profile="2-work" ;;
*)
printf 'Expected MACHINE=private or MACHINE=work\n' >&2
exit 2
;;
esac
stow_options=(
--dir="$stow_dir"
--target="$target"
--dotfiles
--no-folding
)
# Remove whichever machine layer was active before this run.
stow "${stow_options[@]}" --delete 1-private 2-work
# Re-apply the shared layer, pruning links to files it no longer contains.
stow "${stow_options[@]}" --restow 0-all
# Apply the selected machine layer last. It may replace shared Stow links.
stow "${stow_options[@]}" --override='.*' --stow "$profile"
The script deliberately makes three Stow calls instead of copying files into a temporary package.
1. Select one profile
MACHINE=work or MACHINE=private can select a profile for one run. Otherwise the script reads _MACHINE, a local file ignored by Git.
The case statement converts that public name into a package directory and rejects everything else. A typo must stop the run; silently falling back to a profile would be dangerous here.
2. Remove both possible machine layers
stow "${stow_options[@]}" --delete 1-private 2-work
Stow removes only links it owns. It does not delete either package directory, and it does not delete unrelated files in $HOME.
Removing both profiles makes switching deterministic. A work-only link cannot survive when I switch the same checkout to private.
3. Restow the shared layer
stow "${stow_options[@]}" --restow 0-all
--restow means unstow and then stow the package again. It refreshes the shared links and prunes obsolete ones after a file has moved or disappeared from 0-all.
At this point the home directory contains a complete shared baseline.
4. Stow the selected profile last
stow "${stow_options[@]}" --override='.*' --stow "$profile"
The profile is the second layer. --override='.*' allows its files to replace links already owned by the shared Stow package. It does not grant permission to overwrite an unmanaged regular file. That remains a conflict, and Stow stops.
This distinction is useful: the machine layer can intentionally beat the shared layer, but the script does not quietly adopt or destroy something it does not manage.
What the Stow flags mean
The common options are kept in a Bash array so every operation uses the same Stow directory, target, and link policy.
stow_options=(
--dir="$stow_dir"
--target="$target"
--dotfiles
--no-folding
)
--dir tells Stow that the package directories live below src/. --target makes the home directory the destination. The remaining two flags shape the resulting paths.
Why I use --dotfiles
The source packages use visible dot- names:
src/0-all/
├── dot-zshrc
├── dot-config/
│ └── fish/
│ └── config.fish
└── dot-ssh/
└── id_personal_auth.pub
With --dotfiles, Stow replaces a leading dot- in each relevant path component with a literal dot at the target:
source package home directory
src/0-all/dot-zshrc ────> ~/.zshrc
src/0-all/dot-config/fish/config.fish ────> ~/.config/fish/config.fish
src/0-all/dot-ssh/id_personal_auth.pub ───> ~/.ssh/id_personal_auth.pub
The source names stay visible in normal directory listings while applications receive the hidden paths they expect.
Without --dotfiles, Stow performs no translation. It would create ~/dot-zshrc, which is a valid symlink but not the file Zsh reads.
Why I use --no-folding
By default, Stow may optimize a new subtree into one directory symlink. If ~/.config does not yet exist, Stow could point the whole directory at one package.
That is a poor fit for layered dotfiles. ~/.config contains shared files, profile files, and configuration owned by applications outside this repository. I want it to remain a real directory.
--no-folding tells Stow to create real directories and link the managed leaves inside them:
~/.config/ # real directory
├── fish/
│ ├── config.fish # link into src/0-all
│ └── machine.fish # link into selected profile
└── another-application/ # unmanaged by this repository
This keeps ownership narrow and inspectable. A profile can replace one file without appearing to own all of ~/.config, and unstowing one package leaves neighbouring files alone.
The flag prevents new folding and refolding during that operation. It does not automatically convert every directory link created by older commands; those need to be unstowed and applied again.
SSH shows why the order matters
Most of my SSH setup is shared, but the final ~/.ssh/config depends on the machine. Each profile therefore owns that complete file:
src/1-private/dot-ssh/config
src/2-work/dot-ssh/config
The private version selects the personal authentication key:
Host *
IgnoreUnknown UseKeychain
AddKeysToAgent yes
UseKeychain yes
IdentitiesOnly yes
Host github.com
HostName github.com
User git
IdentityFile ~/.ssh/id_personal_auth
The work version selects the company key instead:
Host *
IgnoreUnknown UseKeychain
AddKeysToAgent yes
UseKeychain yes
IdentitiesOnly yes
Host github.com
HostName github.com
User git
IdentityFile ~/.ssh/id_work_auth
The small common block is duplicated on purpose. Stow composes directory trees; it does not merge the contents of two SSH config files. Keeping two complete versions makes the active result obvious.
Shared public keys live in 0-all. Work-only public keys live in 2-work. Stowing shared first and work second naturally produces both sets on the work Mac. The private Mac receives only the shared set.
| Data | Managed from | Stored in Git |
|---|---|---|
| SSH client settings | selected profile | yes |
| Host definitions | selected profile | yes |
| Public keys | shared or selected profile | yes |
| Private keys | 1Password, then ~/.ssh |
no |
Private keys never enter src/. A setup step restores each required key from the selected 1Password account, derives its public key with ssh-keygen, and compares that result with the tracked .pub file. Only a matching key is installed with mode 600 and added to the macOS keychain.
sequenceDiagram
participant P as Machine profile
participant V as 1Password
participant S as SSH setup
participant R as Tracked public key
participant H as ~/.ssh
P->>S: account and required key names
S->>V: restore private key
S->>S: derive its public key
S->>R: compare with tracked .pub file
R-->>S: match
S->>H: install with mode 600
The repository defines policy and public identity. The password manager supplies the secret material.
Git identity uses the same profile
Both machine packages also own a complete ~/.gitconfig. The private profile includes my personal identity; the work profile includes the company identity by default.
The work config keeps one narrow exception for the dotfiles repository:
[include]
path = ~/.config/git/identity-company
[includeIf "gitdir:~/dotfiles/"]
path = ~/.config/git/identity-private
The machine layer chooses the default. Git's conditional include handles the exception.
Switching and verification
The direct links make the normal change loop short whether I open the source path or the target path:
$EDITOR src/0-all/dot-config/fish/config.fish
$EDITOR ~/.config/fish/config.fish
make verify
make apply-all
To change machines for one run:
make apply-all MACHINE=work
make apply-all MACHINE=private
The sync script first removes links from both machine packages, refreshes 0-all, and then adds only the selected package. Tests repeat that sequence in temporary home directories for both profiles, so switching can be verified without touching my real home.
The rules that keep it predictable
0-allis always stowed first.- Exactly one machine package is stowed second.
- A later profile may replace links owned by the shared package.
- Unmanaged target files remain conflicts.
- Stow links directly from
src/; there is no generated configuration tree. - Editing an existing target link changes its owning source file immediately.
- Public configuration may enter Git. Private keys may not.
- Both profile transitions are tested in isolated home directories.
The useful abstraction is not “merge these configurations.” It is “install the common package, then install one deliberate exception package.” The source tree shows every difference, the script shows the exact order, and Stow keeps the home directory linked back to the files I actually version.