Shareable dotfiles with chezmoi and .local override system
  • Go Template 41.8%
  • Python 28.5%
  • Shell 28.1%
  • Lua 1.6%
Find a file
Joerg Ziefle 4c1622bbcb
Some checks failed
Test Bootstrap Script / End-to-End Bootstrap Test (Alpine) (pull_request) Failing after 33s
Simple Test / test (push) Failing after 2s
feat(ssh): enable ~C escape command line globally
2026-07-22 00:23:26 +02:00
.chezmoidata feat(packages): add ccstatusline to development category 2026-05-23 00:46:26 +00:00
.chezmoiscripts fix: NixOS robustness — uv fallback for pkg-merge; stop double-managing .claude/settings.json 2026-06-28 18:42:31 +00:00
.forgejo/workflows feat(ci): cache Docker image layers between runs 2026-05-21 11:54:32 +02:00
docs refactor: clean up root directory organization 2026-01-14 00:51:53 +01:00
dot_claude feat(tmux): auto-slug Claude window names, sticky across shell drops 2026-07-17 15:13:14 +02:00
dot_config feat(tmux): auto-slug Claude window names, sticky across shell drops 2026-07-17 15:13:14 +02:00
dot_local/bin feat: add tea-* wrappers for Forgejo CLI gaps 2026-04-28 11:52:45 +00:00
examples refactor: move personal content to .local submodule 2026-01-13 21:54:40 +01:00
Library/LaunchAgents fix(atuin): resolve daemon binary path and drop invalid 'start' arg 2026-06-30 10:29:58 +02:00
private_dot_gnupg feat: switch to terminal-based pinentry for OnlyKey 2026-01-16 00:23:53 +01:00
private_dot_ssh feat(ssh): enable ~C escape command line globally 2026-07-22 00:23:26 +02:00
test feat(ci): cache Docker image layers between runs 2026-05-21 11:54:32 +02:00
tools/dotfiles-bootstrap fix(bootstrap): use system Python in install-packages.sh; scrub uv venv 2026-05-22 23:43:36 +00:00
.chezmoi.toml.tmpl feat: add pre-hook staging for automatic .local overlay merge 2026-01-14 18:59:10 +01:00
.chezmoidata.yaml.tmpl fix: make .local package changes visible to chezmoi 2026-01-03 20:01:39 +01:00
.chezmoiexternal.toml.tmpl chore(externals): drop refreshPeriod from upstream archives 2026-05-21 19:17:18 +00:00
.chezmoiignore fix: NixOS robustness — uv fallback for pkg-merge; stop double-managing .claude/settings.json 2026-06-28 18:42:31 +00:00
.chezmoiremove feat: add .profile, .zshenv, .npmrc, atuin daemon unit; unify gitignore 2026-05-21 19:42:26 +00:00
.gitattributes chore: add gitattributes to enforce Unix line endings 2026-01-15 22:48:48 +01:00
.gitignore feat: add dedupe-secrets helper to ~/.local/bin via dotfiles 2026-04-28 13:24:07 +02:00
.gitleaks.toml feat: shareable dotfiles with .local override system 2026-01-02 00:21:14 +01:00
.pre-commit-config.yaml feat: shareable dotfiles with .local override system 2026-01-02 00:21:14 +01:00
.yamllint.yaml feat: shareable dotfiles with .local override system 2026-01-02 00:21:14 +01:00
bootstrap.sh feat(bootstrap): Python TUI orchestrator under tools/dotfiles-bootstrap 2026-05-22 22:40:34 +00:00
CLAUDE.md docs: add CLAUDE.md describing three-tier dotfiles layout 2026-04-28 11:56:53 +02:00
dot_gitconfig.tmpl feat(gitconfig): include ~/.gitconfig.local for host-specific overrides 2026-05-23 00:19:36 +00:00
dot_gitignore_global feat: add .profile, .zshenv, .npmrc, atuin daemon unit; unify gitignore 2026-05-21 19:42:26 +00:00
dot_npmrc.tmpl feat: add .profile, .zshenv, .npmrc, atuin daemon unit; unify gitignore 2026-05-21 19:42:26 +00:00
dot_profile.tmpl feat: add .profile, .zshenv, .npmrc, atuin daemon unit; unify gitignore 2026-05-21 19:42:26 +00:00
dot_zshenv.tmpl feat(zsh): fall back to C.UTF-8 when locale missing 2026-07-17 15:43:15 +02:00
dot_zshrc.tmpl fix(zsh): guard atuin init for hosts without the upstream installer 2026-06-29 22:30:25 +00:00
README.md feat: add deletion notification for claude-config sync 2026-01-15 20:13:34 +01:00
sync-staging.sh fix(sync): expose key.txt.age at staging root for decrypt script 2026-05-21 10:51:41 +02:00

Shareable Dotfiles with Chezmoi

A user-friendly, shareable dotfiles repository that keeps secrets private while enabling easy adoption by multiple users.

Architecture: .local Override System

Shared Repository (Git-tracked)
├── Base dotfiles & templates
├── Generic SSH/GPG configs
├── Base packages (13 essentials)
├── Optional packages (108 packages in 7 categories)
└── Examples & documentation
              ↓
        .local/ Directory (Gitignored, User-specific)
        ├── .chezmoi.toml.local          # Your age keys, email
        ├── .chezmoidata.local/          # Package selection
        │   └── packages.yaml
        ├── secrets/                     # Encrypted SSH/GPG keys
        │   ├── ssh/
        │   ├── gnupg/
        │   └── zsh/
        ├── private_dot_ssh/             # Your SSH key templates
        └── private_dot_gnupg/           # Your GPG key templates
              ↓
        Merged at Runtime by Chezmoi

Key Principle: Shared repo provides the framework, .local/ provides your personal secrets and customizations.

Personal Configuration via .local

What Goes in .local?

The .local directory (or submodule) contains ALL personal and machine-specific data:

Core Configuration

  • .chezmoi.toml.local - Your age keys, email, git config, personal URLs
  • .chezmoidata.local/packages.yaml - Package selections for this machine

SSH Configuration

  • private_dot_ssh/config.local.tmpl - Your personal SSH hosts (NAS, servers, devices)
  • private_dot_ssh/*.pub.tmpl - SSH public keys (contain your email)
  • private_dot_ssh/private_authorized_keys.tmpl - Keys for remote access
  • secrets/ssh/ - Encrypted SSH private keys

Public repo keeps: GitHub config, global SSH defaults

GPG Configuration

  • private_dot_gnupg/publickey.*.asc - Your GPG public keys
  • private_dot_gnupg/pubring.kbx - Public keyring
  • private_dot_gnupg/private_trustdb.gpg - Trust database
  • private_dot_gnupg/private_openpgp-revocs.d/ - Revocation certificates
  • private_dot_gnupg/sshcontrol - SSH authentication config
  • private_dot_gnupg/private_onlykey/ - OnlyKey hardware security config
  • secrets/gnupg/ - Encrypted GPG private keys

Public repo keeps: Generic gpg.conf, gpg-agent.conf.tmpl

Password Store

  • private_dot_password-store/ - Entire password store (encrypted passwords)

Even though pass data is GPG-encrypted, directory names reveal service accounts, so it belongs in .local.

Work Configuration

  • dot_gitconfig-sbt - Work-specific git config (email, signing key)

Template Integration

The public repo includes your .local config via templates:

SSH Config (private_dot_ssh/config.tmpl):

{{- $localSSHConfig := joinPath .chezmoi.sourceDir ".local/private_dot_ssh/config.local.tmpl" -}}
{{- if stat $localSSHConfig -}}
{{ includeTemplate ".local/private_dot_ssh/config.local.tmpl" . -}}
{{- end }}

URLs and Domains (from .chezmoi.toml.local):

atuin_sync_url = "https://atuin.yourdomain.com"
claude_config_repo = "https://git.yourdomain.com/you/claude-config.git"

Setup Examples

Comprehensive guides are provided in the examples/ directory:

  • examples/ssh/ - SSH config structure, public key management
  • examples/gnupg/ - GPG setup, OnlyKey integration, agent forwarding
  • examples/password-store/ - Password store setup, organization, best practices

Security Benefits

This split architecture ensures:

Public repo is shareable - No personal hostnames, IPs, emails, or network topology Private data stays private - All PII in .local submodule Easy adoption - Others can fork and customize without seeing your data No accidental leaks - Template system prevents mixing public/private Version control for secrets - .local can be a private git submodule

Quick Start

# Download and run bootstrap script (recommended)
bash <(curl -fsSL https://github.com/YOUR_USERNAME/dotfiles/raw/branch/master/bootstrap.sh)

# Or clone first, then run
git clone https://github.com/YOUR_USERNAME/dotfiles.git ~/dotfiles-temp
bash ~/dotfiles-temp/bootstrap.sh

This will:

  • Install prerequisites (age, chezmoi, Homebrew)
  • Generate your age keypair
  • Initialize chezmoi
  • Let you select package categories
  • Apply dotfiles

Option 2: Manual Setup

If you prefer manual control:

# 1. Install chezmoi
sh -c "$(curl -fsLS get.chezmoi.io/lb)"

# 2. Initialize from repository (fork and use your own URL)
chezmoi init https://github.com/YOUR_USERNAME/dotfiles.git

# 3. Generate age encryption key
mkdir -p ~/.config/chezmoi
age-keygen > ~/.config/chezmoi/key.txt
chmod 600 ~/.config/chezmoi/key.txt

# 4. Configure .local settings
cd ~/.local/share/chezmoi
mkdir -p .local
cat > .local/.chezmoi.toml.local <<EOF
email = "your-email@example.com"
work = false

[age]
    identity = "$HOME/.config/chezmoi/key.txt"
    recipient = "age1..." # from age-keygen output above
EOF

# 5. Select package categories (see below)
mkdir -p .local/.chezmoidata.local
cat > .local/.chezmoidata.local/packages.yaml <<EOF
enabledCategories:
  - 'development'
  - 'cli-tools'

customPackages:
  darwin:
    brews: []
    casks: []
  debian: []
EOF

# 6. Apply dotfiles
chezmoi apply

Package Categories

Base Packages (Installed for Everyone)

13 essential packages:

  • age, chezmoi, coreutils, git, git-delta
  • gnupg, jq, neovim, pinentry, pinentry-mac, zsh
  • iterm2 (cask), tailscale-app (cask)

Optional Categories (User Choice)

Enable categories in .local/.chezmoidata.local/packages.yaml:

1. development (24 packages)

Python, Node.js, Docker, development tools

  • Brews: direnv, node, python@3.13, pipx, pre-commit, docker
  • Casks: docker-desktop
  • NPM: @anthropic-ai/claude-code
  • UV: uv (Python package manager)

2. cli-tools (33 packages)

Modern CLI replacements and productivity tools

  • Brews: bat, bottom, eza, fd, fzf, ripgrep, starship, tmux, zoxide, atuin, lazygit, tldr, dust, duf, procs, sd, tokei, hyperfine, xh, yq, jo, glow, fx
  • Debian: bat, fd-find, fzf, ripgrep, tmux, zoxide, tldr

3. security (17 packages)

Password managers, VPN, security tools

  • Brews: bitwarden-cli, pass, wireguard-go, wireguard-tools, age, gnupg, pinentry
  • Casks: keepassxc, veracrypt, tor-browser
  • UV: onlykey-agent

4. network (5 packages)

Network utilities and monitoring

  • Brews: bandwhich, doggo, gping, mosh
  • Debian: mosh

5. backup (4 packages)

Backup and archival tools

  • Brews: restic, borgbackup
  • UV: borgbackup, borgmatic

6. desktop-apps (24 packages)

GUI applications for macOS

  • Casks: alfred, firefox, rectangle, signal, spotify, vlc, discord, slack, obsidian, visual-studio-code, 1password

7. work (5 packages)

Work-specific applications

  • Casks: citrix-workspace, microsoft-teams, zoom, microsoft-office

8. data-science (2 packages)

Data processing tools

  • Brews: csvkit
  • UV: fowl

Selecting Packages

Edit .local/.chezmoidata.local/packages.yaml:

# Enable all categories
enabledCategories:
  - 'development'
  - 'cli-tools'
  - 'security'
  - 'network'
  - 'backup'
  - 'desktop-apps'
  - 'work'
  - 'data-science'

# Add your own packages
customPackages:
  darwin:
    brews:
      - 'your-package'
    casks:
      - 'your-app'
  debian:
    - 'your-debian-package'
  npm:
    - 'your-npm-package'
  uv:
    - 'your-python-package'

Adding Your Secrets

SSH Keys

See examples/ssh/README.md for detailed instructions.

Quick version:

cd ~/.local/share/chezmoi

# 1. Get your age recipient
AGE_RECIPIENT=$(grep recipient .local/.chezmoi.toml.local | awk '{print $3}' | tr -d '"')

# 2. Encrypt your SSH key
mkdir -p .local/secrets/ssh
age -e -r "$AGE_RECIPIENT" \
    -o .local/secrets/ssh/__ignore__encrypted_private_mykey.age \
    < ~/.ssh/your_private_key

# 3. Create template
mkdir -p .local/private_dot_ssh
cat > .local/private_dot_ssh/private_mykey.tmpl <<'EOF'
{{- $localMachines := .localMachines | default list -}}
{{- if has .chezmoi.hostname $localMachines -}}
{{- if not .work -}}
{{-   $localKeyPath := joinPath .chezmoi.sourceDir ".local/secrets/ssh/__ignore__encrypted_private_mykey.age" -}}
{{-   if stat $localKeyPath -}}
{{-     include $localKeyPath | decrypt -}}
{{-   else -}}
{{-     writeToStdout "WARNING: No mykey SSH key found\n" -}}
{{-   end -}}
{{- end -}}
{{- end -}}
EOF

# 4. Apply
chezmoi apply ~/.ssh/mykey

GPG Keys

See examples/gnupg/README.md for detailed instructions.

Quick version:

# 1. Export your GPG key
gpg --export-secret-keys --armor KEYID > /tmp/gpg_key.asc

# 2. Encrypt it
AGE_RECIPIENT=$(grep recipient .local/.chezmoi.toml.local | awk '{print $3}' | tr -d '"')
mkdir -p .local/secrets/gnupg
age -e -r "$AGE_RECIPIENT" \
    -o .local/secrets/gnupg/__ignore__encrypted_private_KEYID.age \
    < /tmp/gpg_key.asc

shred -u /tmp/gpg_key.asc

# 3. Import on apply (see examples/gnupg/README.md for import script)

Shell Secrets (API Keys, Tokens)

Create .local/secrets/zsh/encrypted_secrets.zsh.age:

# Create plaintext secrets file
cat > /tmp/secrets.zsh <<'EOF'
export ANTHROPIC_API_KEY="sk-ant-..."
export GITHUB_TOKEN="ghp_..."
export OPENAI_API_KEY="sk-..."
EOF

# Encrypt it
AGE_RECIPIENT=$(grep recipient .local/.chezmoi.toml.local | awk '{print $3}' | tr -d '"')
mkdir -p .local/secrets/zsh
age -e -r "$AGE_RECIPIENT" \
    -o .local/secrets/zsh/encrypted_secrets.zsh.age \
    < /tmp/secrets.zsh

shred -u /tmp/secrets.zsh

# It will be automatically sourced by ~/.config/zsh/secrets.zsh

Storing and Syncing .local

The .local directory contains your personal configuration and secrets. You have several options for storing and syncing it:

Option 1: Local Only (Default)

.local stays only on your machine (gitignored). Simple and secure, but no backup or sync.

Turn .local into a private git repository for version control, backup, and multi-machine sync.

Quick setup:

# 1. Create a PRIVATE repo on your git server (e.g., dotfiles-local)
# 2. Run the helper script
cd ~/.local/share/chezmoi
bash .scripts/setup-local-submodule.sh

Benefits:

  • Version controlled
  • Backed up to private git server
  • Sync across machines
  • Use branches for different machine configs

Cloning on new machines:

chezmoi init --recurse-submodules https://github.com/YOUR_USERNAME/dotfiles.git

Option 3: Cloud Storage

Symlink .local to Dropbox/iCloud for automatic sync (no version control).

Option 4: Encrypted Backup

Use restic, borg, or tarsnap for encrypted backups.

Full documentation: See .local-storage.md for detailed comparison and setup instructions.

Claude Config Sync

If you maintain a separate repository for Claude Code configuration (rules, skills, etc.), this setup can automatically sync it to ~/.claude.

Setup

Add the repository URL to .local/.chezmoi.toml.local:

claude_config_repo = "https://git.yourdomain.com/you/claude-config.git"

The sync script (.chezmoiscripts/run_onchange_after_sync-claude-config.sh.tmpl) will:

  • Clone your claude-config repository
  • Sync to ~/.claude (excluding runtime files)
  • Auto-detect upstream changes using git ls-remote
  • Notify you about deleted files without automatically removing them

Deletion Notification

When files are removed from the upstream claude-config repository, the sync script will notify you but will NOT automatically delete them locally.

If you see deletion notifications:

  1. Review the list displayed during chezmoi apply
  2. Check ~/.claude/.sync-deletions.txt for details
  3. Manually delete if appropriate: cd ~/.claude && rm -rf <path>
  4. Delete the notification file to acknowledge: rm ~/.claude/.sync-deletions.txt

Note: Runtime files (history.jsonl, debug/, plans/, projects/, etc.) are never synced or reported as deletions.

What Gets Synced

Synced:

  • Claude Code rules (rules/)
  • Skills and configurations
  • MCP server configs
  • CLAUDE.md navigation files
  • settings.json (from .local/dot_claude/settings.json if it exists)

Excluded (never synced):

  • .git/, docs/, upstream/
  • Runtime files: history.jsonl, stats-cache.json, debug/, file-history/, shell-snapshots/
  • User data: plans/, projects/, todos/, session-env/
  • Metadata: README.md, LICENSE, .gitignore

Customization

Machine-Specific Settings

Edit .local/.chezmoi.toml.local:

email = "your-email@example.com"
work = false  # Set to true on work machines

[age]
    identity = "/Users/you/.config/chezmoi/key.txt"
    recipient = "age1..."

# Optional: Override localMachines list
# localMachines = ["my-laptop", "my-desktop"]

Conditional Deployment

Templates support conditionals based on:

  • {{ .chezmoi.hostname }} - Machine hostname
  • {{ .work }} - Work machine flag
  • {{ .localMachines }} - List of local machines (vs forwarded)
  • {{ .osid }} - OS identifier (darwin, debian, etc.)

Example: Deploy key only on specific machine:

{{- if eq .chezmoi.hostname "my-laptop" -}}
{{-   $localKeyPath := joinPath .chezmoi.sourceDir ".local/secrets/ssh/__ignore__encrypted_private_laptop_key.age" -}}
{{-   if stat $localKeyPath -}}
{{-     include $localKeyPath | decrypt -}}
{{-   end -}}
{{- end -}}

Repository Structure

├── .chezmoi.toml.tmpl          # Main config (loads .local overrides)
├── .chezmoiignore              # Selective ignore (.local secrets)
├── .chezmoidata/
│   ├── packages.yaml           # Base packages (13 essentials)
│   └── packages.optional.yaml  # Optional categories (108 packages)
├── .chezmoiscripts/
│   └── run_onchange_install-packages.sh.tmpl
├── bootstrap.sh                # One-command setup
├── dot_config/                 # Generic configs
│   ├── git/
│   ├── nvim/
│   ├── tmux/
│   └── zsh/
├── private_dot_ssh/
│   ├── config.tmpl             # Generic SSH config
│   ├── authorized_keys.tmpl
│   └── *.pub.tmpl              # Public keys
├── private_dot_gnupg/
│   ├── gpg-agent.conf.tmpl     # Generic GPG config
│   └── gpg.conf.tmpl
├── examples/
│   ├── ssh/README.md           # How to add SSH keys
│   ├── ssh/private_example.tmpl
│   └── gnupg/README.md         # How to add GPG keys
└── .local/ (gitignored)
    ├── .chezmoi.toml.local     # Your age keys, email
    ├── .chezmoidata.local/
    │   └── packages.yaml       # Your package selection
    ├── secrets/
    │   ├── ssh/                # Encrypted SSH keys
    │   ├── gnupg/              # Encrypted GPG keys
    │   └── zsh/                # Encrypted shell secrets
    ├── private_dot_ssh/        # Your SSH key templates
    └── private_dot_gnupg/      # Your GPG key templates

Security

Encryption

  • All secrets encrypted with age (modern, secure)
  • Each user generates their own keypair
  • Private key stored at ~/.config/chezmoi/key.txt (600 permissions)
  • BACKUP YOUR AGE KEY - if you lose it, your secrets are unrecoverable

What's Safe to Commit

Git-tracked (safe to share):

  • All templates and configs
  • Generic SSH/GPG configurations
  • Package definitions
  • Public keys
  • Documentation

Gitignored (private):

  • .local/ directory (entire directory)
  • Age private keys
  • Encrypted secrets (in .local/secrets/)
  • User-specific templates (in .local/private_dot_*/)

Best Practices

  1. Backup your age key to a password manager (1Password, Bitwarden)
  2. Use different age keys on different machines if needed (update .local/.chezmoi.toml.local)
  3. Never commit plaintext secrets - always encrypt with age first
  4. Verify .local/ is gitignored before committing
  5. Use __ignore__ prefix for encrypted files to ensure they're never applied directly

Philosophy

Why .local Override System?

Single source of truth: Everyone uses the same repo, customizations are layered on top

No duplication: Base configs, scripts, and templates maintained in one place

Privacy by design: Secrets never enter git, gitignore prevents accidents

User-friendly: One command to bootstrap, clear examples for customization

Flexible: Choose which packages to install, which secrets to deploy

Why Package Categories?

Not everyone needs 100+ packages. Categories let users pick what they need:

  • Minimal laptop: base only (13 packages)
  • Developer workstation: base + development + cli-tools (70 packages)
  • Full setup: all categories (121 packages)

Why age encryption?

  • Modern: Designed for file encryption (vs gpg's email focus)
  • Simple: One command to encrypt, one to decrypt
  • Secure: Strong defaults, hard to misuse
  • Fast: Efficient for many files

Troubleshooting

"No age encryption configuration found"

Cause: Missing .local/.chezmoi.toml.local

Fix:

cd ~/.local/share/chezmoi
mkdir -p .local
age-keygen > ~/.config/chezmoi/key.txt
# Copy the public key (age1...) from output
cat > .local/.chezmoi.toml.local <<EOF
email = "your-email@example.com"
work = false

[age]
    identity = "$HOME/.config/chezmoi/key.txt"
    recipient = "age1YOUR_PUBLIC_KEY"
EOF

"WARNING: No [keyname] SSH key found"

Cause: Template exists but encrypted key file doesn't

Fix: Encrypt your SSH key and place it in .local/secrets/ssh/

age -e -r "YOUR_AGE_RECIPIENT" \
    -o .local/secrets/ssh/__ignore__encrypted_private_keyname.age \
    < ~/.ssh/your_key

"chezmoi apply" shows unexpected changes

Cause: Package categories changed or templates updated

Fix: Review with chezmoi diff before applying

chezmoi diff

Homebrew package installation fails

Cause: Package name changed or no longer available

Fix: Update .local/.chezmoidata.local/packages.yaml to remove or replace the package

Contributing

Have improvements? Found a bug? Want to add examples?

  1. Fork the repository
  2. Create a feature branch
  3. Make your changes
  4. Test on a fresh machine
  5. Submit a pull request

License

MIT License - feel free to use and modify

Credits

Built with chezmoi by Tom Payne