Automating My Linux Setup: Dotfiles + Stow + Ansible

Automating My Linux Setup: Dotfiles + Stow + Ansible

Almost every couple of months, I reinstall my OS. Most of the time, it’s because of some NVIDIA driver nonsense. Sometimes I break the system myself. Sometimes I just want to try a new distro.

And every single time, I end up spending an entire day putting everything back the way I like it. Installing apps, logging into accounts, tweaking settings, trying to remember which extensions I was using… it gets exhausting fast.

So eventually, I decided to fix this.

Now my entire setup lives in a Git repository. When I install a fresh system, I run a single command and go make a tea. By the time I’m back, my apps are installed, my configs are in place, my desktop looks familiar, and my shortcuts work exactly how I expect them to.

This isn’t just a “programmer thing”, by the way. If you’ve ever lost your settings, had to rebuild your desktop layout after a crash, or wished you could clone your setup onto a new machine, this approach works for you too.

What I Actually Use

The whole setup is built around three tools:

Git
You probably already know this one. It tracks changes to files. I use it to version, back up, and sync all my configuration files.

GNU Stow
This one is less well-known, but incredibly useful. Stow creates symbolic links. Instead of copying config files all over your home directory, it links them from a single central folder. You edit the file once, and the change is reflected everywhere.

If you want a great explanation of how to use Stow with dotfiles, this video by Dreams of Autonomy covers it really well.

Ansible
Ansible is usually associated with servers, but it works just as well for personal machines. You write a playbook describing what should be installed and how things should be configured, and Ansible takes care of the rest. You can run it once or run it ten times. It only changes what actually needs changing.

How It’s Organized

I keep a dotfiles directory in my home folder. Inside it, I mirror the structure of my actual home directory:

~/dotfiles/
├── .config/
│   ├── ghostty/      (my terminal settings)
│   ├── gh/           (GitHub CLI config)
│   └── ...
├── .zshrc            (shell configuration)
├── .gitconfig        (git settings)
└── ansible/
    └── playbook.yaml (the automation script)

When I run stow . inside this folder, Stow creates symlinks:

  • ~/.zshrc points to ~/dotfiles/.zshrc
  • ~/.config/ghostty points to ~/dotfiles/.config/ghostty

The real files live in the dotfiles directory, but the system still sees them in their usual locations. And since that directory is a Git repository, every change is tracked and backed up automatically.

The Ansible Part

This is where most of the automation happens.

Before jumping into examples, a quick note on what Ansible actually is and why I use it here.

Ansible is a configuration management and automation tool. Instead of manually installing packages, editing config files, and running random shell commands, you describe the desired end state of your system in a file called a playbook. Ansible then figures out what needs to be done to reach that state.

The important part is that Ansible is idempotent. You can run the same playbook multiple times, and it will only make changes when something is missing or different. If a package is already installed, it skips it. If a setting is already applied, it does nothing. That makes it safe to re-run whenever you update your setup.

In my case, I use Ansible as the glue that ties everything together. It installs system packages, sets desktop preferences, installs tools that are not in the default repositories, clones my dotfiles repository, and finally runs Stow to link everything into place. Once the playbook finishes, the system is in a known and repeatable state.

Here are some examples from my playbook.

Installing packages

This is the most basic use of Ansible. I declare which packages I want on the system, and Ansible installs any that are missing. If everything is already installed, this step is skipped automatically.

- name: Install essential packages
  become: true
  apt:
    name:
      - curl
      - wget
      - git
      - stow
      - htop
      - jq
      - yq
    update_cache: yes

The become: true line tells Ansible to run this task with sudo. I also let Ansible update the package cache so the list is always fresh.

Desktop preferences (GNOME)

Here I use gsettings to apply GNOME-specific preferences. Each setting is its own task, so it is easy to tweak or remove later.

- name: Set GNOME to dark mode
  command: gsettings set org.gnome.desktop.interface color-scheme 'prefer-dark'
  when: is_gnome

The when: is_gnome condition ensures these commands only run on GNOME systems. On other desktop environments, they are skipped entirely.

Installing GNOME extensions

GNOME extensions are not available through the default package manager, so I install a small CLI tool first and then use it to manage extensions.

- name: Install gnome-extensions-cli
  command: pipx install gnome-extensions-cli
  args:
    creates: "{{ ansible_facts['env'].HOME }}/.local/bin/gext"
  when: is_gnome

The creates argument makes this task idempotent. If the binary already exists, Ansible does not run the command again.

Apps outside the default repositories

Some applications are not available in the system repositories, or I prefer to install them directly from the vendor. In those cases, I fall back to shell commands or Flatpak.

- name: Install Brave browser
  become: true
  shell: curl -fsS https://dl.brave.com/install.sh | sh
  args:
    creates: /usr/bin/brave-browser

This looks a bit imperative, but the creates check prevents the installer from running more than once.

Laptop-specific tweaks

Ansible facts let me detect which machine the playbook is running on. I use that to apply hardware-specific tweaks only where they make sense.

- name: Create rc.local startup script
  become: true
  copy:
    dest: /etc/rc.local
    content: |
      #!/bin/sh -e
      echo 1 | tee /sys/devices/system/cpu/intel_pstate/no_turbo > /dev/null
      exit 0
  when: ansible_facts['product_name'] == "Pulse GL66 12UEK"

Because of the condition, this task only runs on my MSI laptop. On any other machine, Ansible simply skips it.

Version pinning

For some tools, I want exact versions to keep my environment consistent across machines. I define the versions as variables and let Ansible check whether an update is needed.

vars:
  fzf_version: "0.67.0"
  go_version: "1.25.6"

If the installed version does not match the variable, Ansible removes the old one and installs the correct version.

Cloning and linking dotfiles

This is where everything comes together. Ansible clones my dotfiles repository and then runs Stow to link the files into their correct locations.

- name: Clone dotfiles repository
  git:
    repo: "https://{{ git_dotfiles_username }}:{{ git_dotfiles_token }}@{{ git_dotfiles_repo }}"
    dest: "{{ ansible_facts['env'].HOME }}/dotfiles"
    clone: yes

After that, Stow handles the symlinks so the system picks up the configuration automatically.

The Full Playbook

Click to expand the full playbook.yaml

---
- name: Configure desktop
  hosts: localhost
  connection: local

  vars:
    fzf_version: "0.67.0"
    go_version: "1.25.6"
    nvm_version: "0.40.3"
    node_version: "24"
    kubectl_version: "v1.35.0"
    kind_version: "0.31.0"
    heroic_version: "2.18.1"

    # Secrets - use environment variables or ansible-vault in real setup
    git_dotfiles_repo: "your-git-server.com/username/dotfiles.git"
    git_dotfiles_username: "your-username"
    git_dotfiles_token: "your-token-here"

  tasks:
    - name: Set GNOME desktop fact
      set_fact:
        is_gnome: "{{ ansible_facts['env'].XDG_CURRENT_DESKTOP is defined and 'GNOME' in ansible_facts['env'].XDG_CURRENT_DESKTOP }}"

    - name: Install essential packages
      become: true
      apt:
        name:
          - curl
          - wget
          - git
          - stow
          - htop
          - jq
          - yq
        update_cache: yes
      when: ansible_facts['os_family'] == "Debian"

    - name: Add Taskfile repository
      become: true
      shell: curl -1sLf 'https://dl.cloudsmith.io/public/task/task/setup.deb.sh' | bash
      args:
        creates: /etc/apt/sources.list.d/task-task.list
      when: ansible_facts['os_family'] == "Debian"

    - name: Install secondary packages
      become: true
      apt:
        name:
          - bat
          - zsh
          - vlc
          - task
          - flatpak
          - pipx
          - lm-sensors
        state: present
        update_cache: yes
      when: ansible_facts['os_family'] == "Debian"

    - name: Check if fzf exists
      stat:
        path: /usr/local/bin/fzf
      register: fzf_exists

    - name: Get fzf version
      command: /usr/local/bin/fzf --version
      register: fzf_current_version
      changed_when: false
      when: fzf_exists.stat.exists

    - name: Remove fzf if version differs
      become: true
      file:
        path: /usr/local/bin/fzf
        state: absent
      when: fzf_exists.stat.exists and fzf_version not in fzf_current_version.stdout

    - name: Download and install fzf
      become: true
      unarchive:
        src: "https://github.com/junegunn/fzf/releases/download/v{{ fzf_version }}/fzf-{{ fzf_version }}-linux_amd64.tar.gz"
        dest: /usr/local/bin/
        remote_src: yes
        mode: "0755"
      when: not fzf_exists.stat.exists or fzf_version not in fzf_current_version.stdout

    - name: Install Oh My Zsh
      shell: sh -c "$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)" "" --unattended
      args:
        creates: "{{ ansible_facts['env'].HOME }}/.oh-my-zsh"

    - name: Set zsh as default shell
      become: true
      user:
        name: "{{ ansible_facts['env'].USER }}"
        shell: /usr/bin/zsh

    - name: Install zsh-autosuggestions plugin
      git:
        repo: https://github.com/zsh-users/zsh-autosuggestions
        dest: "{{ ansible_facts['env'].HOME }}/.oh-my-zsh/custom/plugins/zsh-autosuggestions"
        clone: yes
        update: yes

    - name: Install zsh-syntax-highlighting plugin
      git:
        repo: https://github.com/zsh-users/zsh-syntax-highlighting.git
        dest: "{{ ansible_facts['env'].HOME }}/.oh-my-zsh/custom/plugins/zsh-syntax-highlighting"
        clone: yes
        update: yes

    - name: Clone dotfiles repository
      git:
        repo: "https://{{ git_dotfiles_username }}:{{ git_dotfiles_token }}@{{ git_dotfiles_repo }}"
        dest: "{{ ansible_facts['env'].HOME }}/dotfiles"
        clone: yes
        update: no

    - name: List all files and directories in dotfiles
      find:
        paths: "{{ ansible_facts['env'].HOME }}/dotfiles"
        hidden: yes
        file_type: any
      register: dotfiles_items

    - name: Remove matching items from HOME directory
      file:
        path: "{{ ansible_facts['env'].HOME }}/{{ item.path | basename }}"
        state: absent
      loop: "{{ dotfiles_items.files }}"
      when: item.path | basename != '.git'

    - name: Stow dotfiles
      command: stow .
      args:
        chdir: "{{ ansible_facts['env'].HOME }}/dotfiles"

    - name: Install Docker dependencies
      become: true
      apt:
        name:
          - ca-certificates
          - gnupg
        state: present
      when: ansible_facts['os_family'] == "Debian"

    - name: Add Docker GPG key
      become: true
      apt_key:
        url: https://download.docker.com/linux/ubuntu/gpg
        state: present
      when: ansible_facts['os_family'] == "Debian"

    - name: Add Docker repository
      become: true
      apt_repository:
        repo: "deb https://download.docker.com/linux/ubuntu {{ ansible_facts['distribution_release'] }} stable"
        state: present
      when: ansible_facts['os_family'] == "Debian"

    - name: Install Docker
      become: true
      apt:
        name:
          - docker-ce
          - docker-ce-cli
          - containerd.io
          - docker-buildx-plugin
          - docker-compose-plugin
        state: present
        update_cache: yes
      when: ansible_facts['os_family'] == "Debian"

    - name: Add user to docker group
      become: true
      user:
        name: "{{ ansible_facts['env'].USER }}"
        groups: docker
        append: yes

    - name: Enable and start Docker service
      become: true
      systemd:
        name: docker
        enabled: yes
        state: started

    - name: Check if Go exists
      stat:
        path: /usr/local/go/bin/go
      register: go_exists

    - name: Get Go version
      command: /usr/local/go/bin/go version
      register: go_current_version
      changed_when: false
      when: go_exists.stat.exists

    - name: Remove Go if version differs
      become: true
      file:
        path: /usr/local/go
        state: absent
      when: go_exists.stat.exists and go_version not in go_current_version.stdout

    - name: Download and install Go
      become: true
      unarchive:
        src: "https://go.dev/dl/go{{ go_version }}.linux-amd64.tar.gz"
        dest: /usr/local
        remote_src: yes
      when: not go_exists.stat.exists or go_version not in go_current_version.stdout

    - name: Install Rust
      shell: curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
      args:
        creates: "{{ ansible_facts['env'].HOME }}/.cargo/bin/rustc"

    - name: Check if nvm exists
      stat:
        path: "{{ ansible_facts['env'].HOME }}/.nvm/nvm.sh"
      register: nvm_exists

    - name: Get nvm version
      shell: |
        export NVM_DIR="{{ ansible_facts['env'].HOME }}/.nvm"
        . "$NVM_DIR/nvm.sh"
        nvm --version
      args:
        executable: /bin/bash
      register: nvm_current_version
      changed_when: false
      when: nvm_exists.stat.exists

    - name: Remove nvm if version differs
      file:
        path: "{{ ansible_facts['env'].HOME }}/.nvm"
        state: absent
      when: nvm_exists.stat.exists and nvm_version not in nvm_current_version.stdout

    - name: Install nvm
      shell: curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v{{ nvm_version }}/install.sh | bash
      when: not nvm_exists.stat.exists or nvm_version not in nvm_current_version.stdout

    - name: Get current Node.js version
      shell: |
        export NVM_DIR="{{ ansible_facts['env'].HOME }}/.nvm"
        . "$NVM_DIR/nvm.sh"
        node --version 2>/dev/null || echo "none"
      args:
        executable: /bin/bash
      register: node_current_version
      changed_when: false

    - name: Install Node.js via nvm
      shell: |
        export NVM_DIR="{{ ansible_facts['env'].HOME }}/.nvm"
        . "$NVM_DIR/nvm.sh"
        nvm install {{ node_version }}
        nvm alias default {{ node_version }}
      args:
        executable: /bin/bash
      when: node_version not in node_current_version.stdout

    - name: Install uv
      shell: curl -LsSf https://astral.sh/uv/install.sh | sh
      args:
        creates: "{{ ansible_facts['env'].HOME }}/.local/bin/uv"

    - name: Check if kubectl exists
      stat:
        path: /usr/local/bin/kubectl
      register: kubectl_exists

    - name: Get kubectl version
      command: /usr/local/bin/kubectl version --client -o yaml
      register: kubectl_current_version
      changed_when: false
      when: kubectl_exists.stat.exists

    - name: Remove kubectl if version differs
      become: true
      file:
        path: /usr/local/bin/kubectl
        state: absent
      when: kubectl_exists.stat.exists and kubectl_version not in kubectl_current_version.stdout

    - name: Download and install kubectl
      become: true
      get_url:
        url: "https://dl.k8s.io/release/{{ kubectl_version }}/bin/linux/amd64/kubectl"
        dest: /usr/local/bin/kubectl
        mode: "0755"
      when: not kubectl_exists.stat.exists or kubectl_version not in kubectl_current_version.stdout

    - name: Install krew
      shell: |
        cd "$(mktemp -d)" &&
        OS="$(uname | tr '[:upper:]' '[:lower:]')" &&
        ARCH="$(uname -m | sed -e 's/x86_64/amd64/' -e 's/\(arm\)\(64\)\?.*/\1\2/' -e 's/aarch64$/arm64/')" &&
        KREW="krew-${OS}_${ARCH}" &&
        curl -fsSLO "https://github.com/kubernetes-sigs/krew/releases/latest/download/${KREW}.tar.gz" &&
        tar zxvf "${KREW}.tar.gz" &&
        ./"${KREW}" install krew
      args:
        executable: /bin/bash
        creates: "{{ ansible_facts['env'].HOME }}/.krew/bin/kubectl-krew"

    - name: Install krew plugin ctx
      shell: |
        export PATH="${KREW_ROOT:-$HOME/.krew}/bin:$PATH"
        kubectl krew install ctx
      args:
        executable: /bin/bash
        creates: "{{ ansible_facts['env'].HOME }}/.krew/store/ctx"

    - name: Install krew plugin ns
      shell: |
        export PATH="${KREW_ROOT:-$HOME/.krew}/bin:$PATH"
        kubectl krew install ns
      args:
        executable: /bin/bash
        creates: "{{ ansible_facts['env'].HOME }}/.krew/store/ns"

    - name: Check if kind exists
      stat:
        path: /usr/local/bin/kind
      register: kind_exists

    - name: Get kind version
      command: /usr/local/bin/kind version
      register: kind_current_version
      changed_when: false
      when: kind_exists.stat.exists

    - name: Remove kind if version differs
      become: true
      file:
        path: /usr/local/bin/kind
        state: absent
      when: kind_exists.stat.exists and kind_version not in kind_current_version.stdout

    - name: Download and install kind
      become: true
      get_url:
        url: "https://kind.sigs.k8s.io/dl/v{{ kind_version }}/kind-linux-amd64"
        dest: /usr/local/bin/kind
        mode: "0755"
      when: not kind_exists.stat.exists or kind_version not in kind_current_version.stdout

    - name: Install Helm
      shell: curl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash
      args:
        creates: /usr/local/bin/helm

    - name: Install Brave browser
      become: true
      shell: curl -fsS https://dl.brave.com/install.sh | sh
      args:
        creates: /usr/bin/brave-browser

    - name: Check if Google Chrome exists
      stat:
        path: /usr/bin/google-chrome
      register: chrome_exists
      when: ansible_facts['os_family'] == "Debian"

    - name: Download Google Chrome
      get_url:
        url: https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb
        dest: /tmp/google-chrome.deb
      when: ansible_facts['os_family'] == "Debian" and not chrome_exists.stat.exists

    - name: Install Google Chrome
      become: true
      apt:
        deb: /tmp/google-chrome.deb
      when: ansible_facts['os_family'] == "Debian" and not chrome_exists.stat.exists

    - name: Install Ghostty
      become: true
      snap:
        name: ghostty
        state: present
        classic: true

    - name: Check if Postman exists
      stat:
        path: /opt/Postman
      register: postman_exists

    - name: Download and install Postman
      become: true
      unarchive:
        src: https://dl.pstmn.io/download/latest/linux_64
        dest: /opt/
        remote_src: yes
      when: not postman_exists.stat.exists

    - name: Create Postman symlink
      become: true
      file:
        src: /opt/Postman/Postman
        dest: /usr/local/bin/postman
        state: link
      when: not postman_exists.stat.exists

    - name: Create Postman desktop entry
      become: true
      copy:
        dest: /usr/share/applications/postman.desktop
        content: |
          [Desktop Entry]
          Type=Application
          Name=Postman
          Icon=/opt/Postman/app/resources/app/assets/icon.png
          Exec=/opt/Postman/Postman
          Comment=Postman API Platform
          Categories=Development;
          Terminal=false
        mode: "0644"

    - name: Install Zed
      shell: curl -f https://zed.dev/install.sh | sh
      args:
        creates: "{{ ansible_facts['env'].HOME }}/.local/bin/zed"

    - name: Check if Heroic exists
      stat:
        path: /usr/bin/heroic
      register: heroic_exists
      when: ansible_facts['os_family'] == "Debian"

    - name: Download Heroic Games Launcher
      get_url:
        url: "https://github.com/Heroic-Games-Launcher/HeroicGamesLauncher/releases/download/v{{ heroic_version }}/Heroic-{{ heroic_version }}-linux-amd64.deb"
        dest: /tmp/heroic.deb
      when: ansible_facts['os_family'] == "Debian" and not heroic_exists.stat.exists

    - name: Install Heroic Games Launcher
      become: true
      apt:
        deb: /tmp/heroic.deb
      when: ansible_facts['os_family'] == "Debian" and not heroic_exists.stat.exists

    - name: Check if Discord exists
      stat:
        path: /usr/bin/discord
      register: discord_exists

    - name: Download Discord
      get_url:
        url: "https://discord.com/api/download?platform=linux&format=deb"
        dest: /tmp/discord.deb
      when: not discord_exists.stat.exists

    - name: Install Discord
      become: true
      apt:
        deb: /tmp/discord.deb
      when: not discord_exists.stat.exists

    - name: Check if Steam exists
      stat:
        path: /usr/bin/steam
      register: steam_exists
      when: ansible_facts['os_family'] == "Debian"

    - name: Download Steam
      get_url:
        url: "https://cdn.fastly.steamstatic.com/client/installer/steam.deb"
        dest: /tmp/steam.deb
      when: ansible_facts['os_family'] == "Debian" and not steam_exists.stat.exists

    - name: Install Steam
      become: true
      apt:
        deb: /tmp/steam.deb
      when: ansible_facts['os_family'] == "Debian" and not steam_exists.stat.exists

    - name: Add Flathub repository
      become: true
      command: flatpak remote-add --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo
      changed_when: false

    - name: Install Telegram via Flatpak
      community.general.flatpak:
        name: org.telegram.desktop
        state: present

    - name: Install Fragments via Flatpak
      community.general.flatpak:
        name: de.haeckerfelix.Fragments
        state: present

    - name: Install GNOME Boxes via Flatpak
      community.general.flatpak:
        name: org.gnome.Boxes
        state: present

    - name: Install OnlyOffice via Flatpak
      community.general.flatpak:
        name: org.onlyoffice.desktopeditors
        state: present

    - name: Install Thunderbird via Flatpak
      community.general.flatpak:
        name: org.mozilla.Thunderbird
        state: present

    - name: Download OpenRGB AppImage
      get_url:
        url: "https://codeberg.org/OpenRGB/OpenRGB/releases/download/release_candidate_1.0rc2/OpenRGB_1.0rc2_x86_64_0fca93e.AppImage"
        dest: "{{ ansible_facts['env'].HOME }}/.local/bin/openrgb"
        mode: "0755"
      when: ansible_facts['product_name'] == "Pulse GL66 12UEK"

    - name: Download OpenRGB udev install script
      get_url:
        url: "https://openrgb.org/releases/release_0.9/openrgb-udev-install.sh"
        dest: /tmp/openrgb-udev-install.sh
        mode: "0755"
      when: ansible_facts['product_name'] == "Pulse GL66 12UEK"

    - name: Run OpenRGB udev install script
      become: true
      command: /tmp/openrgb-udev-install.sh
      when: ansible_facts['product_name'] == "Pulse GL66 12UEK"

    - name: Create rc.local startup script
      become: true
      copy:
        dest: /etc/rc.local
        content: |
          #!/bin/sh -e

          /home/aykhan/.local/bin/openrgb -d "MSI MysticLight MS-1563 v0001" -m off
          echo 1 | tee /sys/devices/system/cpu/intel_pstate/no_turbo > /dev/null

          exit 0
        mode: "0755"
      when: ansible_facts['product_name'] == "Pulse GL66 12UEK"

    - name: Install GNOME Software Flatpak plugin
      become: true
      apt:
        name: gnome-software-plugin-flatpak
        state: present
      when: is_gnome

    - name: Add Azerbaijani keyboard layout
      shell: |
        CURRENT=$(gsettings get org.gnome.desktop.input-sources sources)
        if [[ "$CURRENT" != *"'az'"* ]]; then
          STRIPPED=$(echo "$CURRENT" | sed 's/@a(ss) //')
          if [[ "$STRIPPED" == "[]" ]]; then
            gsettings set org.gnome.desktop.input-sources sources "[('xkb', 'az')]"
          else
            NEW=$(echo "$STRIPPED" | sed "s/]/, ('xkb', 'az')]/")
            gsettings set org.gnome.desktop.input-sources sources "$NEW"
          fi
        fi
      args:
        executable: /bin/bash
      when: is_gnome

    - name: Set F12 as next track shortcut
      command: gsettings set org.gnome.settings-daemon.plugins.media-keys next "['F12']"
      when: is_gnome and ansible_facts['product_name'] == "Pulse GL66 12UEK"

    - name: Set GNOME to dark mode
      command: gsettings set org.gnome.desktop.interface color-scheme 'prefer-dark'
      when: is_gnome

    - name: Show battery percentage
      command: gsettings set org.gnome.desktop.interface show-battery-percentage true
      when: is_gnome

    - name: Hide home icon on desktop
      command: gsettings set org.gnome.shell.extensions.ding show-home false
      when: is_gnome

    - name: Hide trash icon on dock
      command: gsettings set org.gnome.shell.extensions.dash-to-dock show-trash false
      when: is_gnome

    - name: Hide show apps button on dock
      command: gsettings set org.gnome.shell.extensions.dash-to-dock show-show-apps-button false
      when: is_gnome

    - name: Disable fixed dock
      command: gsettings set org.gnome.shell.extensions.dash-to-dock dock-fixed false
      when: is_gnome

    - name: Disable dock extend height
      command: gsettings set org.gnome.shell.extensions.dash-to-dock extend-height false
      when: is_gnome

    - name: Allow volume above 100 percent
      command: gsettings set org.gnome.desktop.sound allow-volume-above-100-percent true
      when: is_gnome

    - name: Install gnome-extensions-cli
      command: pipx install gnome-extensions-cli
      args:
        creates: "{{ ansible_facts['env'].HOME }}/.local/bin/gext"
      when: is_gnome

    - name: Install and enable Bluetooth Battery Meter extension
      shell: ~/.local/bin/gext install Bluetooth-Battery-Meter@maniacx.github.com && ~/.local/bin/gext enable Bluetooth-Battery-Meter@maniacx.github.com
      args:
        creates: "{{ ansible_facts['env'].HOME }}/.local/share/gnome-shell/extensions/Bluetooth-Battery-Meter@maniacx.github.com"
      when: is_gnome

    - name: Install and enable Easy Docker Containers extension
      shell: ~/.local/bin/gext install easy_docker_containers@red.software.systems && ~/.local/bin/gext enable easy_docker_containers@red.software.systems
      args:
        creates: "{{ ansible_facts['env'].HOME }}/.local/share/gnome-shell/extensions/easy_docker_containers@red.software.systems"
      when: is_gnome

    - name: Install and enable Clipboard Indicator extension
      shell: ~/.local/bin/gext install clipboard-indicator@tudmotu.com && ~/.local/bin/gext enable clipboard-indicator@tudmotu.com
      args:
        creates: "{{ ansible_facts['env'].HOME }}/.local/share/gnome-shell/extensions/clipboard-indicator@tudmotu.com"
      when: is_gnome

    - name: Install and enable GSConnect extension
      shell: ~/.local/bin/gext install gsconnect@andyholmes.github.io && ~/.local/bin/gext enable gsconnect@andyholmes.github.io
      args:
        creates: "{{ ansible_facts['env'].HOME }}/.local/share/gnome-shell/extensions/gsconnect@andyholmes.github.io"
      when: is_gnome

    - name: Install and enable Freon extension
      shell: ~/.local/bin/gext install freon@UshakovVasilii_Github.yahoo.com && ~/.local/bin/gext enable freon@UshakovVasilii_Github.yahoo.com
      args:
        creates: "{{ ansible_facts['env'].HOME }}/.local/share/gnome-shell/extensions/freon@UshakovVasilii_Github.yahoo.com"
      when: is_gnome

    - name: Install and enable Net Speed extension
      shell: ~/.local/bin/gext install netspeed@alynx.one && ~/.local/bin/gext enable netspeed@alynx.one
      args:
        creates: "{{ ansible_facts['env'].HOME }}/.local/share/gnome-shell/extensions/netspeed@alynx.one"
      when: is_gnome

    - name: Install and enable Resource Monitor extension
      shell: ~/.local/bin/gext install Resource_Monitor@Ory0n && ~/.local/bin/gext enable Resource_Monitor@Ory0n
      args:
        creates: "{{ ansible_facts['env'].HOME }}/.local/share/gnome-shell/extensions/Resource_Monitor@Ory0n"
      when: is_gnome

    - name: Install and enable RunCat extension
      shell: ~/.local/bin/gext install runcat@kolesnikov.se && ~/.local/bin/gext enable runcat@kolesnikov.se
      args:
        creates: "{{ ansible_facts['env'].HOME }}/.local/share/gnome-shell/extensions/runcat@kolesnikov.se"
      when: is_gnome

    - name: Install and enable Search Light extension
      shell: ~/.local/bin/gext install search-light@icedman.github.com && ~/.local/bin/gext enable search-light@icedman.github.com
      args:
        creates: "{{ ansible_facts['env'].HOME }}/.local/share/gnome-shell/extensions/search-light@icedman.github.com"
      when: is_gnome
  

What I Do on a Fresh Install

My workflow on a brand-new system is simple:

  1. Boot into the new OS
  2. Install git and ansible (the only manual step)
  3. Run the playbook
  4. Done
sudo apt install git ansible
cd ~/dotfiles/ansible
ansible-playbook playbook.yaml --ask-become-pass

The playbook handles cloning the dotfiles repo, removing conflicting files, and running Stow. Everything else is automated.

Day-to-Day Usage

Whenever I change something, add an alias, tweak a config, or install a new app, I update my dotfiles and push them to Git.

cd ~/dotfiles
git add -A
git commit -m "added shortcut for clearing terminal"
git push

If I add or remove something managed by Ansible, I update the playbook and run it again. Ansible skips anything that’s already in the desired state.

A Note on Security

My dotfiles live in a private Git repository, and I am realistic about the trade-offs. Some credentials and tokens are stored in the repo as well. I do not recommend doing this blindly, but for my personal setup, it is a conscious decision.

The credentials I commit are low-risk, easy to rotate, or very tightly scoped. Anything that would cause serious damage if leaked is kept out of the repository. Even so, I do not rely on privacy alone. I assume that anything in Git could leak one day, and plan accordingly.

If you decide to commit credentials too, avoid doing it under your main accounts. Do not keep this kind of repository under your primary GitHub account, especially if it is tied to your real identity or professional work. A safer option is to use a self-hosted Git service or a separate GitLab or GitHub account that is isolated from your main one.

It Doesn’t Automate Everything

This setup is not perfect, and I am fine with that. Some things, especially GNOME extension settings, live in dconf and are not always easy to export cleanly. Some applications store configs in odd places or change formats between versions.

Linux is messy. Settings are scattered across files, formats, and directories. GNOME settings in particular live in a binary database. While gsettings and dconf help, they are not always consistent.

After running the playbook, I still have to do a few manual steps. Log into accounts, tweak a couple of extension settings, adjust some app preferences. But that takes maybe 20 to 25 minutes instead of an entire day.

The goal is not perfection. The goal is to automate most of the setup so the remaining work is just minor cleanup.

You Don’t Have to Go All In

I didn’t build this overnight. I started with just .zshrc. Then Git config. Then, a tiny Ansible playbook that installed a handful of packages. Over time, it grew.

Start with whatever annoys you the most when setting up a new machine. For some people, it’s browser extensions. For others, it’s themes or wallpapers. For me, it was remembering which apps I had installed.

Each time you configure something manually, add it to your dotfiles. Slowly, you build a complete snapshot of your setup.


Honestly, the hardest part is getting started. Once the structure is in place, adding new things takes minutes. And the first time you set up a fresh machine in 20 minutes instead of an entire day, you’ll wonder why you didn’t do this sooner.