Automate Linux Desktop Setup with Ansible and Dotfiles

If you reinstall Linux more than once a year, your setup process is probably still too manual. Most people keep a personal checklist in their head: install packages, copy shell config, fix fonts, reconfigure Git, set up SSH, restore editor plugins, and then spend the next week discovering what they forgot. That works until it does not. A failed SSD, a new laptop, or a distro hop exposes how fragile the workflow is.
A better model is to treat your desktop setup like infrastructure: declarative, version-controlled, and repeatable. Ansible handles package and system state, while GNU Stow manages dotfile symlinks cleanly. The result is a setup you can rebuild in 20 to 40 minutes with minimal hand edits, and one that keeps improving over time instead of drifting.
This guide walks through a practical structure that works on Debian/Ubuntu and Arch, can be adapted for WSL2 , and includes the missing pieces most posts skip: comparison with Chezmoi and Nix Home Manager , CI tests on fresh VMs, secret rotation, and rollback planning with filesystem snapshots.
Why automate your desktop setup
The obvious win is speed, but speed is not the main advantage. The main advantage is confidence. If every step of your environment is codified, you can wipe and rebuild without anxiety because you already tested that path.
Typical triggers where automation pays for itself:
- New laptop migration.
- Reinstall after disk corruption.
- Moving between Ubuntu and Arch.
- Standardizing a team laptop baseline.
- Rebuilding after experimentation broke local configs.
The reproducibility dividend matters long-term. With Git history, you can answer questions like: when did I switch shells, why did this terminal setting change, and which commit introduced this broken alias. Manual setup gives you no audit trail. Automated setup gives you controlled change.
Design a dotfiles repository that scales
Start with a Stow-friendly layout. Keep package directories tool-centric and mirror final paths under each package.
~/.dotfiles/
bash/
.bashrc
.bash_aliases
git/
.gitconfig
.config/git/ignore
nvim/
.config/nvim/init.lua
tmux/
.tmux.conf
alacritty/
.config/alacritty/alacritty.toml
scripts/
.local/bin/bootstrap-toolsWith this structure, stow nvim creates ~/.config/nvim/init.lua as a symlink to your repo. The package boundary is clean: enable only what you need on each machine.
Repository hygiene rules that prevent pain later:
- Never commit private keys, tokens, browser profiles, or machine-specific secrets.
- Keep
README.mdin the repo with bootstrap commands and role/tag descriptions. - Use local include files for machine-specific config, such as
~/.config/git/local, and source them from committed config. - Keep shell scripts POSIX-friendly when possible; your future system may not have your preferred shell yet.
A minimal .gitignore for dotfiles:
*.env
*.pem
*.key
secrets/
.cache/
.local/share/keyrings/Structure your Ansible project for desktop use
A simple role layout is enough for most personal setups and team baselines.
~/desktop-setup/
ansible.cfg
inventory/
hosts.ini
group_vars/
all.yml
host_vars/
laptop-work.yml
roles/
base/
shell/
editor/
terminal/
fonts/
devtools/
gui_apps/
dotfiles/
site.ymlsite.yml can stay compact while roles keep complexity contained:
---
- name: Configure Linux workstation
hosts: localhost
connection: local
become: true
vars:
dotfiles_repo: "https://github.com/yourusername/dotfiles.git"
roles:
- base
- shell
- editor
- terminal
- fonts
- devtools
- gui_apps
- dotfilesUse tags aggressively from day one. They turn your setup from one big script into a practical maintenance tool.
ansible-playbook -i inventory/hosts.ini site.yml --tags "editor,terminal"Idempotency is non-negotiable. If running the playbook twice causes unexpected changes, the setup is not production-ready yet.
Manage packages across distros without chaos
Package management is where many desktop playbooks become brittle. Keep shared packages in distro-agnostic tasks and isolate distro-specific packages by conditions.
Example role task using facts:
---
- name: Install common packages
ansible.builtin.package:
name:
- git
- curl
- stow
- tmux
- ripgrep
state: present
- name: Install Debian-specific packages
ansible.builtin.apt:
name:
- build-essential
- python3-pip
state: present
update_cache: true
when: ansible_facts['os_family'] == 'Debian'
- name: Install Arch-specific packages
community.general.pacman:
name:
- base-devel
- python-pip
state: present
update_cache: true
when: ansible_facts['distribution'] == 'Archlinux'Flatpak support is a practical bridge across distros for desktop apps:
- name: Install Flatpak apps
community.general.flatpak:
name:
- com.visualstudio.code
- md.obsidian.Obsidian
- org.gimp.GIMP
state: presentFor Arch AUR
packages, prefer explicit handling in a dedicated role and document the helper requirement (paru or yay). Keep AUR usage minimal for core tooling to reduce fragility.
Deploy dotfiles with Stow from Ansible
Ansible orchestrates, Stow links. Do not replace Stow with fragile custom symlink loops unless you have a very specific reason.
---
- name: Clone dotfiles repository
ansible.builtin.git:
repo: "{{ dotfiles_repo }}"
dest: "{{ ansible_env.HOME }}/.dotfiles"
version: main
- name: Ensure config directories exist
ansible.builtin.file:
path: "{{ ansible_env.HOME }}/.config"
state: directory
mode: "0755"
- name: Stow selected packages
ansible.builtin.command:
cmd: "stow --dir {{ ansible_env.HOME }}/.dotfiles --target {{ ansible_env.HOME }} {{ item }}"
args:
chdir: "{{ ansible_env.HOME }}/.dotfiles"
loop:
- bash
- git
- nvim
- tmux
- alacritty
changed_when: falseTwo real-world conflict strategies:
stow --adopt: pulls existing unmanaged files into your dotfiles tree. Good for first migration.- Backup then link: move existing files to
~/.config-backup/<date>/and stow cleanly. Better for strict repos.
Use Ansible templates for machine-specific values that should never be symlinked globally, such as git user.email, company proxy values, or host aliases.
Add fonts, shell, and developer toolchains
A complete desktop bootstrap goes beyond packages and symlinks. Fonts and language toolchains are often the hidden time sink.
Nerd Fonts example role:
---
- name: Download JetBrainsMono Nerd Font archive
ansible.builtin.get_url:
url: "https://github.com/ryanoasis/nerd-fonts/releases/latest/download/JetBrainsMono.zip"
dest: "/tmp/JetBrainsMono.zip"
- name: Unpack Nerd Font
ansible.builtin.unarchive:
src: "/tmp/JetBrainsMono.zip"
dest: "{{ ansible_env.HOME }}/.local/share/fonts"
remote_src: true
- name: Rebuild font cache
ansible.builtin.command: fc-cache -fv
changed_when: falseShell setup ideas:
- Install
zsh, set default shell if needed. - Install plugin manager (
zinit,antidote, or your preferred choice). - Link
.zshrcvia Stow. - Add a post-task that validates shell startup with
zsh -i -c exit.
For language/runtime versions, mise
is practical and cross-distro. Keep versions in .tool-versions, then let Ansible run mise install after package installation.
Bootstrap and day-to-day workflow
Fresh machines have a chicken-and-egg problem: you need Ansible to configure the machine, but Ansible is not installed yet. Solve it once with a minimal bootstrap script.
#!/usr/bin/env bash
set -euo pipefail
if command -v apt >/dev/null 2>&1; then
sudo apt update
sudo apt install -y git ansible stow
elif command -v pacman >/dev/null 2>&1; then
sudo pacman -Sy --noconfirm git ansible stow
else
echo "Unsupported package manager" >&2
exit 1
fi
git clone https://github.com/yourusername/desktop-setup.git "$HOME/desktop-setup"
cd "$HOME/desktop-setup"
ansible-playbook -i inventory/hosts.ini site.yml -KCommon commands you will actually use each week:
- Full apply:
ansible-playbook -i inventory/hosts.ini site.yml -K - Dry run:
ansible-playbook -i inventory/hosts.ini site.yml --check --diff -K - Partial apply:
ansible-playbook -i inventory/hosts.ini site.yml --tags "dotfiles" -K - Target host profile:
ansible-playbook site.yml -e profile=work -K
A small Makefile keeps muscle memory easy:
install:
ansible-playbook -i inventory/hosts.ini site.yml -K
check:
ansible-playbook -i inventory/hosts.ini site.yml --check --diff -K
dotfiles:
ansible-playbook -i inventory/hosts.ini site.yml --tags "dotfiles" -K
Ansible + Stow vs Chezmoi vs Nix Home Manager
This comparison is frequently skipped, but it determines whether your tooling matches your goals.
| Approach | Strengths | Trade-offs | Best fit |
|---|---|---|---|
| Ansible + Stow | Easy mental model, strong package/system automation, incremental adoption, works across many distros | More YAML to maintain, rollback not native, can become verbose | Most Linux users and teams wanting practical reproducibility |
| Chezmoi | Excellent dotfile templating, secret integrations, very fast dotfile workflows | Not a full system config manager by itself | Users focused mainly on dotfiles, not full machine bootstrap |
| Nix Home Manager | Declarative and reproducible to an extreme level, strong rollback model, consistent packages | Steeper learning curve, Nix ecosystem concepts are non-trivial | Users who want deep determinism and accept ecosystem shift |
Recommendation for most readers: start with Ansible + Stow. It teaches the right habits without forcing a full package manager paradigm change. If you later need stronger reproducibility and atomic rollbacks, migrate gradually toward Nix.
Secrets management and rotation plan
ansible-vault
solves encrypted storage, not lifecycle. A robust setup includes both storage and rotation.
Baseline pattern:
- Store encrypted vars in
group_vars/all/vault.yml. - Keep vault password outside the repo, preferably in a password manager.
- Reference secrets in templates and tasks, never hardcode in plain YAML.
Example usage:
ansible-vault create group_vars/all/vault.yml
ansible-vault edit group_vars/all/vault.yml
ansible-playbook site.yml --ask-vault-pass -KRotation policy you can enforce:
- API tokens: rotate every 90 days.
- SSH deploy keys: rotate every 180 days.
- GPG keys: evaluate yearly and replace on role change or compromise.
Practical implementation tip: create a security tag in Ansible for key regeneration and config propagation, then run it on schedule.
ansible-playbook site.yml --tags "security" --ask-vault-pass -KCI/CD test your setup on every push
If your playbook only runs on your personal laptop, breakage will slip in silently. Add CI that provisions a clean Linux VM/container, runs your playbook, and fails the build on regression.
A pragmatic GitHub Actions flow:
- Lint YAML and Ansible tasks (
ansible-lint). - Run syntax check.
- Launch fresh Ubuntu VM/container.
- Apply playbook in local mode.
- Run a smoke test script that verifies expected binaries and symlinks.
Minimal workflow sketch:
name: test-desktop-playbook
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Ansible
run: sudo apt update && sudo apt install -y ansible ansible-lint stow git
- name: Lint
run: ansible-lint
- name: Syntax check
run: ansible-playbook -i inventory/hosts.ini site.yml --syntax-check
- name: Dry run
run: ansible-playbook -i inventory/hosts.ini site.yml --check --connection=localYou can extend this with Molecule for role-level tests and multiple distro matrices (Ubuntu + Arch container images) once the basics are stable.
WSL2 support: what transfers and what does not
Many developers want one repo for Linux laptops and WSL2. That is realistic if you separate Linux-native UI pieces from terminal/dev tooling.
What transfers well to WSL2:
- Shell configuration (
bash,zsh, aliases, prompt). - Git config and SSH agent integration.
- Neovim/Tmux setup.
- CLI toolchains managed via
mise.
What needs conditional handling:
- GUI app installs via distro package manager or Flatpak.
- Systemd user services (WSL systemd availability varies by config).
- Linux desktop environment components (window manager, waybar, notification daemons).
Use Ansible facts to branch tasks cleanly:
- name: Skip Linux desktop packages on WSL
ansible.builtin.debug:
msg: "Skipping GUI desktop role on WSL"
when: ansible_facts['kernel'] is search('microsoft')This keeps one codebase while avoiding fragile hacks.
Troubleshooting and rollback strategy
Automation reduces mistakes, but when a bad change lands, recovery speed matters more than blame.
Common symptoms and fixes:
| Symptom | Likely cause | Fix |
|---|---|---|
stow refuses to link files | Existing unmanaged file conflicts | Backup existing file or run stow --adopt during first migration |
| Playbook always reports changed | Non-idempotent command task | Add creates, removes, or rewrite task with idempotent module |
| Missing packages on Arch only | Task condition mismatch on distro facts | Verify ansible_facts['distribution'] values with debug output |
| Fonts installed but not visible | Font cache not refreshed | Run fc-cache -fv and restart terminal/session |
| SSH setup applied but pushes fail | Key not loaded into agent | Ensure agent service is running and key is added |
For rollback, remember: Ansible does not provide transactional undo. Use filesystem snapshots before applying large updates.
Recommended pre-run snapshot commands:
# [Btrfs](https://btrfs.readthedocs.io/) example
sudo btrfs subvolume snapshot -r / @pre-ansible-$(date +%F-%H%M)
# [ZFS](https://openzfs.github.io/openzfs-docs/) example
sudo zfs snapshot rpool/ROOT@pre-ansible-$(date +%F-%H%M)Then run your playbook. If a run goes sideways, roll back from filesystem tooling, not by manually untangling dozens of changed files.
Final implementation checklist
Use this checklist to turn the approach into a working baseline quickly:
- Create dotfiles repo with Stow-compatible structure.
- Build Ansible role layout (
base,devtools,dotfiles,fonts,shell). - Implement distro-aware package tasks.
- Add dotfiles clone + Stow tasks.
- Add vault-encrypted secrets and a
securityrotation tag. - Add bootstrap script for fresh installs.
- Add CI workflow for lint/syntax/dry-run checks.
- Add WSL2 conditionals for desktop-specific roles.
- Snapshot filesystem before major apply runs.
Once this is in place, rebuilding your desktop stops being a manual project and becomes a repeatable command. That is the real goal: not just a faster reinstall, but a Linux environment you can reason about, test, and evolve like any other serious codebase.