A modular, cross-platform dotfile manager. Define your entire system setup — packages, files, binaries, and scripts — in a single dotular.yaml, then apply it on any machine.
Most dotfile managers only manage files. You still need a separate bootstrap script to install packages, download binaries, configure OS settings, and glue everything together. That script inevitably becomes a fragile, untested mess of if statements for each platform.
dotular replaces all of that with a single declarative YAML file.
| dotular | chezmoi | GNU Stow | yadm | mackup | |
|---|---|---|---|---|---|
| Manages files | Yes | Yes | Yes | Yes | Yes |
| Installs packages | Yes — brew, apt, winget, and 10+ managers | No | No | No | No |
| Downloads binaries | Yes — archives, extraction, versioning | No | No | No | No |
| Runs scripts | Yes — local and remote, with skip/verify | Templates only | No | Bootstrap only | No |
| OS settings | Yes — macOS defaults, extensible |
No | No | No | No |
| Cross-platform config | One file, per-OS paths and packages | Separate templates | Symlinks only | Git + encryption | macOS only |
| Atomicity | Snapshot + rollback per module | No | No | No | No |
| No templating language | Plain YAML — no Go templates to learn | Go text/template |
N/A | Jinja2 (alt) | N/A |
| Shareable modules | Registry with parameters and overrides | Community scripts | No | No | No |
| Audit log | Built-in, append-only JSON | No | No | No | No |
A "module" in dotular groups everything a tool needs — the package install, its config files, post-install scripts, binary downloads, and OS settings — into one unit. Apply a single module to fully set up one tool. Apply all modules to bootstrap an entire machine.
- name: Neovim
items:
- binary: nvim # download the binary
source:
macos: https://...nvim-macos.tar.gz
linux: https://...nvim-linux.tar.gz
install_to: ~/.local/bin
- directory: nvim # push config files
destination: ~/.config
- run: nvim --headless "+Lazy sync" +qa # install pluginsNo bootstrap script. No platform if-statements. One file, any machine.
- Modules — group related items; apply one or all
- Cross-platform — macOS, Linux, and Windows; per-OS package managers and destinations
- File direction —
push(repo→system),pull(system→repo), orsync(bidirectional with conflict prompt) - Symlinks —
link: truecreates a symlink instead of copying - Idempotency — skips already-applied packages and symlinks automatically
- Hooks — shell commands before/after module or file item
- Verification — health-check commands per item (
verify:) - Encrypted secrets —
age-encrypted files, decrypted on apply - File permissions — enforce
chmod-style permissions on pushed files - Atomic applies — snapshot files before each module; roll back on failure
- Machine tagging —
only_tags/exclude_tagsper module - Audit log — append-only log of every action taken
- Registry — reusable remote modules with parameters and overrides
skip_if— skip an item when a shell condition exits zero
Requires Go 1.22+.
git clone https://github.com/atomikpanda/dotular
cd dotular
go build -o dotular ./cmd/dotularOr install directly:
go install github.com/atomikpanda/dotular/cmd/dotular@latestdotular apply # apply all modules
dotular apply homebrew # apply a single module
dotular status # dry-run with verbose output
dotular list # list all modulesdotular.yaml (or pass --config path/to/file.yaml):
# Optional: age encryption key
age:
identity: ~/.config/dotular/identity.txt # age identity file
# passphrase: env:MY_AGE_PASSPHRASE # or passphrase (supports env: prefix)
modules:
- name: My Module
only_tags: [darwin] # optional: only run on matching machines
exclude_tags: [work] # optional: skip on matching machines
hooks:
before_apply: echo "starting"
after_apply: echo "done"
before_sync: echo "syncing"
after_sync: echo "synced"
items:
- ...- package: ripgrep
via: brew # brew | brew-cask | apt | dnf | pacman | snap | winget | choco | scoop
skip_if: command -v rg
verify: rg --versionSupported package managers and their platforms:
via |
Platform |
|---|---|
brew |
macOS |
brew-cask |
macOS |
apt |
Linux |
dnf |
Linux |
pacman |
Linux |
snap |
Linux |
winget |
Windows |
choco |
Windows |
scoop |
Windows |
Package items are idempotent — dotular checks whether the package is already installed before running the install command.
- script: https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh
via: remote # remote | local (default: local)
skip_if: command -v brew
verify: brew --versionvia: remote downloads the script to a temp file and runs it. via: local runs the path as a local script.
- file: settings.json
direction: sync # push | pull | sync (default: push)
link: false # true to create a symlink instead of copying
permissions: "0600" # optional chmod
encrypted: false # true if the repo copy is .age-encrypted
destination:
macos: ~/Library/Application Support/Code/User
windows: '%APPDATA%\Code\User'
linux: ~/.config/Code/User
hooks:
before_sync: echo "about to sync"
after_sync: echo "sync complete"
verify: test -f ~/Library/Application\ Support/Code/User/settings.jsondestination accepts either a plain string (all platforms) or a per-OS mapping.
- directory: nvim
direction: push
destination: ~/.config
link: falsesync direction: pushes if only the repo copy exists, pulls if only the system copy exists, pushes if both exist. For per-file conflict resolution use individual file items.
- binary: nvim
version: "0.10.2"
source:
macos: https://github.com/neovim/neovim/releases/download/v0.10.2/nvim-macos-arm64.tar.gz
linux: https://github.com/neovim/neovim/releases/download/v0.10.2/nvim-linux-x86_64.tar.gz
install_to: ~/.local/bin
skip_if: test -f ~/.local/bin/nvim
verify: nvim --versionDownloads the archive (.tar.gz, .tgz, .zip, or plain binary), extracts the matching binary by name, and installs it with chmod 755.
- run: nvim --headless "+Lazy sync" +qa
after: directory # informational only — ordering follows declaration order- setting: com.apple.dock
key: autohide
value: true # bool | int | float | string| Field | Description |
|---|---|
skip_if |
Shell command — skip this item if it exits zero |
verify |
Shell command — run after apply and on dotular verify; fails the item if non-zero |
hooks |
before_apply, after_apply, before_sync, after_sync |
dotular apply [module...]
dotular apply --dry-run
dotular apply --no-atomicApply all modules (or specified ones). Runs hooks, checks idempotency, handles rollback on failure.
dotular push [module...]
dotular pull [module...]
dotular sync [module...]Override the direction on all file and directory items for the run. Link items (link: true) are never overridden.
dotular verify [module...]Run all verify: commands without modifying anything. Exits 1 if any check fails.
dotular statusDry-run with verbose output — shows what would be applied.
dotular listPrint all modules and their item counts.
dotular platformPrint the detected OS (darwin / linux / windows).
dotular encrypt secrets/file.txt # writes secrets/file.txt.age
dotular decrypt secrets/file.txt.age # writes secrets/file.txtRequires age.identity or age.passphrase in config, or DOTULAR_AGE_IDENTITY / DOTULAR_AGE_PASSPHRASE env vars.
dotular tag list
dotular tag add workManage machine tags stored in ~/.config/dotular/machine.yaml. Tags auto-detected on first run include OS, architecture, and hostname.
dotular log
dotular log --module homebrew
dotular log --limit 20Show the audit log at ~/.local/share/dotular/history.log.
dotular registry list # show cached registry modules
dotular registry clear # remove all cached modules
dotular registry update # re-fetch all modules from the network| Flag | Description |
|---|---|
--config |
Path to config file (default dotular.yaml) |
--dry-run |
Print actions without executing |
--verbose |
Show skipped items and extra output |
--no-atomic |
Disable snapshot/rollback per module |
--no-cache |
Re-fetch registry modules from the network |
Add tags to a machine to control which modules run on it:
dotular tag add work
dotular tag add desktopThen in your config:
- name: Work Tools
only_tags: [work]
items:
- package: slack
via: brew-cask
- name: Gaming
exclude_tags: [work]
items:
- package: steam
via: brew-cask-
Configure an age key:
age: identity: ~/.config/dotular/identity.txt
-
Encrypt a file:
dotular encrypt ~/.ssh/config # writes ~/.ssh/config.age — commit this file
-
Reference the encrypted file in your config:
- file: .ssh/config.age encrypted: true destination: ~/.ssh permissions: "0600"
On apply, dotular decrypts to a temp file and copies it to the destination.
Reuse and share module definitions:
modules:
- from: neovim
with:
neovim_version: "0.10.2"
override:
- directory: nvim
direction: push
destination: ~/.config- dotular fetches the remote YAML module definition.
- Parameters from
with:(merged with module defaults) are applied via Go templates. override:items are merged by(type, primary-value)— unmatched overrides are appended.- A lockfile (
dotular.lock.yaml) records SHA-256 checksums for reproducible fetches.
| Source | Trust |
|---|---|
github.com/atomikpanda/dotular/... or bare name |
Official |
Other github.com/... repos |
GitHub |
| Other URLs | External |
Bare names (e.g. neovim) expand to github.com/atomikpanda/dotular/modules/neovim@main. GitHub refs are automatically rewritten to raw.githubusercontent.com.
Remote modules are cached at ~/.cache/dotular/registry/. Use --no-cache or dotular registry update to re-fetch.
By default, dotular snapshots any files it will modify before running each module. If any item fails, the snapshot is restored. Disable with --no-atomic.
Every action is appended to ~/.local/share/dotular/history.log as JSON lines:
TIME COMMAND MODULE OUTCOME ITEM
2024-01-15 12:00:00 apply homebrew skipped script "https://..."
2024-01-15 12:00:01 apply Visual Studio Code success push settings.json -> ...
make build # build the binary
make tidy # go mod tidy
make test-list # run dotular list
make test-status # run dotular status
make test-apply-dry # run dotular apply --dry-run
make clean # remove binary