It’s been a while since I last opened a shiny new laptop or set up a fresh system. Yet, occasionally, I find myself indulging in my mad scientist tendencies, creating a virtual machine (VM) for some wild experiment before wiping it off the face of the earth. My usual partners in crime are Vagrant or VirtualBox, each hosting a fresh installation of my preferred Linux distro.

However, each time I embark on this journey, I find myself slightly handicapped. My seasoned hands miss their trusted tools like fish, spaceship, brew, and my beloved keyboard shortcuts. Transitioning back to Gnome or — 😱 — using a mouse feels as unnatural as a fish learning to tap dance. An inconvenience, sure, but not yet at the ā€œpull your hair outā€ level of frustration.

Ansible

Now, fast forward to earlier this week. I finally decided to construct a new rig for myself. Besides partaking in a bit of casual gaming, I needed a dedicated workstation to tackle the ever-growing mountain of side projects that’s been gathering dust. After installing Ubuntu, I realized that my long-suppressed curiosity will inevitably lead me to explore other distros. The pain of setting up each new shell quickly loomed over me like a dark cloud. So, I rolled up my sleeves and dove deep into my toolbox to make life easier for the future Sumit.

I love automation! And having used Ansible to build some sturdy servers in the past, I couldn’t help but think: ā€œIf it can configure servers, why can’t it do the same for my desktop environment?ā€ So, armed with my usual resolve (and a few cups of chai), I started crafting some Ansible playbooks for my bare minimum development setup.

But there’s a twist. Despite Ansible’s expectations for an inventory, I found a neat little loophole. You can make Ansible run on localhost—like a self-hosted party. And voila! I had a playbook that would install my basic CLI and GUI packages, such as fish-shell, LinuxBrew, podman, VLC, Chrome, and VS Code. I know, I use IntelliJ Idea more than VS Code, but I haven’t yet found a way to invite it to this automation party. It’s on the to-do list, I promise!

To get started, I have setup a few variable files to list down the packages that I need to install:

packages:
	core:
		- git
		- stow
		- fish
	system:
		- wget
		- curl
		- fuse
		- jq
		- libfuse2
		- zip
		- unzip
		- unrar
	desktop:
	# Audio and video
		- vlc
	fonts:
		- fonts-powerline
	virtualization:
		- podman

With that set, I ended up creating a series of tasks that’d upgrade the packages and run a series of tasks to setup my system. Here are some example tasks that I ended up writing:

- name: Upgrade system packages
  become: true
  ansible.builtin.apt:
    update_cache: yes
    upgrade: yes
 
- name: Install core packages
  become: true
  ansible.builtin.apt:
    name: "{{ item }}"
    state: latest
    update_cache: no
  loop: "{{ packages.core }}"
 
- name: Install system packages
  become: true
  ansible.builtin.apt:
    name: "{{ item }}"
    state: latest
    update_cache: no
  loop: "{{ packages.system }}"
 
- name: Install desktop packages
  become: true
  ansible.builtin.apt:
    name: "{{ item }}"
    state: latest
    update_cache: no
  loop: "{{ packages.desktop }}"
 
 

The task for installing the Jetbrain Mono Nerd font:

- name: Install fonts packages
  become: true
  ansible.builtin.apt:
    name: "{{ item }}"
    state: latest
    update_cache: no
  loop: "{{ packages.fonts }}"
  
# Install Nerd font
- name: Install Jetbrains Nerd Font
  block:
    - name: Create Directory
      ansible.builtin.file:
        path: "/home/{{ user }}/.fonts/JetBrainsMonoNerd"
        state: directory
        mode: "0755"
        owner: "{{ user }}"
        group: "{{ user }}"
    - name: Download
      ansible.builtin.unarchive:
        src: https://github.com/ryanoasis/nerd-fonts/releases/download/v3.0.2/JetBrainsMono.zip
        dest: "/home/{{ user }}/.fonts/JetBrainsMonoNerd"
        remote_src: true
        owner: "{{ user }}"
        group: "{{ user }}"
 
    - name: Font cache update
      action: command fc-cache -fv
 

I have been a fan of HomeBrew and have missed that in the Linux world. Thankfully it does support Linux very well now - albeit with a fairly anaemic list of packages. So I ended up using a task to install that as well:

---
# Installs [HomeBrew](https://brew.sh/) prompt
- name: Check if HomeBrew is installed
  ansible.builtin.stat:
    path: /home/linuxbrew/.linuxbrew/bin/brew
  register: brew
 
- name: Download & Install HomeBrew
  when: not brew.stat.exists
  block:
    - name: Download Installer Script
      ansible.builtin.get_url:
        url: https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh
        dest: /tmp/brew_install.sh
        mode: '775'
    - name: Run Installer Script
      ansible.builtin.shell: NONINTERACTIVE=1 /tmp/brew_install.sh 
 
- name: Set LinuxBrew Executable Path
  ansible.builtin.set_fact:
    linuxbrew_executable: /home/linuxbrew/.linuxbrew/bin/brew
    linuxbrew_path: /home/linuxbrew/.linuxbrew/bin
 

Now I can install Starship easily. If you have used tools like oh-my-zsh built by Robby Russell or the fish equivalent oh-my-fish, Starship is similar, but is cross platform by default and can run on multiple shells. Besides this I also use fisherwhich makes it easier to install some tools without having to port them into fish from bash. Here are the tasks to install them:

---
# Installs [Starship](https://starship.rs/) prompt
- name: Check if Starship is installed
  ansible.builtin.stat:
    path: /home/{{ user }}/.config/starship.toml
  register: starship
 
- name: Install
  when: not starship.stat.exists
  ansible.builtin.command: "{{ linuxbrew_executable }} install starship"
  environment: 
    HOME: "/home/{{ user }}"
  args:
    executable: /bin/bash
 
# Installing [Fisher](https://github.com/jorgebucaran/fisher)
- name: Install Fisher
  ansible.builtin.shell: curl -sL https://raw.githubusercontent.com/jorgebucaran/fisher/main/functions/fisher.fish | source && fisher install jorgebucaran/fisher
  environment:
    HOME:  "/home/{{ user }}"
  args:
    executable: /bin/fish
 

I have been using i3wm for about 4 years now, but haven’t been too fond of the high degree of learning required for configuring the desktop environment. Regolith has done an amazing job to make i3 truly accessible for the masses. I am one of the masses :) It did take me more time than the other tasks to get it right, because of the step of adding the signing key. I hope this helps:

---
- name: regolith ~> configure apt-repository for {{ ansible_distribution }} {{ ansible_distribution_release }}
  become: true
  block:
    - name: Regolith |no apt key
      ansible.builtin.get_url:
        url: https://regolith-desktop.org/regolith.key
        dest: /usr/share/keyrings/regolith-archive-keyring.asc
 
    - name: Regolith | apt source
      ansible.builtin.apt_repository:
        repo: "deb [arch=amd64 signed-by=/usr/share/keyrings/regolith-archive-keyring.asc] https://regolith-desktop.org/release-ubuntu-jammy-amd64 jammy main"
        state: present
 
- name: Regolith ~> installing
  become: true
  ansible.builtin.apt:
    name:
      - regolith-desktop
      - regolith-compositor-picom-glx
      - regolith-look-blackhole
      - regolith-look-default
      - i3xrocks-focused-window-name
      - i3xrocks-rofication
      - i3xrocks-info
      - i3xrocks-app-launcher
      - i3xrocks-memory
    state: present

For VS Code I am using a role I found on Ansible Galaxy. It does the job well. Here’s quick view. Besides these I also ended up installing Google Chrome.

Dotfiles

While installing packages is pretty straightforward, recreating the look and feel, themes, and configurations of my tools becomes a bit challenging if done by Ansible. These configuration files are generally known as dotfiles, and there’s a passionate community around efficient dotfile management. You can go to awesome-dotfilesand find numerous tools and articles on how to manage dotfiles effectively. I felt chezmoi would be a pretty good fit for my setup, as it comes with git and password-manager support. This makes it significantly easier for me to set things up.

Setting up packages with Ansible is as straightforward as a Roman road, but replicating the configurations, themes, and overall feel of my favorite tools requires a different approach. These settings live in the world of dotfiles. And there’s an entire legion of folks just as obsessed with efficient dotfile management as I am.

For my setup, chezmoi seemed like the perfect candidate. It comes with git and password-manager support, simplifying the process. But as the saying goes, ā€œEvery rose has its thorns.ā€ I discovered that Ubuntu runs on Wayland while Regolith leans on the X11 display server. This created some issues - such as regolith-refresh or regolith-look require Wayland specific tools. Copying over the dotfiles sometimes resulted in a broken Regolith desktop. Annoying? Absolutely. The end of the world? Not quite.

Currently, I have a workaround using Ansible to install all the packages in the Ubuntu Gnome Shell environment. I then log out and back in with Regolith, which runs on X11. On the first startup, it sets the default theme. At this point, I sync over the dotfiles using chezmoi (with Ansible tasks). Here’s how it looks:

- hosts: localhost
 
  vars_files:
    - vars/packages.yml
    - vars/common.yml
 
  tasks:
    - ansible.builtin.import_tasks: tasks/packages.yml
      when: lookup('ansible.builtin.env', 'XDG_SESSION_TYPE') != "x11"
    - ansible.builtin.import_tasks: tasks/fonts.yml
      when: lookup('ansible.builtin.env', 'XDG_SESSION_TYPE') != "x11"
    - ansible.builtin.import_tasks: tasks/brew.yml
    - ansible.builtin.import_tasks: tasks/shell.yml
      when: lookup('ansible.builtin.env', 'XDG_SESSION_TYPE') != "x11"
    - ansible.builtin.import_tasks: tasks/regolith.yml
      when: lookup('ansible.builtin.env', 'XDG_SESSION_TYPE') != "x11"
    - ansible.builtin.import_tasks: tasks/chrome.yml
      when: lookup('ansible.builtin.env', 'XDG_SESSION_TYPE') != "x11"
    - ansible.builtin.import_tasks: tasks/programming.yml
      when: lookup('ansible.builtin.env', 'XDG_SESSION_TYPE') != "x11"
    - name: Copy dot files
      ansible.builtin.include_tasks: tasks/dotfiles.yml
      when: lookup('ansible.builtin.env', 'XDG_SESSION_TYPE') == "x11"
 

And here’s how the dotfiles sync setup looks:

#Installing [Chezmoi](https://www.chezmoi.io/)
- name: Install & Initialize Chezmoi
  block:
    - name: Install Chezmoi
      ansible.builtin.command: "{{ linuxbrew_executable }} install chezmoi"
      environment: 
        HOME: "/home/{{ user }}"
      args:
        executable: /bin/bash
    - name: Initialize Chezmoi
      ansible.builtin.command: "{{ linuxbrew_path }}/chezmoi init --apply https://github.com/{{ github_username }}/dotfiles.git"
      environment: 
        HOME: "/home/{{ user }}"
      args:
        executable: /bin/bash
 
# Configuring gnome-shell to copy over the colors that I use
- name: Configure gnome-terminal
  block:
    - name: Copy dconf settings
      ansible.builtin.copy:
        src: gnome-terminal.conf
        dest: /tmp/gnome-terminal.conf
    - name: Apply dconf
      ansible.builtin.shell: dconf load /org/gnome/terminal/ < /tmp/gnome-terminal.conf
      environment:
        HOME:  "/home/{{ user }}"
      args:
        executable: /bin/bash
 

It’s not an ideal situation, but for now, it’s a peace treaty I’m willing to sign. In the future, I plan to refine the playbook and dotfiles, and hopefully, concoct a one-step solution.

With this setup, all I’d need to do on a fresh Ubuntu installation is install Ansible. No other tooling required, no hair-pulling, no keyboard smashing. Just automation bliss.

Check out the GitHub repositories for both projects:

  1. Dotfiles: sumitsarkar/dotfiles
  2. Ansible Playbook: sumitsarkar/dev-box-setup

Here’s to smoother setups and fewer headaches! Until next time, keep automating and keep exploring!