Contents

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-tools

With 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.md in 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.yml

site.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
    - dotfiles

Use 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: present

For 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: false

Two 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: false

Shell setup ideas:

  • Install zsh , set default shell if needed.
  • Install plugin manager (zinit, antidote, or your preferred choice).
  • Link .zshrc via 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 -K

Common 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.

ApproachStrengthsTrade-offsBest fit
Ansible + StowEasy mental model, strong package/system automation, incremental adoption, works across many distrosMore YAML to maintain, rollback not native, can become verboseMost Linux users and teams wanting practical reproducibility
ChezmoiExcellent dotfile templating, secret integrations, very fast dotfile workflowsNot a full system config manager by itselfUsers focused mainly on dotfiles, not full machine bootstrap
Nix Home ManagerDeclarative and reproducible to an extreme level, strong rollback model, consistent packagesSteeper learning curve, Nix ecosystem concepts are non-trivialUsers 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 -K

Rotation 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 -K

CI/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:

  1. Lint YAML and Ansible tasks (ansible-lint ).
  2. Run syntax check.
  3. Launch fresh Ubuntu VM/container.
  4. Apply playbook in local mode.
  5. 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=local

You 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:

SymptomLikely causeFix
stow refuses to link filesExisting unmanaged file conflictsBackup existing file or run stow --adopt during first migration
Playbook always reports changedNon-idempotent command taskAdd creates, removes, or rewrite task with idempotent module
Missing packages on Arch onlyTask condition mismatch on distro factsVerify ansible_facts['distribution'] values with debug output
Fonts installed but not visibleFont cache not refreshedRun fc-cache -fv and restart terminal/session
SSH setup applied but pushes failKey not loaded into agentEnsure 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 security rotation 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.