dotctl is a CLI (plus optional tray apps) to sync dotfiles across machines using a private GitHub repository as the source of truth.
It is designed for:
- profile-aware dotfiles (
laptop,workstation,server, etc.) - safe sync with backups and rollback
- reproducible setup via
manifest.yaml
- Declarative sync from
manifest.yaml symlinkandcopyfile modes- Optional encrypted file deployment (
decrypt: truewithsopsorage) - Built-in secrets management (
dotctl secretswith age encryption) - Suggested manifest generation from common local config paths (
dotctl manifest suggest) - Pre/post sync hooks plus bootstrap hooks
- Multi-repo support (
dotctl repos ...) - Health checks (
dotctl doctor) - JSON output mode for scripting (
--json) - Commit/push using your Git identity and signing settings
- Optional tray apps:
- macOS status bar app
- Linux system tray app
| OS | Architectures | CLI | Tray |
|---|---|---|---|
| macOS | arm64, amd64 | Yes | Yes (Swift status bar app) |
| Linux | arm64, amd64 | Yes | Yes (AppIndicator tray app) |
git(required)ghCLI only if you use HTTPS GitHub repo URLs (not needed for SSH URLs)sopsorageonly if your manifest usesdecrypt: true
brew tap felipe-veas/homebrew-tap
brew install dotctlDownload the archive for your OS/arch from GitHub Releases, then extract and move dotctl into your PATH.
Example (macOS arm64):
curl -L -o dotctl.tar.gz https://github.com/felipe-veas/dotctl/releases/latest/download/dotctl_Darwin_arm64.tar.gz
tar -xzf dotctl.tar.gz
chmod +x dotctl
sudo mv dotctl /usr/local/bin/dotctlReleases also include .deb and .rpm packages.
Debian/Ubuntu:
sudo dpkg -i dotctl_<version>_linux_amd64.debFedora/RHEL:
sudo rpm -i dotctl_<version>_linux_amd64.rpmgit clone https://github.com/felipe-veas/dotctl.git
cd dotctl
make build
./bin/dotctl versionCurrent documentation:
- Getting Started
- Installation
- Manifest Specification
- Command Reference
- Sync Lifecycle
- Security Model
- Troubleshooting
- Roadmap
Recommended onboarding: initialize first, generate suggested manifest second, refine it, then sync.
Using SSH URL:
dotctl init --repo [email protected]:<you>/dotfiles.git --profile laptopUsing HTTPS URL (requires gh auth login):
dotctl init --repo https://github.com/<you>/dotfiles.git --profile laptopYou can also set a custom clone location:
dotctl init --repo <repo-url> --profile laptop --path /custom/pathThis step clones your dotfiles repository automatically. The repository can be empty for a first-time setup.
If the remote repository started empty, commit and push your initial content from the local clone after generating your manifest/files:
git -C ~/.config/dotctl/repo add .
git -C ~/.config/dotctl/repo commit -m "chore: bootstrap dotfiles manifest and config files"
git -C ~/.config/dotctl/repo push -u origin mainYou can also use:
dotctl push -m "chore: bootstrap dotfiles manifest and config files"to stage/commit/push from the active dotctl repository.
During init, dotctl also ensures recommended default ignore patterns in repo .gitignore:
.DS_Store
Thumbs.db
.env
.env.*
*.pem
*.key
*.p12
*.pfx
*.token
*credentials*
*secret*
!configs/secrets/
!configs/secrets/**
!configs/credentials/
!configs/credentials/**
configs/tmux/plugins/You can refine this list in your repo if your workflow needs different rules.
Scan common config paths (asks for confirmation first):
dotctl manifest suggestAfter running dotctl manifest suggest, use this workflow:
-
Review
manifest.suggested.yaml. -
Keep only files you want to manage across machines.
-
Add
when.profileandwhen.osfilters where behavior should differ by machine/OS. -
Use
mode: copyonly when symlink is not appropriate. -
Use
decrypt: truefor sensitive files and keep encrypted sources as.enc.*. -
Confirm detected files exist in the repo under the suggested
sourcepaths. -
If this is your first manifest and the suggested file looks good as-is, rename it:
mv manifest.suggested.yaml manifest.yaml
-
If you already have a
manifest.yaml, merge selected entries frommanifest.suggested.yamlinto the existing file. -
Commit and push those changes.
Typical repository structure after this step:
dotfiles/
manifest.yaml
configs/
zsh/.zshrc
git/.gitconfig
nvim/
If you prefer full manual control, create manifest.yaml directly:
version: 1
vars:
config_home: "~/.config"
files:
- source: configs/zsh/.zshrc
target: ~/.zshrc
- source: configs/git/.gitconfig
target: ~/.gitconfig
- source: configs/nvim
target: "{{ .config_home }}/nvim"
mode: copydotctl doctor
dotctl sync
dotctl statusdotctl sync flow is:
git pull --rebase- apply manifest actions
- run hooks
- commit and push (if there are changes)
If you already have a working dotfiles repo on machine A and want the same setup on machine B:
- On machine A, ensure everything is pushed:
dotctl status
dotctl push -m "sync latest dotfiles before onboarding machine B"- On machine B, install
dotctland run init with the same repository URL:
dotctl init --repo [email protected]:<you>/dotfiles.git --profile laptop- On machine B, apply the repo state:
dotctl doctor
dotctl syncNotes:
- You do not need to manually clone the repo first;
dotctl initclones it automatically. - If both machines should use identical rules, keep the same
--profile. - If a machine needs different rules, use another profile and
when.profileentries inmanifest.yaml. dotctl manifest suggestis mainly for bootstrapping a new manifest, not required when reusing an existing one.- If you use
dotctl secrets, copy~/.config/dotctl/age-identity.txtto machine B and rundotctl secrets init --import <path>.
| Command | Purpose |
|---|---|
dotctl sync |
Pull, apply manifest, push |
dotctl status |
Current state (repo/auth/symlinks) |
dotctl doctor |
Health checks (git/auth/manifest/symlinks/security) |
dotctl diff |
Show current drift/changes |
dotctl diff --details |
Include unified diff for changed files |
dotctl pull |
Pull latest changes only |
dotctl push |
Commit and push local repo changes |
dotctl push -m "msg" |
Push with custom commit message |
dotctl watch |
Auto-sync on repo file changes |
dotctl bootstrap |
Run bootstrap hooks |
dotctl open |
Open repo in browser |
dotctl repos list |
List configured repos |
dotctl repos add --name work --url ... |
Add another repo |
dotctl repos use work |
Switch active repo |
dotctl manifest suggest |
Scan common paths and write manifest.suggested.yaml |
dotctl secrets init |
Generate or import age encryption keys |
dotctl secrets encrypt <file> |
Encrypt a file for safe repo storage |
dotctl secrets decrypt <file> |
Decrypt a file (or --stdout to inspect) |
dotctl secrets status |
Show secrets protection status |
dotctl secrets rotate |
Rotate keys and re-encrypt all files |
Useful global flags:
--dry-run: show planned actions only--json: machine-readable output--verbose: enable detailed logs + git tracing--config <path>: use a specific config file--profile <name>: override active profile for this run--repo-name <name>: pick active repo for this run
dotctl manifest suggest scans common configuration paths from your machine and writes a reviewable draft file:
- default output:
<active-repo>/manifest.suggested.yaml - before scanning, dotctl asks for explicit confirmation (
[y/N]) - by default, it also copies detected local config files/directories into repo
sourcepaths - on
dotctl sync, if amanifest.yamlsourceis missing in the repo but its localtargetexists, dotctl backfills the repo source from that local target - on later
dotctl sync, sources previously managed by this flow are pruned from the repo if theirsourceentries were removed frommanifest.yaml - use
--forceto skip confirmation (useful for automation) - use
--dry-runto preview without writing files - use
--output <path>to customize output file location - use
--no-copy-sourcesto only generate the suggestion without copying files
Current scan candidates include:
- Home files:
.zshrc,.zprofile,.bashrc,.bash_profile,.profile,.gitconfig,.gitignore,.tmux.conf,.vimrc ~/.configentries:nvim,wezterm,kitty,alacritty,starship.toml,fish,gh,bat,tmux,helix,lazygit,ghostty
Example flow:
dotctl manifest suggest
# review manifest.suggested.yaml
# merge selected entries into manifest.yamlOther common usage:
# non-interactive
dotctl manifest suggest --force
# preview only
dotctl manifest suggest --dry-run --force
# generate suggestion only (no source copy)
dotctl manifest suggest --no-copy-sources --force
# custom output filename/path
dotctl manifest suggest --output manifest.suggested.work.yaml --forceJSON mode note:
dotctl manifest suggest --jsonrequires--forcebecause confirmation is interactive
Security note:
- the scan skips sensitive candidates such as
.env, SSH key paths, and key/cert suffix patterns
dotctl push uses your Git configuration for author/signing instead of forcing a dotctl author.
Recommended setup:
git config --global user.name "Your Name"
git config --global user.email "[email protected]"Or per-repository:
git -C /path/to/repo config user.name "Your Name"
git -C /path/to/repo config user.email "[email protected]"Initialize default repo first:
dotctl init --repo [email protected]:<you>/dotfiles.git --profile laptopAdd a second repo:
dotctl repos add --name work --url [email protected]:<you>/work-dotfiles.git --activateSwitch when needed:
dotctl repos use work
dotctl syncTop-level keys:
version: currently1vars: custom variables used in templated targetsfiles: list of managed entriesignore: source patterns to skiphooks:pre_sync,post_sync,bootstrap
Per-file fields:
source(required): path relative to repo roottarget(required): absolute path or templatemode:symlink(default) orcopywhen.os:darwin,linux, or listwhen.profile: profile name(s)decrypt: only valid withmode: copy, source filename must contain.enc.backup:true(default) orfalse
Available template vars in target:
homeosarchprofilehostname- plus your custom
vars
Hook commands run with /bin/sh -c from the repo directory.
Environment variables exposed to hooks:
DOTCTL_HOOK_PHASEDOTCTL_HOOK_REPO
Example:
hooks:
pre_sync:
- command: ./scripts/pre-sync.sh
post_sync:
- command: ./scripts/post-sync.sh
bootstrap:
- command: ./scripts/bootstrap.sh
when:
os: darwinFor encrypted sources in the repo:
- use
mode: copy - set
decrypt: true - ensure source filename includes
.enc.(for validation) - install
sopsoragein PATH
Example:
files:
- source: configs/secrets/api.enc.yaml
target: ~/.config/secrets/api.yaml
mode: copy
decrypt: truedotctl secrets provides built-in key generation, encryption, and rotation using age (X25519 + ChaCha20-Poly1305).
# Generate an age key pair
dotctl secrets init
# Encrypt a sensitive file
dotctl secrets encrypt configs/env/.env
# Add to manifest with decrypt: trueCopy ~/.config/dotctl/age-identity.txt to each machine, then import:
dotctl secrets init --import ~/path/to/age-identity.txt
dotctl sync# Inspect encrypted file without writing to disk
dotctl secrets decrypt configs/env/.env.enc --stdout
# Check what is protected and what is not
dotctl secrets status
# Rotate keys and re-encrypt everything
dotctl secrets rotatedotctl push will block if unencrypted sensitive files (.env, *.key, etc.) are tracked. Use --force to override, or encrypt first.
Defaults (when XDG vars are not set):
- Config file:
~/.config/dotctl/config.yaml - Cloned default repo:
~/.config/dotctl/repo - Backups:
~/.config/dotctl/backups- Snapshot layout:
~/.config/dotctl/backups/<timestamp>/targets/<target-path>
- Snapshot layout:
- Age identity (secrets):
~/.config/dotctl/age-identity.txt - Logs:
- Linux:
~/.local/state/dotctl/dotctl.log - macOS:
~/.config/dotctl/dotctl.log
- Linux:
- Sync lock: same state dir as log (
sync.lock)
- macOS DotCtl app instructions: mac/DotCtl/README.md
- Linux tray instructions: linux/tray/README.md
dotctl not initialized: rundotctl init --repo <url> --profile <name>gh not authenticated: rungh auth login --webrepository has uncommitted changes: commit/stash inside dotctl repo, then rundotctl syncagainconfigure git identity (user.name and user.email): set Git identity in repo or globally, then retrydotctl push- decrypt tool errors: install
sopsorageand confirm it is in PATH - inspect detailed logs with
--verboseand the log file path above