Reproducible, version-controlled setup for my Linux machines, driven by Ansible and run locally on each machine.
- Followable — a written runbook for the steps that must stay manual.
- Reproducible — idempotent, re-runnable Ansible for everything after first boot.
- Version-controlled — this repo is the single source of truth.
NEVER commit secrets (LUKS keyfiles, passwords, KeePassXC databases, private keys).
This repo is public on GitHub (and served by a local Gitea), so it must contain
only procedures and templates. If a secret must live here, encrypt it with
ansible-vault or sops. See .gitignore.
- laptop-old — Intel i5-2430M (Sandy Bridge), NVIDIA GT 520M (Fermi), 8 GB RAM, SSD + HDD, Optimus. Runs Linux Mint (Cinnamon).
- desktop-phenom — AMD Phenom X4 (AM2+, DDR2, legacy BIOS), NVIDIA GT 1030, 8 GB RAM, one SSD. Runs Linux Mint (Cinnamon), in its Wayland session.
- desktop-bazzite — main desktop, Bazzite (immutable, rpm-ostree). Uses a different
package model than apt; the
baserole's apt tasks are guarded and will not run here. - tablet-miix — Lenovo Miix 2 8 (Atom Z3740, 2 GB RAM), Debian 13 with Phosh, installed from the image tools/tablet-image builds. The image carries the hardware support and the core apps; the playbook adds access, /etc tracking and the further apps.
machine-setup/
├── README.md # this file — runbook + usage
├── AGENTS.md # instructions for coding agents (CLAUDE.md imports it)
├── bootstrap.sh # curl-able bootstrap (see Workflow step 2)
├── machine-setup.code-workspace # portable VS Code workspace
├── ansible.cfg # local-run defaults
├── inventory.ini # hosts and groups (all use local connection)
├── requirements.txt # pinned python toolchain (ansible-core, ansible-lint)
├── requirements.yml # galaxy collections — both Renovate-managed
├── scripts/
│ ├── apply.sh # run the playbook with the repo-local toolchain
│ ├── check.sh # release gate (CI runs exactly this)
│ ├── ensure-venv.sh # bootstraps .venv/ + .ansible/ (gitignored)
│ ├── luks-header-backup.sh # LUKS header backups (see roles/storage)
│ └── sudo-askpass.sh # sudo password from a dialog, for runs without a terminal
├── tools/ # standalone tooling — not part of the playbook
│ ├── gpu-tune/ # measure an AMD GPU undervolt (see its README)
│ ├── gtasa-intro/ # GTA San Andreas's intro videos under Proton
│ ├── mem-tune/ # measure memory latency, bandwidth, capacity
│ ├── mmiotrace-live/ # Mint live ISO for tracing the NVIDIA 390 driver over SSH
│ ├── patched-deb/ # Debian/Ubuntu packages rebuilt with our patches (iwd, i965)
│ ├── rescue-usb/ # SystemRescue stick that is driven over SSH
│ ├── resource-top/ # memory and CPU use per program
│ └── tablet-image/ # Debian + Phosh disk image for Bay Trail tablets
├── work/ # working files; in the repo, out of git (see AGENTS.md)
├── site.yml # maps each host to its enabled roles
├── group_vars/
│ └── all.yml # shared variables (primary_user, ssh_keys_dir)
├── host_vars/
│ ├── laptop-old/
│ │ ├── main.yml # per-host settings (roles_enabled, ...)
│ │ └── local.yml # machine identifiers — untracked; prompted+saved by site.yml
│ ├── desktop-bazzite/ # same layout (main.yml + untracked local.yml)
│ ├── desktop-phenom/ # same layout
│ └── tablet-miix/ # same layout
└── roles/
├── base/ # packages (present/absent), sudo; Mint desktop defaults (base_desktop)
├── ssh-access/ # hardened SSH server
├── printers/ # CUPS queues bound to the right driver
├── brave/ # browser (apt on Mint, rpm-ostree layer on Bazzite)
├── flatpaks/ # per-host Flatpak app set
├── appimages/ # Gear Lever's AppImage set + update sources
├── brew/ # Homebrew CLI packages (Bazzite)
├── git/ # global git settings
├── synology-drive/ # Synology Drive client
├── steam/ # Steam (+ flatpak on Bazzite)
├── lutris/ # Flatpak Lutris: game icons shared with the host
├── heroic/ # Heroic's default Wine: a Proton build from ProtonPlus
├── vscode/ # VS Code + settings + extensions + board udev rules
├── luks-unlock/ # remote root unlock at boot (dropbear in the initramfs)
├── keyring/ # KeePassXC as the SSH agent (both OSes)
├── kde/ # Plasma desktop tweaks (Bazzite)
├── phosh/ # Phosh session settings in dconf (tablet)
├── graphics/ # phantom VGA (laptop), NVIDIA driver (Phenom), AMD undervolt (Bazzite)
├── patched-debs/ # tools/patched-deb packages built on the host, installed and held
├── firewall/ # ufw
├── storage/ # data-disk crypttab/mount
├── wireguard/ # auto-VPN when away from home
├── containers/ # rootless Podman
├── journal/ # systemd journal size cap
├── connectivity-check/ # NetworkManager's connectivity check off (Bazzite)
├── zram/ # swap compressed in memory (Mint)
├── disk-health/ # SMART watch, self-tests, btrfs scrub, desktop alerts
├── system-updates/ # Mint automatic upgrades (packages, Flatpaks, spices) + Timeshift snapshots
├── applied/ # records the applied branch + commit in /etc
└── etckeeper/ # /etc in git (runs last)
The encrypted base install is interactive/destructive and is not scripted. Follow the runbook section.
Paste this into a terminal (as your normal user — not root; works anywhere with Internet — the repo is public on GitHub):
bash -c "$(curl -fsSL https://raw.githubusercontent.com/MrNoname3/machine-setup/main/bootstrap.sh)"It installs the bare minimum (git + python3-venv via apt on Mint; nothing on
Bazzite — both ship with the image), clones this repo to
~/Projects/machine-setup, then shows a host menu (read from inventory.ini,
so new machines appear automatically) and offers to run the playbook right
away. Idempotent — safe to re-run any time.
Ansible itself is not installed system-wide: scripts/apply.sh bootstraps
the pinned toolchain (requirements.txt + requirements.yml) into the
gitignored .venv/ + .ansible/ on first use. Deleting the repo directory
removes the whole toolchain with it.
Non-interactive variant (picks the host and runs immediately):
bash -c "$(curl -fsSL https://raw.githubusercontent.com/MrNoname3/machine-setup/main/bootstrap.sh)" -- laptop-oldThe
bash -c "$(curl ...)"form (instead ofcurl | bash) keeps stdin on the terminal so the menu can prompt.
From a branch — --branch (before the host) clones that branch, or switches
an existing clone to it; anything after the host goes to scripts/apply.sh,
here a dry run first:
bash -c "$(curl -fsSL https://raw.githubusercontent.com/MrNoname3/machine-setup/main/bootstrap.sh)" -- --branch my-branch laptop-old --check --diffWithout --branch, a new clone gets main and an existing one stays on its
branch and is fast-forwarded. The branch must already be on GitHub, which the
Gitea push mirror takes care of. The same by hand: pick 0) clone/update only
in the menu, then git -C ~/Projects/machine-setup switch my-branch and
./scripts/apply.sh laptop-old -K.
At home you can clone from the local Gitea instead (it mirrors to GitHub on
every push): prefix the command with REPO_URL=<gitea-clone-url>.
Always via scripts/apply.sh — it bootstraps/uses the repo-local toolchain and
runs ansible-playbook -c local with it. The host argument selects which
machine's configuration to apply (the "switch" for the multi-host repo); any
further arguments are passed straight to ansible-playbook.
laptop-old (Mint) — day-to-day (passwordless sudo is set up by the playbook; desktop-phenom runs the same way):
cd ~/Projects/machine-setup && git pull --ff-only
./scripts/apply.sh laptop-oldFirst run only (before passwordless sudo exists — -K asks the sudo password):
./scripts/apply.sh laptop-old -Kdesktop-bazzite — day-to-day (no passwordless sudo there):
cd ~/Projects/machine-setup && git pull --ff-only
./scripts/apply.sh desktop-bazzite -e ansible_become=falseFirst run, or whenever a task needs root (e.g. the KeePassXC flatpak install):
./scripts/apply.sh desktop-bazzite -KWithout a terminal to type into (from an IDE or an agent), a desktop dialog asks the password instead:
./scripts/apply.sh desktop-bazzite --become-password-file scripts/sudo-askpass.shtablet-miix — the image already gives the user passwordless sudo, so every run, the first included, is:
cd ~/Projects/machine-setup && git pull --ff-only
./scripts/apply.sh tablet-miixWhat a machine runs — every full run (not one limited by --tags) ends
by writing the branch and commit it applied, and whether the checkout had local
changes, to /etc/machine-setup-applied (the applied role), which etckeeper
then commits.
Dry run — show what would change (with diffs) without touching anything; works with any command above:
./scripts/apply.sh laptop-old --check --diff(Command/shell tasks are skipped in check mode, so on a fresh machine the
prediction is less complete than on an already-provisioned one. With the
bootstrap one-liner, pick 0) clone/update only, then dry-run by hand.)
Single role — the keyring, appimages and graphics roles are tagged; tag more
roles in site.yml as needed:
./scripts/apply.sh laptop-old --tags keyring
./scripts/apply.sh desktop-bazzite --tags appimages -e ansible_become=false
./scripts/apply.sh desktop-bazzite --tags graphics -KNote the difference on Bazzite: most roles there run unprivileged
(-e ansible_become=false), but graphics writes /usr/local/bin and
/etc/systemd/system, and storage writes crypttab and fstab, so they need
-K and a sudo password instead (storage skips itself without root).
Machine identifiers (hardened SSH port, trusted home-gateway MACs,
the private Gitea URL, optionally primary_user) live in
host_vars/<host>/local.yml, which is untracked — the public repo carries
no machine-specific identifiers. On a fresh machine the playbook prompts for
any missing value needed by an enabled role and saves it there (mode 0600), so
it only ever asks once. Two of the prompts help you fill them in and can be
postponed:
- Home-gateway MAC — it detects the current default gateway's MAC (correct only while you are on the home network) and asks you to confirm it, enter MAC(s) manually, or skip.
- Git origin — a checkout bootstrapped from the public GitHub mirror has
GitHub as its
origin; the playbook offers to repoint it to the private Gitea (y = enter the Gitea URL, n = keep the current origin and never ask again, s = skip and ask on the next interactive run).
Skipping (or a non-interactive run) simply leaves that value unset; the dependent role tasks skip cleanly until you set it on a later run. The SSH port has no safe default, so it stays required. You can also create the file by hand before the first run:
# host_vars/laptop-old/local.yml
ssh_port: 22
wg_home_gateway_macs:
- "aa:bb:cc:dd:ee:ff"
git_origin_url: "https://gitea.example/you/machine-setup.git" # or 'keep'Everything CI checks is one script, and CI runs exactly that script:
./scripts/check.shIt bootstraps its own toolchain into the gitignored .venv/ + .ansible/
(first run is slower; versions pinned in requirements.txt /
requirements.yml, both Renovate-managed — the same toolchain
scripts/apply.sh uses to run the playbook), then runs: ansible syntax
check → ansible-lint (production profile, config in .ansible-lint) →
secret scan over the tracked files (private keys, IPv4s, MACs, UUIDs — with
the documentation placeholders allowlisted).
Because the repo is public, the scan's built-in patterns are generic on
purpose. Personal identifiers (your domain, hostnames, ports, ...) go into
.secret-patterns.local — one extended regex per line, # comments
allowed. The file is gitignored and picked up automatically, so your private
patterns guard every local run without ever being published.
Lint exception policy: a rule violated by one deliberate task gets an inline
# noqa: <rule> next to a justifying comment; skip_list in .ansible-lint
is reserved for rules that would force a repo-wide rename (see the comments
there).
Move its name from packages_present to packages_absent in the relevant vars
file and re-run. Keep entries in packages_absent permanently so fresh installs
stay clean. Never list the same package in both.
The base install is interactive and destructive, so it stays a written runbook. It is deliberately short: everything that can be automated happens after first boot, via the bootstrap + playbook.
- Live USB — boot the Mint (Cinnamon) live USB and verify hardware first:
Wi-Fi, audio, and both GPUs show up (
inxi -G; Intel iGPU is the daily driver, NVIDIA via nouveau/PRIME is optional — see thegraphicsrole). - Install with full-disk encryption on the SSD only: choose Erase disk and install, tick Encrypt the new installation (LUKS), and pick a strong passphrase — this passphrase is the primary unlock and is never stored anywhere. Create the normal daily user when asked.
- Leave the HDD untouched. It is the encrypted data disk (
storagerole); a new, empty disk gets the role's one-time manual bootstrap (seeroles/storage/README.md) — the installer must not touch it. - First boot — log in and run the bootstrap one-liner (Workflow step 2). The playbook prompts once for the machine-local values (SSH port, and the postponable gateway-MAC / git-origin prompts) and applies everything else.
- Manual follow-ups that need secrets in hand (each documented in its
role's README): copy the data disk's keyfile from its KeePassXC entry to
/etc/cryptsetup-keys.d/data.keyand re-run the playbook (storage), import the WireGuard client configs (wireguard), and unlock/populate KeePassXC so it serves the SSH keys (keyring).
- Live USB — the board boots legacy BIOS only, so boot the Mint Cinnamon live
USB in BIOS mode. Check the network, audio and the GT 1030 (
inxi -G; the live system drives it with nouveau, the proprietary driver comes later). - Install with full-disk encryption: language English, keyboard layout Hungarian (the boot-time passphrase prompt uses it too), time zone Europe/Budapest. Erase disk and install, tick Encrypt the new installation (LUKS), and pick a strong passphrase — the primary unlock, never stored anywhere. Add a generated fallback passphrase to a second key slot afterwards and keep that one in KeePassXC.
- First boot — run the bootstrap one-liner (it suggests
desktop-phenomon a Phenom CPU), then./scripts/apply.sh desktop-phenom -K. Reboot: the NVIDIA driver and the dropbear initramfs take effect only then. - Manual follow-ups that need secrets in hand: import the WireGuard
client configs (
wireguard), and unlock/populate KeePassXC so it serves the SSH keys (keyring). The remote unlock is described inroles/luks-unlock/README.md. - Wayland — after the playbook, the login screen offers Cinnamon on
Wayland by default (
base_session); Brave decodes video on the GPU only there.
- Install Bazzite (KDE) on the NVMe system disk; the installer's encryption option is the way to an encrypted system disk. Leave both HDDs and the Windows SSD untouched.
- First boot — run the bootstrap one-liner, then
./scripts/apply.sh desktop-bazzite -K. The playbook stops at its manual gates (Synology Drive sign-in and initial sync, Brave sync, Steam sign-in) and asks once for the machine-local values. Reboot — the Brave layer and the amdgpu kernel argument take effect only then — and run it once more. - Data disks — copy each disk's keyfile from its KeePassXC entry to
/etc/cryptsetup-keys.d/<name>.key(root, mode 0400), then./scripts/apply.sh desktop-bazzite --tags storage -K. Without a keyfile a disk still opens with its fallback passphrase (seeroles/storage/README.md). The games are still on the data disk, but the launchers have to find them again: add/var/mnt/data/Games/SteamLibraryin Steam (Settings → Storage), and import each game installed under/mnt/data/Games/Heroicfrom its page in Heroic (Import Game). - AppImages —
./scripts/apply.sh desktop-bazzite --tags appimages -e ansible_become=false -e appimages_install_missing=true. - KeePassXC — unlock the database so it serves the SSH keys (
keyring). - Services from other repositories — once git can push to the forge (a
token in the credential store), clone
ai-stackandgitea-act-runnerinto~/Projectsand run theirscripts/setup.sh;ai-stackalso provides the Claude Code skills. - Manual by choice, not in the playbook:
- virtualization for virt-manager:
ujust setup-virtualization; QEMU and libvirt then run inside the flatpak, so virt-manager connects to QEMU/KVM User session (qemu:///session), not to the system connection - Waydroid:
sudo waydroid init -c https://ota.waydro.id/system -v https://ota.waydro.id/vendor -s VANILLA - Steam: default compatibility tool Proton-GE Latest, and local network game transfers (Settings → Downloads)
- Lutris game entries, KDE panels and widgets, the power profile
- Radmin VPN joins as a new device on its first start; Bluetooth devices need pairing again
- virtualization for virt-manager:
- Secure Boot (optional; a TPM unlock of an encrypted system disk needs
it) — enroll the uBlue key once with
ujust enroll-secure-boot-keyand confirm it in the MOK screen at the next boot, then switch Secure Boot on in the firmware (ujust bios).