A small, opinionated collection of scripts to bootstrap a Linux system the way I like it.
This repository is intentionally separate from my dotfiles and secretfiles,
which live in bare Git repositories. machines focuses on getting a system
ready; dotfiles and secretfiles focus on configuring the user environment.
Think of this repo as the missing step between a fresh OS install and a usable, familiar machine.
I use a bare Git repository for my dotfiles, which works great once a system already has:
- a package manager set up
- core tools installed (git, curl, zsh, etc.)
- sane defaults and dependencies available
machines exists to solve everything before that.
Goals:
- Work across multiple distributions (Arch, CachyOS, Manjaro, EndeavourOS, Debian, Ubuntu, macOS)
- Be idempotent (safe to re-run)
- Be readable and boring, not clever
- Keep distro-specific logic isolated
- Stay small enough to understand months later
This is not a full distro installer or a replacement for tools like Nix or Ansible — it's a personal, pragmatic bootstrap kit.
machines handles:
- Detecting the current distribution (Linux/macOS)
- Installing core system packages
- Installing optional groups (dev tools, desktop apps, server tools…)
- Handling special installers that don't fit normal package managers (AUR, .deb from upstream, build-from-source, etc.)
- Preparing the system so my bare dotfiles repo can be used immediately
It does not:
- Manage dotfiles directly
- Replace a configuration manager
- Make irreversible system changes without asking
The repo lives at:
~/machinesmachines/
├── install.sh # main entry point
├── test.sh # lint + syntax + dry-run checks
├── Makefile # convenience targets for the test suite
├── bootstrap/ # distro-specific system prep
│ ├── arch.sh # covers arch / cachyos / manjaro / endeavouros
│ ├── ubuntu.sh # covers ubuntu / debian
│ └── macos.sh
├── lib/ # shared helpers
│ ├── log.sh # logging
│ ├── os.sh # distro detection + capability gates
│ ├── pkg.sh # package-manager abstraction + AUR + apt repo
│ └── sysd.sh # systemd / sudoers / privileged-file helpers
├── packages/ # logical package groups
│ ├── core.sh # git, curl, build tools, archive tools, …
│ ├── cli.sh # fzf, ripgrep, fd, bat, eza, jq, mise, uv, atuin, …
│ ├── dev.sh # slot for extra dev-only packages
│ ├── shell.sh # zsh + oh-my-zsh
│ ├── ai.sh # ollama + llama-cpp + llama-swap
│ ├── desktop-apps.sh # firefox, filezilla, mpv (gui-capable hosts)
│ ├── wayland.sh # cliphist, wl-clipboard, grim, slurp
│ ├── nautilus.sh # nautilus + plugins
│ ├── sharing.sh # avahi + nss-mdns (mDNS / .local discovery)
│ ├── gaming.sh # gamemode, mangohud, protonup-qt (+lib32)
│ ├── security.sh # fail2ban, ufw, openssh, auditd, logwatch
│ ├── server-tools.sh # ncdu, rsync
│ └── homelab.sh # net-tools, iproute2, sops
├── installers/ # special-case installers (one per tool)
│ └── <tool>.sh # AUR-only, third-party apt repo, source build, etc.
├── hosts/ # host-specific overrides
│ └── <hostname>.sh # auto-loaded by hostname; sets ROLE + host_extras
├── roles/ # role presets
│ ├── server.sh
│ ├── workstation.sh
│ ├── gaming.sh
│ └── homelab.sh
└── test/ # docker + vagrant test infrastructure
├── docker/
├── vagrant/
└── lib/
Design rules:
packages/describe intent (what I want)bootstrap/handles distro reality (how to get there)installers/are escape hatches for weird cases- Logic lives in
lib/, not copy-pasted everywhere
Clone the repository:
git clone <repo-url> ~/machines
cd ~/machinesRun the installer:
./install.sh --role server # Lean security-focused server
./install.sh --role workstation # Full dev environment + AI stack
./install.sh --role homelab # Server + sops + networking tools
./install.sh --role gaming # Steam + nvidia drivers + desktop appsUse --dry-run to preview actions without installing.
Install extra packages on demand:
./install.sh --packages "htop, jq, bat"Add host-specific overrides by creating hosts/<hostname>.sh. The file is
auto-loaded based on the current hostname. It can set ROLE and define a
host_extras() function that runs after the role completes. Gate
role-specific extras on $ROLE so a --role server install on a workstation
host doesn't pull in desktop packages — see hosts/example.sh.
Once install.sh finishes, the system is ready for:
- cloning / enabling the bare dotfiles repository
- applying secretfiles from a separate bare repository
- day-to-day usage with minimal manual setup
| Role | Includes | Notes |
|---|---|---|
server |
core + security + server-tools + docker + neovim | fail2ban, ufw, openssh, auditd, logwatch |
workstation |
core + shell + cli + ai-stack + desktop-apps + wayland + nautilus + sharing + neovim + tmux + opencode + docker | Desktop environment lives in host_extras (e.g. niri-stack) |
homelab |
server + net-tools + iproute2 + sops | Server hardening with secrets/network tools |
gaming |
core + shell + dev + gaming-desktop-apps + sharing + vulkan + nvidia-driver + steam + steam-remote-play-firewall + gaming-stack + vr-stack | Multilib auto-enabled on Arch. Vulkan ICD per GPU (AMD/Intel/NVIDIA), plus lib32-nvidia-utils for 32-bit Steam/Proton. VR stack is AUR-only. See VR / Steam Link for what's automated vs. manual. |
install_core_packages runs once at the top of main(), before the role —
roles compose by calling sub-installers, not by re-running core.
The gaming role targets streaming VR to a standalone headset (Meta Quest,
Pico) via Steam Link — the game runs on the PC and SteamVR streams frames
to the headset over the LAN. Reference setup: CachyOS + Niri/Wayland + Meta
Quest + Half-Life: Alyx.
| Piece | Handled by |
|---|---|
| Steam client | installers/steam.sh |
| Vulkan runtime + 32-bit ICD (AMD/Intel/NVIDIA) | installers/vulkan.sh (+ lib32-nvidia-utils from nvidia-driver.sh) |
Headset discovery (mDNS / .local) |
packages/sharing.sh — avahi + nss-mdns |
| OpenVR→OpenXR + wireless runtime | installers/vr-stack.sh — opencomposite, xrizer, wivrn (AUR-only) |
| Firewall ports for Steam Link / Remote Play / VR streaming | installers/steam-remote-play-firewall.sh |
Steam Link, Remote Play, and VR streaming need inbound ports open. On a box
with no firewall these are already reachable — the gaming role does not pull
in the security stack, so it does not install or enable ufw. But if ufw is
present (e.g. installed separately), its default deny incoming silently drops
the headset's packets and the PC shows up greyed-out in Steam Link.
installers/steam-remote-play-firewall.sh closes that gap. It runs only when
ufw is already installed, and adds LAN-scoped allow rules (never global):
| Ports | Proto | Purpose |
|---|---|---|
| 27031, 27036 | UDP | Remote Play discovery + streaming |
| 27036, 27037 | TCP | Remote Play |
| 10400, 10401 | UDP | VR streaming (Steam Link → SteamVR) |
The LAN subnet is auto-detected from the default-route interface. Override it when detection is wrong or the box has multiple NICs:
STEAM_LAN_SUBNET=192.168.128.0/23 ./install.sh --role gamingRules are added with ufw allow (idempotent — duplicates are skipped) and are
refused for non-private subnets as a safety guard. Verify with
sudo ufw status numbered.
-
Steam Link on the headset — install the Steam Link app on the Quest/Pico from its store; it is not a PC package.
-
Enable Remote Play — Steam → Settings → Remote Play → Enable Remote Play.
-
In-Steam content — install SteamVR and the VR title (e.g. Half-Life: Alyx) from within Steam.
-
SteamVR on Wayland (Niri/wlroots) —
vrmonitorcan crash trying to load a Wayland Qt plugin. Set a per-game launch option (stored in Steam's own config, so the bootstrap can't set it) under SteamVR → Properties → Launch Options:QT_QPA_PLATFORM=xcb %command%
Stronger fallback if it still crashes:
WAYLAND_DISPLAY= QT_QPA_PLATFORM=xcb %command%. -
Network — wire the PC by Ethernet, keep the headset on 5/6 GHz Wi-Fi on the same subnet, and avoid guest/client/AP isolation. VPN or virtual interfaces (WireGuard, Tailscale, ZeroTier) can make VRLink bind the wrong NIC — disable them while streaming if the PC won't connect.
# Steam is listening on the streaming ports
ss -lntup | grep -E ':(27031|27036|27037|10400|10401)\b'
# UFW rules applied
sudo ufw status numberedPackages are resolved through install_package_with_mapping in lib/pkg.sh,
which accepts entries like:
"build-essential,arch:base-devel"
"7zip,arch:p7zip,debian:p7zip-full"
"poppler-utils,arch:poppler"Resolution per distro family:
- Arch family (arch / cachyos / manjaro / endeavouros) —
arch:override uses pacman;aur:override uses paru. With no override, the auto path tries pacman first and falls back to AUR. Paru is bootstrapped fromparu-bin; if that fails its smoke test (e.g. libalpm soname mismatch), it's rebuilt from source. - Debian/Ubuntu —
ubuntu:override beatsdebian:on Ubuntu;debian:is the fallback for both. Always apt. - macOS —
macos-cask:uses brew --cask;macos:uses brew. Always brew.
Capability gates (also in lib/os.sh) skip GUI/Wayland installers on headless
hosts and containers automatically.
Two layers, both invoked through the Makefile:
test.sh— lint (always fast, runs locally): bash syntax check on every script, source-import check for the lib/packages stack, and a--dry-runfor the default role. Required to pass before any container/VM test.- Docker quick tests (~2-5 min/distro): build a minimal image, run
./test.sh, then./install.sh --role X --dry-run, then run the install twice (second pass exercises idempotency), then assertgit zsh cargo ollamaare on PATH. Three matrices: Ubuntu 24.04 / Debian 12 / Arch. - Vagrant full tests (~10-20 min/distro, optional): run the full install in a real VM with systemd, including service enablement and the second-run idempotency check. Needs Vagrant + libvirt.
./test.sh # lint + dry-run only
make test-quick # docker quick tests for all 3 distros
make test-quick-ubuntu24 # one distro
make test-quick-debian12
make test-quick-arch
make test-full # vagrant full tests for all 3 distros
make test-shell-ubuntu24 # interactive SSH into a vagrant VM for debugging
make test-clean # remove built images, vagrant boxes, and resultsOLLAMA_SKIP_MODELS=true— skip the interactive model-pull menu ininstallers/ollama.sh. Always set inside the test runner.SKIP_CARGO_PACKAGES=true— skipcargo installinpackages/core.sh(those crates compile slowly and are non-critical for verification). Always set inside the test runner.DRY_RUN=true— set automatically by--dry-run.
# Docker (quick tests)
sudo pacman -S docker docker-buildx # arch
sudo apt install docker.io docker-buildx # debian/ubuntu
sudo usermod -aG docker $USER && newgrp docker
# Vagrant + libvirt (full tests)
sudo pacman -S vagrant qemu libvirt # arch
sudo apt install vagrant qemu-kvm libvirt-daemon-system libvirt-clients # debian/ubuntu
vagrant plugin install vagrant-libvirt
sudo usermod -aG libvirt $USER && newgrp libvirtThe Docker test runner exports DOCKER_BUILDKIT=0 so it works on hosts where
the buildx component isn't installed. Detailed troubleshooting and the full
distro/role matrix live in TESTING.md.
- Declarative over clever – scripts should read like documentation
- Re-runnable by default – no one-shot assumptions
- Explicit over implicit – nothing happens silently
- Personal, not universal – this is for my systems
If something feels like it belongs in dotfiles, it probably doesn't belong here. If something is needed before dotfiles can even run, it probably belongs here.
The model is simple: add tools and apps as the need arises, and keep each addition small and explicit.
When adding a new tool:
- Try a clean cross-distro mapping in a
packages/*.shgroup first. - If a tool needs apt-repo-add, build-from-source, AUR-only, or other
special handling, write
installers/<tool>.shand call it viainstall_package <tool>. - Wire it into a role or package group.
- Keep distro logic in helpers or the installer, not in the roles.
This keeps the scripts boring, repeatable, and easy to evolve over time.
Fresh system → machines → dotfiles → secretfiles → done.