An Ansible playbook for deploying Nomad + Consul across a mixed-OS cluster.
Nomadable is the parent playbook that composes the platform-specific child playbooks:
- Nomadintosh — macOS (Apple Silicon) nodes managed via Homebrew and LaunchAgents
- Nomaduntu — Ubuntu nodes managed via the HashiCorp apt repository and systemd
A single inventory can contain a mix of macOS and Ubuntu hosts. Nomadable dispatches to the correct child playbook based on the ansible_os_family fact of each host, so all nodes end up in the same Nomad/Consul datacenter regardless of operating system.
Nomad is a workload orchestrator by HashiCorp. It schedules and runs containerised and bare-metal applications across a cluster of machines.
Consul is a service mesh and service discovery tool, also by HashiCorp. It provides a distributed key-value store, health checking, and DNS-based service discovery. Nomad integrates with Consul natively to handle cluster membership and service registration.
Nomadable (and the Nomadintosh/Nomaduntu child playbooks it composes) provisions the Nomad + Consul agents themselves — it intentionally does not deploy the job specs those agents run. Nomadintosh used to also template and register a couple of job specs directly, but that role was removed 2026-09-05; job specs now live in dedicated repos instead — Jellify/Nomad-Jobs (Terraform-managed) and a legacy hand-deployed nomad-jobs repo. If you're looking to add or change a running job, it belongs in one of those, not here.
- Ansible installed on the control machine
- The Nomadintosh/Nomaduntu collections (and their shared
cosmonautical.notifydependency), pinned incollections/requirements.yml:ansible-galaxy collection install -r collections/requirements.yml - SSH access to all hosts in the inventory
Each host's Nomad datacenter is its DNS domain label (hopper.jellify.app → jellify), and the Consul datacenter is the Consul servers' domain label — so hosts must be listed by fully qualified name. Inventory groups are freeform: organise hosts however you like, and target groups from roles (via group variables) and Nomad jobs (via meta.inventory_groups), mixing hosts across datacenters. A single group may contain a mix of macOS and Ubuntu hosts.
Example inventory with mixed hosts:
all:
children:
cosmonautical:
hosts:
mac1.example.com:
server:
enabled: true
mac2.example.com: {}
ubuntu1.example.com: {}See the individual child playbook READMEs for the full list of supported host variables:
Ansible Galaxy has no synopsis/description field for playbooks shipped inside a collection (unlike roles, which get one from meta/main.yml), so this is the canonical place it's documented:
| Playbook | Description |
|---|---|
playbooks/main.yml |
Full deployment — imports cosmonautical.nomadintosh.deploy and cosmonautical.nomaduntu.deploy against whatever's in the inventory; each host only ever runs the half matching its own OS. See What it does below for the full breakdown. Idempotent — safe to rerun. |
playbooks/reboot.yml |
Reboots every host in the inventory, serially within each OS's own playbook — imports cosmonautical.nomadintosh.reboot and cosmonautical.nomaduntu.reboot. Does not run the full deployment. |
playbooks/clean.yml |
Prunes stale upgrade residue on every host — brew cleanup on macOS, apt autoremove on Ubuntu — by importing cosmonautical.nomadintosh.clean and cosmonautical.nomaduntu.clean. Does not run the full deployment. |
Run a full deployment across all hosts:
ansible-playbook -i inventory/hosts.yml playbooks/main.ymlTo limit execution to a single host or group:
ansible-playbook -i inventory/hosts.yml playbooks/main.yml --limit <hostname>The repository includes helper scripts for common workflows:
./deploy.zsh— runs the full deployment:
./deploy.zsh./check.zsh— runs the playbook in Ansible check mode with diff output:
./check.zsh./reboot.zsh— reboots every host in the inventory, one at a time:
./reboot.zsh./clean.zsh— prunes stale upgrade residue on every host:
./clean.zshNomadable delegates to the appropriate child playbook for each host based on its OS:
- macOS hosts → Nomadintosh — see that project's README for a full breakdown of what is configured.
- Ubuntu hosts → Nomaduntu — see that project's README for a full breakdown of what is configured.
Both child playbooks derive datacenters the same way — Nomad from each host's DNS domain, Consul from the servers' domain — so macOS and Ubuntu hosts in the same domain land in the same datacenter. If you instead run each OS group separately against a partial inventory (e.g. per-OS Semaphore tasks), see inventory/README.md for existing_consul_datacenter/existing_cluster_servers, which both child projects support for joining a control plane whose servers aren't part of that particular run's own inventory.
- Multi-platform clusters — Nomad's native support for multiple platforms means macOS and Ubuntu nodes can participate in the same cluster and share workloads. Platform-specific capabilities (e.g. hardware acceleration on macOS, GPU passthrough on Linux) are exposed via Nomad node attributes and can be targeted with job constraints.
- Child playbook versions — Each child playbook is maintained independently. Pin submodule or collection versions as appropriate for your environment to avoid unexpected changes on deployment.
Anything that should be versioned and reviewed — and bumped by Renovate — lives in group_vars/, not in the inventory itself. The inventory (local inventory/hosts.yml or Semaphore's Static inventory) only says which hosts are in which group.
playbooks/vars_plugins/nomadable_group_vars.py loads group_vars/ (and host_vars/, if present) for every run, whatever inventory it uses, with nothing to configure: Ansible picks up vars_plugins/ next to the playbook on its own. It's needed because Ansible normally only reads group_vars/ beside the inventory source, which a Semaphore Static inventory isn't, or beside the playbook, which only applies to plays in that directory, and every play in playbooks/main.yml is imported from the child collections. These load at group_vars precedence, so a host's own inventory vars still override them; Semaphore's extra variables override both. main.yml stops before deploying anything if the plugin didn't run, rather than deploying hosts without their group's toolchain.
| Group | What it provisions |
|---|---|
github_runners |
Toolchain for GitHub Actions self-hosted runners — pinned GitHub Actions runner binary (/opt/actions-runner/current), bun, Ruby, Maestro, Android SDK (see github_runners.yml). On macOS hosts, Nomadintosh also installs Xcode and an iOS simulator runtime for this group (one of its magic groups); sign in to the App Store on each Mac before its first deploy. Registering and running the runner is the actions-runner Nomad job in Jellify/Nomad-Jobs, which targets this group via Nomadintosh's inventory_groups node meta |
game_servers |
Host packages for game servers — the Java runtime Minecraft runs (see game_servers.yml). The minecraft and valheim Nomad jobs in Jellify/Nomad-Jobs target this group via nomaduntu's inventory_groups node meta |
To add a runner, add the host to github_runners in the inventory — its datacenter still comes from its DNS name, so a group can mix hosts from any datacenter.
Versions annotated with a # renovate: comment are bumped by Renovate (see renovate.json). Merging a bump doesn't touch any host — it lands on the next deploy.
- Add this repository as a Semaphore Repository, and a Key Store entry (SSH key plus become/sudo password) covering every host in the inventory — the same credentials
inventory/hosts.ymlwould otherwise hold locally. - Add a Semaphore Inventory.
inventory/hosts.ymlis gitignored — it holds plaintext SSH/become credentials, not something to commit — so define the hosts directly as a Semaphore "Static" inventory instead of pointing at a file in this repo. Useinventory/hosts.example.ymlas the shape to replicate. - Add a Task Template of type "Ansible Playbook": this repository,
playbooks/main.yml, and the Inventory/Key Store from the steps above. No extra CLI arguments are needed: the committed group variables load on top of the Static inventory automatically. Semaphore installscollections/requirements.ymlautomatically before each run, so both child collections are pulled fresh from Galaxy at whatever version is pinned there — no separateansible-galaxy collection installstep to configure, and no stale local collection cache to worry about (unlike a manual./deploy.zshrun — see the note oncollections/requirements.yml's pins). - There's no
plan/applysplit here the way Nomad-Jobs' Terraform pipeline has — Ansible has no true dry-run equivalent toterraform plan, only--check --diff(what./check.zshruns locally), which isn't guaranteed identical to the real run for every module. If a review step before applying matters, add a second Task Template running the same playbook with--check --diffappended, to read before triggering the real one. - One Static inventory covering every host needs nothing else. If you split inventories instead (e.g. one per datacenter, each with its own Task Template or Variable Group), any run whose inventory has no
server.enabledhosts must setexisting_consul_datacenterandexisting_cluster_serversin its extra variables (see inventory/README.md) — otherwise those hosts would derive a Consul datacenter of their own and have no servers to join. Their Nomad datacenter still comes from DNS either way.
