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:
~/.zshrcpoints to~/dotfiles/.zshrc~/.config/ghosttypoints 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:
- Boot into the new OS
- Install
gitandansible(the only manual step) - Run the playbook
- 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.