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 11 more | 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, Windows registry |
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 of file and directory items, 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 the destination of each
fileanddirectoryitem before its module runs; 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.23+.
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 init # scan this machine, suggest registry modules to adopt
dotular add ~/.zshrc shell # manage a file under the "shell" module
dotular 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:
- ...Two on-disk formats are accepted:
| Format | Shape | Global keys (age:) |
|---|---|---|
| Mapping (current) | modules: key holding the module list |
Yes |
| Legacy | a bare top-level sequence of modules | No — there is nowhere to put them |
Both parse identically into modules, so the legacy format keeps working. Only the
mapping format can express age:, so an encrypted-secrets setup requires it.
dotular add and dotular init rewrite the config file, and they always write
the mapping format — a legacy file is silently migrated on the first write.
The rewrite re-marshals the config from scratch, so comments, key order, and
formatting in the file are lost. Commit before running either command.
- package: ripgrep
via: brew # see the table below for every accepted value
skip_if: command -v rg
verify: rg --versionSupported package managers and the platform each one is bound to:
via |
Platform | Install command |
|---|---|---|
brew |
macOS | brew install |
brew-cask |
macOS | brew install --cask |
mas |
macOS | mas install |
apt |
Linux | sudo apt-get install -y |
apt-get |
Linux | sudo apt-get install -y |
dnf |
Linux | sudo dnf install -y |
yum |
Linux | sudo yum install -y |
pacman |
Linux | sudo pacman -S --noconfirm |
snap |
Linux | sudo snap install |
flatpak |
any | flatpak install -y |
nix |
any | nix-env -iA |
winget |
Windows | winget install --id |
choco |
Windows | choco install -y |
scoop |
Windows | scoop install |
A package item whose via is bound to one platform is skipped on the others,
so a single config can list the brew, apt, and winget spelling of the same tool.
flatpak and nix are treated as cross-platform and are never skipped.
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.
install_to is optional and defaults to ~/.local/bin. An item is skipped when source has no entry for the current platform.
- run: nvim --headless "+Lazy sync" +qa
after: directory # informational only — ordering follows declaration order- setting: com.apple.dock # macOS bundle ID, or a Windows registry path
key: autohide
value: true # bool | int | float | stringThe command used depends on the platform the apply runs on:
| Platform | Command | Value mapping |
|---|---|---|
| macOS | defaults write <setting> <key> … |
bool → -bool, int → -int, float → -float, string → -string |
| Windows | reg add <setting> /v <key> … |
bool/int → REG_DWORD, float/string → REG_SZ |
On Windows, setting: is the registry path (e.g. HKCU\Control Panel\Desktop)
and booleans are written as 1/0. setting items are not supported on Linux.
| 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 initFetches the module registry, scans this machine for the packages and config files
those modules manage, and lets you pick which ones to adopt. Selected modules are
appended to the config as from: registry references. In a non-interactive shell
the picker is skipped and only fully-matching modules are added.
dotular add ~/.config/nvim nvim
dotular add ~/.zshrc shell --direction sync
dotular add ~/.config/nvim/init.lua nvim --link
dotular add ~/.zshrcCopies a file or directory into the module's managed store (a directory named
after the module, next to the config file) and records it as a file: or
directory: item. The module is created if it does not exist. The item's
destination is set to the source's parent directory, for the current platform
only — add the other platforms' paths by hand.
If the module name is omitted, dotular infers it from the registry or prompts.
| Flag | Description |
|---|---|
--link |
Record link: true, so apply symlinks instead of copying (default false) |
--direction |
Record direction: — push, pull, or sync (default push) |
This command rewrites the config file — see Config file formats.
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.
pull and sync reconcile files only: package, script, binary, run, and setting items are skipped for the run (listed with --verbose, recorded in the audit log). push behaves like apply and runs them.
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 versionPrint the version, commit, and build date stamped into the binary.
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. --limit defaults to
the 50 most recent entries; --module filters by module name.
dotular registry list # fetch and print the remote module index
dotular registry list --cached # print the modules pinned in dotular.lock.yaml
dotular registry clear # remove all cached modules
dotular registry update # re-fetch all modules from the networkregistry list reaches the network by default and prints the name and version of
every module in the official index. --cached needs no network: it reads
dotular.lock.yaml and prints each pinned ref with its trust level and fetch time.
| 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 the destination of every file and directory item before running each module. If any item in the module fails, the snapshot is restored. Disable with --no-atomic.
Only file and directory items are snapshotted. package, script, binary, run, and setting items are not covered — an installed package, an executed script, or a written OS setting is not undone by a rollback.
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 to ./build/dotular
make run ARGS=".." # go run ./cmd/dotular with ARGS
make tidy # go mod tidy
make index # regenerate modules/index.yaml from modules/*.yaml
make test-list # run dotular list
make test-status # run dotular status
make test-apply-dry # run dotular apply --dry-run
make clean # remove binaryRun make index after adding or bumping a module under modules/ — CI checks
that modules/index.yaml matches the module files.