Architecture
How Dotfiles Pro models your machine, and how the pieces are laid out, the mental model first, then the structure.
Your machine is described as a set of roles: packages, system config, shell, prompt, git, cloud CLIs, and so on. Managing raw dotfiles is just one of them, the optional dotfiles role, which links, copies, or templates files into $HOME when you list them and does nothing when you do not. "Dotfiles" is the brand, but the unit of work is a role applied to the whole machine, not a file synced into $HOME.
Desired and actual state
Two states, and a step that reconciles them:
- Desired state: what you declare: your personal repo (vars and the role list) plus the collection's role defaults. The source of truth, in git.
- Actual state: what's on the machine right now: installed packages, config files, shell aliases, cloned repos.
- Apply: a playbook run reads the desired state, computes what the machine should look like, compares that to the actual state, and applies only the difference.
Re-running an apply is safe and cheap: when actual already matches desired, nothing changes. A first install and a routine update are the same operation, there is no separate "install" and "upgrade" path.
Compute, then apply
Desired state is not a literal copy of files, the apply computes the target from your declarations:
- Merging: collection defaults,
group_vars, andhost_varsmerge (hash_behaviour = merge), so a host overrides only the keys it cares about. - Templating: roles render config from your vars with Jinja2, so one declaration produces the right file for each machine.
So the desired state is the result of merging and templating your declarations, not the declarations verbatim, which is what lets shared roles and per-person config coexist in a single run. Where a tool co-writes its own config, roles own only the keys you declared (see Settings, not files).
Collection and personal repo
The shared roles live in the dotfilespro.roles collection; your identity, repository lists, vars, and aliases live in your own personal repo, which references the collection in requirements.yml. One engine, two layers: shared defaults and per-person config merge in a single run, so a team gets a common baseline while each person keeps their own overrides.
Local execution
- The collection runs locally via
ansible_connection: local, no agent, no server, no remote control node. The machine configures itself. - Inventory hosts use their actual hostname (from
hostname), sohost_vars/<hostname>.yamlis picked up automatically. Run with--limit $(hostname)to apply only the current machine's config. - Every role's tasks are tagged with the role name (
--tags git,--tags kubernetes) plus a few cross-cutting tags, see Roles → Tags. - Preview an apply with
--diff(the example playbooks setdiff: true, so every run prints its changes) or a full dry run with--check --diff, which computes the target and reports what would change without touching the machine.
dotpro, a smart Ansible wrapper
Day to day you drive the collection with dotpro, a single bash command installed to ~/.local/bin (by the dotpro role, and seeded by install.sh during the first bootstrap). It is deliberately thin: it shells out to ansible-playbook and ansible-galaxy and never reimplements them, so every subcommand maps to a plain Ansible invocation you could run by hand.
dotpro apply # ansible-playbook playbook.yaml --ask-become-pass
dotpro plan # ansible-playbook playbook.yaml --check --diff
dotpro tags git # ansible-playbook playbook.yaml --tags git
dotpro tags # ansible-playbook playbook.yaml --list-tags
dotpro vars # ansible-inventory --host $(hostname) --yaml
dotpro requirements --upgrade # ansible-galaxy collection install -r requirements.yml --upgradeWhat dotpro adds is convenience, not magic: it resolves the repo path, picks the apt or uv toolchain, limits to the current host when the repo has an inventory, decides the become prompt, and installs the collection if it is missing. Because the same install.sh that bootstraps a machine seeds the binary and runs its first apply through it, day-0 and day-2 share one apply engine. Run dotpro --debug <command> to print the exact Ansible command any subcommand runs.
Speed is a feature
Ansible is the slow part of any apply, so dotpro is built to add as little of its own latency as possible and to shorten the apply itself wherever it safely can.
The wrapper stays out of the way. It avoids a full Ansible or Galaxy startup whenever a cheaper path exists: it checks whether the collection is installed with a filesystem probe rather than ansible-galaxy collection list, reads the role list by parsing the playbook instead of asking Ansible, and decides the become prompt by statically scanning the selected tasks. A subcommand that does not need Ansible never pays to start it.
The apply engine is where the real time goes, so the ongoing performance work targets the apply itself: overlapping the network-bound roles' downloads with their package installs so a first apply is not a serial wait on every fetch, and running the independent roles concurrently where it is safe.
Package installs are merged. A role opts in by shipping a tasks/apt-packages.yml that registers its base-repo packages, and the base ubuntu role installs the union of every enabled role's declared packages in a single dpkg transaction (one lock, one man-db and ldconfig trigger pass) instead of one apt task per role. A role that has not opted in, an install from a role-managed repository (terraform, kubectl, docker), or an apply run with --sequential installs per role as before. The single install runs before the tool roles, so each role finds its packages already present and skips its own step.
Role conventions
roles/<name>/tasks/main.yaml: entry pointroles/<name>/defaults/main.yaml: default variable values (override via vars, inventory, or host_vars)roles/<name>/files/: static files copied verbatimroles/<name>/templates/: Jinja2 templates rendered at runtime
Version pinning
Every tool a role installs from an upstream release takes a <role>_<tool>_version variable (kubernetes_kubectl_version, agents_sd_version, …), defaulting to latest. Pin it to an exact version and the tool installs, or up/downgrades, to precisely that on every apply; latest checks the remote only on first install or an --upgrade run. Tools managed by apt or an apt repository (ansible, docker, terraform) track their repository instead and take no such pin.
Which source a tool comes from is a separate axis, set by dotpro_install_source and its <role>_install_source / <role>_<tool>_install_source overrides (most specific wins): package_manager prefers apt or Homebrew and falls back to the upstream release where unavailable, latest always takes the newest upstream release. Only tools that have both a package-manager and a release path honor it. This per-tool pinning is distinct from pinning the whole collection to a release, which the Releases roadmap covers.
Machine-level variables
Nearly every variable belongs to a role and carries its prefix (git_config, agents_tooling, macos_defaults). A handful are deliberately bare: machine-level variables state a fact about the machine itself, owned by your inventory rather than by any role, for any role to consume:
shell— the login shell (bashorzsh); theunixrole dispatches on it.aliases/functions— the machine's own shell aliases and functions, rendered alongside the role-generated ones.projects— the project inventory; thegitrole's clone list (git_repositories) defaults to it and theagentsrole renders~/.agents/PROJECTS.mdfrom it.
A role consumes a machine-level variable by defaulting its own prefixed variable to it — unix_shell: "{{ shell | default(…) }}", git_repositories: "{{ projects | default({}) }}" — never by depending on it directly, so each consumer can still be overridden without touching the others. The bare namespace stays deliberately small: a new machine-level variable is an architectural decision (a fact of the machine that several roles need), not a naming convenience.
Shell configuration pattern
Roles drop scripts into ~/.profile.d/ rather than editing .bashrc or .profile directly. The unix base role sources every file in that directory on shell startup, so each role contributes its own shell snippet without roles fighting over a single rc file.
The login shell is its own axis, separate from the OS. A host-level shell variable (bash or zsh, like the bare aliases / functions knobs) selects it; it defaults from the OS — zsh on macOS, bash elsewhere — so you only set it for the off-diagonal, e.g. shell: zsh in a Linux host's host_vars. unix resolves it into unix_shell, derives which login files to source (~/.zprofile and ~/.zshrc for zsh, ~/.profile otherwise), and hands off to the matching shell role (os -> unix -> shell). Aliases and functions are POSIX and render identically under either shell; only completions differ, so the generator emits bash complete -F directives only when shell is bash.
Aliases and functions
Aliases and functions follow the same drop-in pattern. The shared bash role provides an aliases.yaml task and an aliases.j2 template that render one file under ~/.profile.d/aliases/<name>.sh from three dicts, bash_aliases, bash_functions, and bash_completions.
- Per tool: each tool role imports
bash'saliases.yamlwith its own dict (git_aliases,kubernetes_aliases,ssh_aliases, …) and abash_aliases_filename, so every role contributes its own~/.profile.d/aliases/<role>.sh. - Per machine: your personal
aliasesandfunctions, read straight from your playbook config, render the same way intoaliases/aliases.sh, so you add shell shortcuts without touching a role.
The unix role drops a loader into ~/.profile.d/ that sources every file in ~/.profile.d/aliases/ on shell startup, so aliases and functions compose the same way the rest of the shell framework does (see Shell configuration pattern).
Environment variables
Environment variables follow the same declarative, drop-in approach as aliases, in two tiers:
- Shared standard (
unix_env, on theunixrole): a dict rendered to~/.profile.d/env.sh, whichinit.shsources first, before the banner, aliases, and every other fragment. Bothubuntuandmacosinherit it, so the locale (and, for example, the XDG base directories) is one cross-platform standard rather than a per-OS copy. - Per role (
<role>_env): a role exports its own variables at the top of its own~/.profile.d/<role>.sh, since role-local env is only needed within that fragment. Thegooglecloudandwslroles do this withgooglecloud_env/wsl_env.
A value may be a literal (LANG: en_US.UTF-8) or a shell expansion emitted verbatim (XDG_CONFIG_HOME: "${XDG_CONFIG_HOME:-$HOME/.config}"). Values that are genuinely computed at runtime (a command substitution, an ordering-dependent PATH edit) stay as hand-written lines in the role's profile script rather than in the dict.
Settings, not files
Where software co-writes its own config (.gitconfig, kubeconfig, wsl.conf), roles manage the keys they declare rather than owning the whole file, so the program's improved defaults and runtime writes survive across updates. Whole files are owned only as .d drop-ins or configs the software never writes itself (~/.profile.d/, starship.toml).
Privacy by default
Tools that phone home for telemetry, error reporting, or feedback surveys ship with that traffic turned off in their role defaults, so a managed machine is quiet out of the box and the user opts in rather than out. The claude role sets DISABLE_TELEMETRY, DISABLE_ERROR_REPORTING, and the feedback-survey toggle in its rendered settings; any future tool with similar phone-home behaviour follows the same rule.
Auto-updates stay enabled, since keeping software up-to-date is a goal of the collection rather than non-essential traffic.
Per-shell context isolation
Cloud and Kubernetes tools normally keep "which account am I?" in a single mutable file: gcloud's active_config, kubeconfig's current-context and namespace, doctl's context key, glab's per-host token in ~/.config/glab-cli, gh's in ~/.config/gh. Switch in one terminal and every other terminal switches with you, because they all read the same global state. That is exactly the wrong model when you run more than one session at a time.
Dotfiles Pro moves that selection out of the shared file and into environment variables scoped to the shell. The context-switching functions export rather than write:
- googlecloud:
CLOUDSDK_CORE_ACCOUNT,CLOUDSDK_CORE_PROJECT,CLOUDSDK_CORE_REGION, withactive_configpinned todefault. - kubernetes:
KUBECONFIG(a per-cluster file under~/.kube/configs/),KUBECTL_NAMESPACE(injected by thekubectlwrapper), andHELM_NAMESPACE(read natively by Helm), sokubectl,helm, andk9sagree. - digitalocean:
DIGITALOCEAN_CONTEXT. - github:
GH_CONFIG_DIR(a per-account config dir under~/.config/gh/accounts/), so two accounts on the same host — which a single gh config cannot hold — get one token each, selected withgh-account. - gitlab:
GLAB_CONFIG_DIR(a per-account config dir under~/.config/glab/accounts/), so two accounts on the same host — which a single glab config cannot hold — get one token each, selected withgl-account. - googleworkspace:
GOOGLE_WORKSPACE_CLI_CONFIG_DIR(a per-account config dir under~/.config/gws/accounts/), so a personal and a work mailbox stay signed in side by side, selected withgws-account. - sentry:
SENTRY_PROPERTIES(a per-account properties file under~/.config/sentry/accounts/), so a sentry.io and a self-hosted account — which a single~/.sentryclirccannot hold — get one token each, selected withsentry-account. - claude:
CLAUDE_CONFIG_DIR(a per-account config dir under~/.claude/accounts/, with the role-managed config deployed into each), so two Anthropic accounts get one login each, selected withclaude-account.
Because the selection lives in the environment, each terminal is its own isolated session and nothing writes back to the centralized config for another shell to inherit. You can hold a different account, project, cluster, and namespace in every window at once, and switching context in one never disturbs the others.
This is what makes the machine safe for parallel work, several terminals on different environments, and for coding agents like Claude Code that run their own shells alongside yours without polluting your state or each other's.
The starship prompt reads the same variables, so each shell visibly shows the account, project, cluster, and namespace it is on, and two terminals on different contexts show different prompts at once.