Your dotfiles belong in git. Your secrets don't.
dotf is a single Rust binary that manages dotfiles with template rendering and pluggable secret injection. Templates go in git, secret values stay in your password manager. Works at two scales: global dotfiles (~/.dotf) synced across machines, and project-local configs (.env, .claude/settings.json) auto-detected from the current working directory.
brew tap chrisfentiman/dotf && brew install dotfYou built a good shell setup. You want to version it, share it, clone it on a new laptop in 30 seconds. So you push it to GitHub -- then you grep your configs and find your email in .gitconfig, an API token in .npmrc, a registry credential in .cargo/config.toml.
The usual fix is a .localrc or .bash_profile_priv -- a file you source but never commit. It works on one machine. On a new machine you spend an hour with your old laptop open next to it, manually copying values. Nothing documents what secrets are needed or where they came from.
The same problem exists at the project level. Your .env has database credentials, your .claude/settings.json has API keys, your docker-compose.override.yml has registry tokens. You can't commit them, so every new contributor gets a Slack message: "ask Sarah for the env file." There's no schema, no validation, no way to know if your .env is stale.
Existing dotfiles tools solve one piece but not the whole problem:
| Tool | Templates | Secrets | Symlinks | Project-local | The catch |
|---|---|---|---|---|---|
| GNU Stow | -- | -- | Symlink farm | -- | No templating or secrets. Machine-specific configs require external scripts. |
| yadm | Minimal | Git-crypt (whole-file) | -- | -- | Alternate files are full copies per machine, not variable substitution. No runtime secret injection. |
| dotbot | -- | -- | YAML-driven | -- | Just a symlink + shell runner. Requires Python runtime. |
| rcm | -- | -- | Tag-based | -- | No templates, no secrets, no encryption. Unix only, low activity. |
| chezmoi | Go text/template |
GPG/age + PM integrations | -- (copies) | -- | Secrets embedded in template syntax. Steep learning curve. |
| home-manager | Nix expressions | agenix/sops-nix | Nix-managed | -- | Requires learning Nix. Overkill for config files. |
| dotf | {{PLACEHOLDER}} |
Declarative .secrets.toml |
.symlinks.toml |
Auto-detected | -- |
dotf fixes this by making the secrets part of the repo -- not their values, their locations. Every secret becomes a placeholder that maps to a URI in your password manager. At sync time, dotf fetches and injects them. Git only ever sees the template.
You have a .gitconfig with your email in it. You want the file in git. You don't want your email in git.
# ~/.dotf/configs/.gitconfig.tmpl <-- committed to git
[user]
name = Chris Fentiman
email = {{GIT_EMAIL}}
[github]
token = {{GITHUB_TOKEN}}
# ~/.dotf/.secrets.toml <-- committed to git (URIs only, never values)
[secrets]
GIT_EMAIL = "op://personal/github/email"
GITHUB_TOKEN = "op://personal/github/token"When you run dotf sync:
- Fetches
op://personal/github/emailfrom 1Password - Renders the template with the real values
- Writes
~/.dotf/configs/.gitconfig(gitignored,0o600permissions) - Symlinks
~/.gitconfigto the rendered file - Commits and pushes the dotfiles repo
The secret never touches git. The mapping does -- so on a new machine, dotf init knows exactly what to fetch.
brew tap chrisfentiman/dotf
brew install dotfcargo install --git https://github.com/chrisfentiman/dot.gitDownload from the releases page. Each release includes binaries for macOS (ARM/x86) and Linux (ARM/x86) with SHA256 checksums.
# New machine -- clone your dotfiles repo and render everything
dotf init
# Add a config file to be managed
dotf config ~/.gitconfig
# dotf shows the file, you mark the secret values,
# it replaces them with {{PLACEHOLDERS}} and asks for the URI
# Check what's managed and what's broken
dotf status
# Render all templates and sync to git
dotf syncdotf routes secrets by URI scheme. Use whichever password manager you already have. You can mix backends in the same .secrets.toml.
| URI scheme | Password manager | CLI |
|---|---|---|
pass://vault/item/field |
Proton Pass | pass |
op://vault/item/field |
1Password | op |
bw://item-name/field |
Bitwarden | bw |
env://VAR_NAME |
Environment variable | -- |
Backends are pluggable -- adding a new one is a single match arm in src/secret.rs.
| Command | Description |
|---|---|
dotf init [path] |
Clone dotfiles repo, check CLIs, install completions, render all templates |
dotf config <path> |
Add a config file -- interactively extract secrets into {{PLACEHOLDERS}} |
dotf modify [name] |
Edit a template in $EDITOR, re-render on save |
dotf sync |
git pull --rebase, render all templates, commit and push |
dotf diff [name] |
Preview what sync would change, without writing anything |
dotf status |
Health check -- which configs are ok, missing, or broken |
dotf remove [name] |
Stop managing a config, optionally restore the file in place |
dotf secrets list |
Show all placeholder-to-URI mappings with backend column |
dotf secrets validate |
Test that every secret can actually be fetched |
dotf secrets add <n> <uri> |
Add a secret mapping |
dotf secrets remove <name> |
Remove a secret mapping |
dotf completions <shell> |
Print shell completions (bash, zsh, fish) |
dotf uses git-style auto-detection to find the right context. When you run any command, dotf walks up from the current working directory looking for a .dotf/ directory. If it finds one, it operates in project-local mode scoped to that project. If none is found, it falls back to the global ~/.dotf/ directory. No flag needed.
cd ~/Development/myproject
dotf init . # creates .dotf/ directory in current dir
dotf config .env # template + secrets for .env (auto-detected from cwd)
dotf sync # render only, no git operations (auto-detected from cwd)The .env.tmpl template and .secrets.toml are committed to your project repo. The rendered .env (with real values) is gitignored. New contributors clone the repo, run dotf sync from inside the project, and get a working .env without Slack messages or shared password docs.
myproject/
.dotf/
configs/
.env.tmpl <-- committed
.env <-- rendered, gitignored
.secrets.toml <-- committed (URIs only)
.symlinks.toml <-- committed
.env -> .dotf/configs/.env <-- symlink to rendered file
Key differences from global mode:
- No git operations in
sync-- your project repo handles its own git workflow - Symlink targets are relative to the project root, not
$HOME - Absolute paths and
~paths in symlink targets are rejected (security boundary is the project root) .dotf/configs/*(except.tmpl) and.dotf/.secrets.tomlare added to.gitignoreautomatically
This is useful for any project config that has secrets: .env, .claude/settings.json, docker-compose.override.yml, .cargo/config.toml, CI credential files.
~/.dotf/
configs/
.gitconfig.tmpl <-- template, committed
.gitconfig <-- rendered output, gitignored
.zshrc.tmpl
.zshrc
.secrets.toml <-- placeholder -> URI map, committed
.symlinks.toml <-- name -> target path map, committed
.gitignore <-- ignores rendered outputs
Brewfile <-- optional, run by dotf init
~/.gitconfig is a symlink to ~/.dotf/configs/.gitconfig, which is rendered from .gitconfig.tmpl at sync time.
myproject/
.dotf/
configs/
.env.tmpl <-- template, committed
.env <-- rendered, gitignored
.secrets.toml <-- placeholder -> URI map, committed
.symlinks.toml <-- name -> target path map, committed
.env <-- symlink to .dotf/configs/.env
See SECURITY.md for the full threat model. Key properties:
- Secrets never enter git. Templates use
{{PLACEHOLDER}}syntax. Rendered outputs are gitignored. - Rendered files are
0o600. Owner read/write only. - Subprocess isolation. All child processes run with
env_clear()and an explicit allowlist. No ambient secrets leak. - Memory safety. Secret values use
Zeroizing<String>and are zeroed on drop. - Path traversal protection. Symlink targets are canonicalized and verified inside
$HOME. Paths with..are rejected. - Atomic writes. All file writes use tempfile-then-rename. No partial writes on crash.
- TOML injection prevention. Config types use
#[serde(deny_unknown_fields)].
chezmoi is the most feature-complete dotfiles manager available. If you need OS-conditional logic, run-once scripts, or external file fetching, use chezmoi.
dotf is for the common case: config files with some secrets that you want in git without the secrets leaking.
| chezmoi | dotf | |
|---|---|---|
| Template syntax | Go text/template + Sprig: {{ if eq .chezmoi.os "darwin" }} |
{{PLACEHOLDER}} -- nothing to learn |
| Secrets in templates | Embedded: {{ (bitwarden "item").password }} |
Separated: template says {{DB_PASS}}, .secrets.toml maps it to bw://db/password |
| Switch password managers | Edit every template that references the old backend | Change one line in .secrets.toml |
| File management | Copies files from source to target | Symlinks to rendered files |
| Project-local configs | Global only (~/.local/share/chezmoi) |
Auto-detected per-project .env, .claude/settings.json, etc. |
| Secret auditing | Manual | dotf secrets list and dotf secrets validate |
| Concepts to learn | Source state, target state, filename attributes (dot_, private_, run_once_, modify_) |
Two TOML files and {{PLACEHOLDER}} syntax |
| Runtime | Go binary | Rust binary |
Issues and PRs welcome at github.com/chrisfentiman/dot.
cargo test # run tests
cargo clippy # lint
cargo fmt --check # formatting