Ansible playbook for bootstrapping macOS/Linux workstations and managing dotfiles.
  • Shell 35.5%
  • Jinja 23.6%
  • Lua 22.2%
  • Python 17.8%
  • Just 0.9%
Find a file
Brad Frank 9a41a47297
docs: restructure and de-drift the README
Full rewrite around What / Bootstrap / Daily use / How it works / Gotchas.
Fix everything that no longer matched the repo: boot-incus/boot-do/boot-mp ->
boot-remote, drop the non-existent all_roles.yml and the --tags all,never and
host_skip_* sections, correct the [homelab]/[vps] -> [rsync_net] groups and the
consolidated group_vars/vm.yml, and the GUI-only 1Password push model.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-11 19:10:03 -04:00
docs/decisions docs(decisions): supersede ADR 0004 with 0005 (consolidate bootstraps) 2026-07-11 10:11:19 -04:00
inventory refactor(inventory): consolidate VM group_vars into a shared vm group 2026-07-11 18:17:28 -04:00
lib fix: address best-practice review nits 2026-07-11 18:49:52 -04:00
playbooks refactor(onepassword): GUI-only bootstrap; merge the cli/gui plays 2026-07-11 17:56:33 -04:00
plugins/inventory merge: main into main-2026-May-22-1804 2026-07-01 19:20:17 -04:00
roles fix: address best-practice review nits 2026-07-11 18:49:52 -04:00
scripts chore(scripts): add one-off ~/Development home-layout migration 2026-07-10 19:25:58 -04:00
.ansible-lint release(v21): refactor entire repo 2025-12-18 20:50:31 -05:00
.gitignore feat(inventory): split group_vars/all into directory for cross-role sharing 2026-04-30 10:51:20 -04:00
.yamllint chore: add .yamllint with a 100-char line length 2026-07-11 09:52:34 -04:00
ansible.cfg feat(inventory): add multipass dynamic inventory plugin 2026-06-15 21:45:10 -04:00
justfile refactor(onepassword): GUI-only bootstrap; merge the cli/gui plays 2026-07-11 17:56:33 -04:00
LICENSE Initial commit 2020-12-21 21:39:16 -05:00
README.md docs: restructure and de-drift the README 2026-07-11 19:10:03 -04:00
requirements.txt refactor(onepassword): GUI-only bootstrap; merge the cli/gui plays 2026-07-11 17:56:33 -04:00
requirements.yml feat(inventory): add Incus and DigitalOcean dynamic inventories 2026-05-17 10:35:44 -04:00
setup refactor(onepassword): GUI-only bootstrap; merge the cli/gui plays 2026-07-11 17:56:33 -04:00
vault refactor(playbooks): consolidate bootstraps into playbooks/ with a bootstrap_ prefix 2026-07-11 10:11:06 -04:00

Bootstrap & Dotfiles (Ansible)

Ansible for two jobs, sharing one set of roles, one inventory format, and one just driver:

  • Local workstation — bring a fresh macOS/Linux desktop up right after the OS install, then keep its dotfiles, packages, and shell in sync over time.
  • Headless remote hosts — provision homelab boxes, VPSes, and disposable VMs (Incus, Multipass, DigitalOcean) and re-converge them on demand — all pushed from your workstation, which holds the secrets.

Supported platforms

Platform Used as Notes
macOS (Apple Silicon) workstation App Store login needed for mas
Fedora workstation, homelab
Debian / Ubuntu workstation, homelab, VPS bare hosts via bootstrap_provision.yml
FreeBSD (rsync.net) special-case via bootstrap_rsync_net.yml

Bootstrap

Local workstation

git clone https://codeberg.org/bradfrank/dotfiles.git ~/dotfiles
cd ~/dotfiles
. ./setup      # MUST be sourced — it activates .venv and exports ANSIBLE_* into your shell

setup prompts for a hostname and sets it on the system, installs Python + Homebrew, builds .venv from requirements.txt, generates host_vars/<hostname>.yml and opens it in $EDITOR (populate host_secrets: and save), then hands off to just boot-local, which runs in order:

  1. just onepassword — install the 1Password app + CLI, enable CLI integration.
  2. just vault — pull secrets from 1Password into the encrypted vault.
  3. just role base --ask-become-pass — converge the base role.

Remote hosts

Remote hosts are configured by pushing from your workstation, so they never need 1Password themselves. This requires a vault on your workstation already (from a prior just vault) — secrets resolve locally and push over SSH.

A brand-new bare Debian/Ubuntu host (no user yet) needs its account + SSH access created first:

ansible-playbook playbooks/bootstrap_provision.yml -i '<ip>,' -u root

Then bring any remote host to a converged state with boot-remote <inventory> <instance> — it installs Homebrew, then runs the base role via the matching inventory:

just boot-remote incus <instance>          # Incus VM/container
just boot-remote digitalocean <instance>   # DigitalOcean droplet
just boot-remote multipass <instance>      # Multipass VM
just boot-remote remote <hostname>         # static host in inventory/remote.ini

<instance> is the name as it appears in that inventory — list them with ansible-inventory -i inventory/<name>.yml --graph.

rsync.net (FreeBSD) is a one-shot (FreeBSD Python install + the rsync_net role only — no Homebrew, no general bootstrap):

ansible-playbook playbooks/bootstrap_rsync_net.yml -i inventory/remote.ini --limit de3481b

Daily use

just dots [args]         # dotfiles/configs only        (base role, --tags dots)
just moredots [args]     # + plugins/caches/extras       (--tags dots,moredots)
just role <name> [args]  # run one role/playbook end-to-end
just vault               # regenerate the vault from 1Password
just ssh-config          # rebuild ~/.ssh/config from the incus/multipass/DO inventories
just lint                # ansible-lint over roles/
just sc                  # shellcheck the setup script

just role <name> runs playbooks/<name>.yml — covering the reusable roles (base, cloud, docker, virt) and the bespoke plays (ssh_config, do_nagios_server). Args pass through, e.g. just dots -i inventory/remote.ini -l memory-alpha.

Opt-in tasks are tagged never and skipped by default; request them by tag: hardening / host_keys (ssh), and dock / volumes / sleep / firewall (macOS) — e.g. just role base --tags hardening.

How it works

A few load-bearing design choices — worth knowing before you edit.

Three layers

Layer Job Note
setup (sh) bare OS → "can run Ansible": Python, Homebrew, .venv, env vars source it, once per host
just <target> wraps ansible-playbook with the right inventory/tags/flags daily driver — see justfile
playbooks / roles the actual config playbooks/ (run-once = bootstrap_-prefixed), roles/, shared helpers in lib/

setup aborts if executed instead of sourced, because it activates .venv and exports ANSIBLE_* for the current shell.

Hostname is identity

The hostname selects the host_vars/<hostname>.yml to load, the 1Password entry to look up, and the SSH config block to write. setup asks for it and sets it on the system so the convention holds from the start.

Inventories

  • inventory/local.py — returns the local host; the ansible.cfg default.
  • inventory/{incus,multipass,digitalocean}.yml — dynamic plugins that enumerate running instances on demand; their hosts share group_vars/vm.yml.
  • inventory/remote.ini — static hosts (currently [rsync_net]).
  • host_vars/<hostname>.yml — per-host knobs, generated by just hostvars then hand-edited.

Secrets: 1Password → Ansible Vault

just vault (part of boot-local) runs bootstrap_import_vault.yml to pull secrets from 1Password into inventory/group_vars/all/vault.yml, encrypted with a per-host vault password (also fetched from 1Password). The vault is generated, never committed.

  • 1Password runs only on the GUI workstation you drive Ansible from (bootstrap_onepassword.yml installs the app + CLI and enables integration). Headless hosts are pushed to from there, so they never authenticate 1Password.
  • Each host needs a 1Password item named after its hostname, with an ansible_vault field holding its vault password.

Gotchas

Warning

Biometric sudo (e.g. fprintd) breaks plays run locally — see ansible/ansible#80883. Workaround: set ansible_host: localhost in host_vars so Ansible connects over SSH instead of running locally.

Note

The Multipass inventory plugin needs both inventory_plugins = ./plugins/inventory and enable_plugins = multipass, … in ansible.cfg; drop either and Multipass VMs silently vanish from inventory.

Tip

Colorscheme: Solarized Light. Every attempt to switch has ended back here.