Working Environment Bootstrapper

A summary of how I improved my dotfiles project to get a fully functional working environment ready in just a few minutes.

  ยท   16 min read

Recently, I started a personal plan to improve my skills in software development. This includes not only software development per se, but also deepening my understanding of infrastructure and the different execution contexts where my applications can live. To support this, I also considered having several helper servers to store my notes and progress (I’ll share more about that later when I write specifically about it).

My first challenge was to replicate a consistent working setup across devices and operating systems. This was mainly because I didn’t want to mix things from work with my personal study. For work, I use my Mac, and for personal projects, I use my old Dell laptop running Debian 13 (I can’t deny the recent release had a motivating effect on me).

But how could I make both systems share the same configuration despite being different operating systems? The answer had an incipient form in one of my old repos: https://github.com/mesirendon/dotvim. For me, it was quite easy to clone the repo locally into my home directory, and it gave me a basic setup to work with.

However, I always knew something was missing: I was forced to install all the software manually, and by doing so, I kept my systems kind of โ€œunsynced.โ€ Why? Simply because I couldn’t replicate what I had on the Mac to Debian (or Ubuntu, if that was the case), or vice versa.

This is how my new repository https://github.com/mesirendon/dotfiles was born. My goal was clear: first, I needed a way to install the exact (or at least most similar) software on both Mac and Debian. Then, I needed to have the exact same configuration for the most commonly used tools. Finally, I wanted a useful Vim environment to code efficiently no matter where I was, especially considering I was aiming to optimize resources such as memory.

The bootstrap.sh File

This is the backbone of the repo. This Bash script is in charge of orchestrating the whole process. You can check it at: https://github.com/mesirendon/dotfiles/blob/main/bootstrap.sh

There is something I really like about this project, and it’s that it was designed with idempotency in mind. You will see that many steps are skipped if the condition is already met.

Header & Safety

1#!/usr/bin/env bash
2set -euo pipefail
3REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"

#!/usr/bin/env bash runs the script with the first bash available in the PATH, this makes it portable across Mac and Linux.

set -euo pipefail sets the way the program manages errors:

  • -e exit immediately if any command returns a non-zero status.
  • -u treat unset variables as an error (helps catch typos like $PLATFROM).
  • -o pipefail a pipeline fails if any part fails (not only the last command).

Finally, REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" resolves the directory of this script.

OS Detection

 1echo "===> ๐Ÿ’ป Detecting OS"
 2OS="$(uname -s)"
 3
 4if [[ "$OS" == "Linux" ]]; then
 5  if [[ -f /etc/debian_version ]]; then
 6    PLATFORM="Debian"
 7  else
 8    echo "Unsupported Linux Distribution"
 9    exit 1
10  fi
11elif [[ "$OS" == "Darwin" ]]; then
12  PLATFORM="macos"
13else
14  echo "Unsupported OS: $OS"
15  exit 1
16fi
  • uname -s detects either Linux or Darwin (macOS)
  • The inner -f /etc/debian_version test distinguishes between Debian/Ubuntu from other distributions.

Debian/Ubuntu Prerequisites

1if [[ "$PLATFORM" == "Debian" ]]; then
2  echo "===> ๐Ÿง Installing Debian/Ubuntu Prerequisites"
3  "$REPO_DIR/scripts/apt.sh"
4fi

This one runs the apt.sh installer. As the script was set to -e, if the apt.sh script fails, the whole bootstrap script fails too.

1#!/usr/bin/env bash
2set -euo pipefail
3SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
4sudo apt update
5xargs -a "$SCRIPT_DIR/../Aptfile" sudo apt install -y

This script looks for a plain text file containing the set of packages (one per line) to install. This is useful because it focuses only on things that should be installed on Debian/Ubuntu. I try to keep this one as short as possible.

Homebrew Prefix & Install

 1if [[ "$PLATFORM" == "macos" ]]; then
 2  BREW_PREFIX="/opt/homebrew"
 3else
 4  BREW_PREFIX="/home/linuxbrew/.linuxbrew"
 5fi
 6
 7if [[ ! -d "$BREW_PREFIX" ]]; then
 8  echo "===> ๐Ÿบ Installing Homebrew"
 9  NONINTERACTIVE=1 /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
10else
11  printf '\t---> โœ… Homebrew is already installed\n'
12fi

In this step the script decides the brew prefix based on the operating system. This step might fail on Intel Macs as the location might be different.

Once the location is defined, the installation is non-interactive to avoid homebrew asking for anything.

If, for any reason, the installation must be restarted and Homebrew was successfully installed, the script will ignore the installation step.

Linuxbrew Post-Installation and Loadings

1if [[ "$PLATFORM" == "Debian" ]]; then
2  "$REPO_DIR/scripts/linuxbrew.sh"
3fi
4
5eval "$("$BREW_PREFIX/bin/brew" shellenv)"

After having homebrew installed on Linux, some post-installation actions have to be done. Finally, as some installations are going to occur, I need them to be performed in the context of this script. That’s that last line job.

Brew Packages and Mac Casks

 1echo "===> ๐Ÿป Installing Brew Bundle"
 2BFILE="$REPO_DIR/Brewfile"
 3
 4i=0
 5total=$(grep -Ev '^[[:space:]]*($|#)' "$BFILE" | wc -l)
 6
 7while IFS= read -r pkg; do
 8  [ -z "$pkg" ] && continue
 9  case "$pkg" in \#*) continue ;; esac
10  i=$((i + 1))
11  echo "[$i/$total] โณ Installing $pkg..."
12  brew install --quiet "$pkg" || true
13done <"$BFILE"

This part of the script reads one package name per line, skipping blanks/comments. Here are some features:

  • Progress counter: [$i/$total].
  • || true ensures a single failed formula doesn’t kill the whole bootstrap.
1if [[ "$PLATFORM" == "macos" ]]; then
2  echo "==> ๐ŸŽ Brew bundle (mac)"
3  brew bundle --file="$REPO_DIR/Brewfile.mac" --verbose || true
4fi

This last part is just to install casks on Mac that I’ve seen cannot be installed in Linux.

Stow (dry-run & confirmation)

1echo "===> ๐Ÿ“ฅ Stowing dotfiles (dry-run)"
2DOTFILES=("git" "zsh" "p10k" "tmux" "nvim" "bin")
3(cd "$REPO_DIR" && stow -nv "${DOTFILES[@]}") || true
4read -p "Proceed stowing dotfiles? [y/N] " ans
5if [[ "$ans" =~ ^[Yy]$ ]]; then
6  (cd "$REPO_DIR" && stow -v "${DOTFILES[@]}")
7fi

This part presents me with the set of config files that are going to be stowed in my home folder (as a symbolic link that resolves to the repo).

Shell, Fonts, and Tmux Plugins

1"$REPO_DIR/scripts/oh-my-zsh.sh"
2brew install --cask font-meslo-lg-nerd-font
3
4echo "===> ๐Ÿ’ฟ Installing tmux plugins"
5"$REPO_DIR/scripts/tmux.sh"

Installs oh-my-zsh + P10k and the Nerd Font

1if [[ "$SHELL" != *"zsh" ]]; then
2  if command -v zsh >/dev/null; then
3    echo "===> ๐Ÿ’ป Setting zsh as default shell"
4    chsh -s "$(command -v zsh)" || echo "โš ๏ธ chsh failed; run manually"
5  fi
6fi

The oh-my-zsh installation makes sure to set zsh as the default shell for the system.

 1#!/usr/bin/env bash
 2set -euo pipefail
 3
 4TPM_DIR="$HOME/.tmux/plugins/tpm"
 5
 6# Installing TPM if missing
 7if [[ ! -d "$TPM_DIR" ]]; then
 8 | echo "===> ๐Ÿ’ฟ Installing Tmux Plugin Manager (TPM)"
 9 | git clone https://github.com/tmux-plugins/tpm "$TPM_DIR"
10else
11 | printf '\t---> โœ… TPM is already installed\n'
12fi
13
14# Running TPM to fetch plugins
15if command -v tmux >/dev/null; then
16 | echo "===> ๐Ÿ’ฟ Installing tmux plugins via TPM"
17 | tmux start-server
18 | tmux new-session -d -s __tpm_bootstrap 'sleep 0.1' || true
19 | "$TPM_DIR/bin/install_plugins"
20 | tmux kill-session -t __tpm_bootstrap >/dev/null 2>&1 || true
21 | echo "โœ… tmux plugins installed"
22else
23 | echo "โš ๏ธ tmux is not installed, skipping plugin setup"
24fi

The good thing about the tmux installer is that it makes sure to install all the plugins without having to start a session just to load them. The next time I open a new session, the configs and plugins are already loaded.

The Repo Structure

Now that I’ve explained the bootstrap file, it’s time to review the repo structure. Here’s the layout:

.
โ”œโ”€โ”€ Aptfile
โ”œโ”€โ”€ bin
โ”‚ย ย  โ””โ”€โ”€ .bin
โ”‚ย ย      โ””โ”€โ”€ codews2tmux
โ”œโ”€โ”€ bootstrap.sh
โ”œโ”€โ”€ Brewfile
โ”œโ”€โ”€ Brewfile.mac
โ”œโ”€โ”€ git
โ”‚ย ย  โ””โ”€โ”€ .gitconfig
โ”œโ”€โ”€ nvim
โ”‚ย ย  โ””โ”€โ”€ .config
โ”‚ย ย      โ””โ”€โ”€ nvim
โ”‚ย ย          โ”œโ”€โ”€ .gitignore
โ”‚ย ย          โ”œโ”€โ”€ .neoconf.json
โ”‚ย ย          โ”œโ”€โ”€ home.jpg
โ”‚ย ย          โ”œโ”€โ”€ init.lua
โ”‚ย ย          โ”œโ”€โ”€ lazy-lock.json
โ”‚ย ย          โ”œโ”€โ”€ lazyvim.json
โ”‚ย ย          โ”œโ”€โ”€ LICENSE
โ”‚ย ย          โ”œโ”€โ”€ lua
โ”‚ย ย          โ”‚ย ย  โ”œโ”€โ”€ config
โ”‚ย ย          โ”‚ย ย  โ”‚ย ย  โ”œโ”€โ”€ autocmds.lua
โ”‚ย ย          โ”‚ย ย  โ”‚ย ย  โ”œโ”€โ”€ example.lua
โ”‚ย ย          โ”‚ย ย  โ”‚ย ย  โ”œโ”€โ”€ extras.lua
โ”‚ย ย          โ”‚ย ย  โ”‚ย ย  โ”œโ”€โ”€ keymaps.lua
โ”‚ย ย          โ”‚ย ย  โ”‚ย ย  โ”œโ”€โ”€ lazy.lua
โ”‚ย ย          โ”‚ย ย  โ”‚ย ย  โ””โ”€โ”€ options.lua
โ”‚ย ย          โ”‚ย ย  โ””โ”€โ”€ plugins
โ”‚ย ย          โ”‚ย ย      โ”œโ”€โ”€ colorscheme.lua
โ”‚ย ย          โ”‚ย ย      โ”œโ”€โ”€ dashboard.lua
โ”‚ย ย          โ”‚ย ย      โ”œโ”€โ”€ ide.lua
โ”‚ย ย          โ”‚ย ย      โ”œโ”€โ”€ test.lua
โ”‚ย ย          โ”‚ย ย      โ”œโ”€โ”€ treesitter.lua
โ”‚ย ย          โ”‚ย ย      โ””โ”€โ”€ ui-select.lua
โ”‚ย ย          โ”œโ”€โ”€ README.md
โ”‚ย ย          โ””โ”€โ”€ stylua.toml
โ”œโ”€โ”€ p10k
โ”‚ย ย  โ””โ”€โ”€ .p10k.zsh
โ”œโ”€โ”€ README.md
โ”œโ”€โ”€ scripts
โ”‚ย ย  โ”œโ”€โ”€ apt.sh
โ”‚ย ย  โ”œโ”€โ”€ linuxbrew.sh
โ”‚ย ย  โ”œโ”€โ”€ oh-my-zsh.sh
โ”‚ย ย  โ””โ”€โ”€ tmux.sh
โ”œโ”€โ”€ tmux
โ”‚ย ย  โ””โ”€โ”€ .tmux.conf
โ””โ”€โ”€ zsh
    โ””โ”€โ”€ .zshrc

I’ll leave the nvim and bin folders for later. But here you can see that the bootstrap.sh file relies on the scripts folder to perform more focused actions. This helps maintain readability while allowing each step to be fixed independently (i.e., the orchestration and the specific step).

Aptfile, Brewfile, and Brewfile.mac are files that contain the software to be installed.

scripts holds all the helper scripts that are part of the orchestration process.

git, p10k, tmux, and zsh are folder that contains the configurations for the software named after them. The same is true for both nvim and bin, but I’d like to talk about them in a moment.

NVIM: A Self-Built-In IDE

While the bootstrap file is the backbone of this repo, as it acts as the orchestrator to install all the software I need, nvim is the central piece. It serves not only as a quick editor to modify files, but also as a powerful IDE that enables me to code. Why? Why would I go through the trouble of configuring something that might seem too complicated to set up?

There are a couple of reasons for this. When I edit text, I always use Vim, so I’m used to working with it. Also, I’d like to use fewer resources when opening a coding project (to be honest, this is something I still need to benchmark, so my claim would be justified). Another reason is that I want to work with some remote servers, and I might eventually need that capability (I seriously doubt I’ll be running a full IDE on a server, even a text-based one). If I need to do it, I can connect from my local machine using Vim through an SSH tunnel.

But in the end, the main reason is that I find pleasure in getting into all this trouble.

Built Upon LazyVim

I did a quick research to find a good starting point for the project. I looked into five well-known setups: NvChad, LunarVim, Kickstart, AstroVim, and LazyVim. For each of them, I briefly evaluated six aspects:

  1. Out-of-the-box polish: This was very subjective, based mostly on screenshots and overall aesthetics, but ultimately driven by personal taste.
  2. Ease of customization: I considered factors like coupling, control, and modularity.
  3. Learning curve: I didn’t want to add much more to my existing Vim knowledge. The more I had to learn, the worse.
  4. Performance: I reviewed how each setup handled configuration loading, from eager to lazy loading.
  5. Community & Docs: This is important. If anything breaks, I want solid documentation and shared knowledge to fall back on.

I made my decision based on the average across all those criteria.

Some features from each one:

  • NvChad:
    • Out-of-the-box polish: slick UI, statusline, dashboard
    • Ease of customization: opinionated, harder to strip
    • Learning value: abstracted, less educational
    • Performance: optimized, very fast
    • Community & docs: very large, lots of tutorials
    • IDE readiness: but more UI than LSP focus
  • LunarVim:
    • Out-of-the-box polish: VSCode-like, dev focus
    • Ease of customization: tight coupling, heavy
    • Learning value: hides complexity
    • Performance: heavier load
    • Community & docs: active, VSCode migrators
    • IDE readiness: LSP, DAP, linters, formatters
  • KickStart.nvim:
    • Out-of-the-box polish: barebones
    • Ease of customization: you control everything
    • Learning value: you learn Neovim internals
    • Performance: lean
    • Community & docs: small, official support
    • IDE readiness: you build it yourself
  • AstroVim:
    • Out-of-the-box polish: polished, modular
    • Ease of customization: modular but opinionated
    • Learning value: semi-abstracted
    • Performance: larger footprint
    • Community & docs: decent community
    • IDE readiness: full IDE out of box
  • LunarVim:
    • Out-of-the-box polish: sensible defaults, clean UI
    • Ease of customization: plugin-per-file, easy overrides
    • Learning value: good balance, built on lazy.nvim
    • Performance: lazy loading everywhere
    • Community & docs: growing fast, strong docs
    • IDE readiness: LSP, treesitter, formatters, dap easily added
Criteria NvChad LunarVim KickStart.nvim AstroVim LazyVim
Out-of-the-box polish โ˜…โ˜…โ˜…โ˜…โ˜… โ˜…โ˜…โ˜…โ˜…โ˜† โ˜…โ˜…โ˜†โ˜†โ˜† โ˜…โ˜…โ˜…โ˜…โ˜† โ˜…โ˜…โ˜…โ˜…โ˜†
Ease of customization โ˜…โ˜…โ˜†โ˜†โ˜† โ˜…โ˜…โ˜†โ˜†โ˜† โ˜…โ˜…โ˜…โ˜…โ˜… โ˜…โ˜…โ˜…โ˜…โ˜† โ˜…โ˜…โ˜…โ˜…โ˜…
Learning value โ˜…โ˜…โ˜†โ˜†โ˜† โ˜…โ˜…โ˜†โ˜†โ˜† โ˜…โ˜…โ˜…โ˜…โ˜… โ˜…โ˜…โ˜…โ˜†โ˜† โ˜…โ˜…โ˜…โ˜…โ˜†
Performance โ˜…โ˜…โ˜…โ˜…โ˜… โ˜…โ˜…โ˜…โ˜†โ˜† โ˜…โ˜…โ˜…โ˜…โ˜† โ˜…โ˜…โ˜…โ˜†โ˜† โ˜…โ˜…โ˜…โ˜…โ˜…
Community & docs โ˜…โ˜…โ˜…โ˜…โ˜… โ˜…โ˜…โ˜…โ˜…โ˜… โ˜…โ˜…โ˜…โ˜…โ˜† โ˜…โ˜…โ˜…โ˜…โ˜† โ˜…โ˜…โ˜…โ˜…โ˜†
IDE readiness โ˜…โ˜…โ˜…โ˜…โ˜† โ˜…โ˜…โ˜…โ˜…โ˜… โ˜…โ˜…โ˜†โ˜†โ˜† โ˜…โ˜…โ˜…โ˜…โ˜† โ˜…โ˜…โ˜…โ˜…โ˜…
Average 3.84 3.50 3.67 3.67 4.5

This is why I chose LazyVim.

The Implementation Plan

I needed a strategy to develop this customization in a successful and controlled way. For this, I established the following action plan. It’s worth mentioning that at certain points I didn’t fully know what I was trying to achieve, so this final plan is the result of trial and error. Still, I decided to leave it as it appears here as a reminder of the importance of each step in the plan.

  1. Bootstrap LazyVim
  2. Understand the folder structure, options, keymaps, and autocmds
  3. IDE setup (Go, Bash, TS, etc., with LSP, formatting, linting, and debugging)
  4. Treesitter + LSP sanity
  5. Dashboard branding (mesirendon / chafa image)
  6. Color schemes + automatic day/night rotation
  7. Curate custom keymaps (e.g., Go test/debug, theme toggle shortcuts)
  8. Polish (tests, snippets, UI tweaks)

To come up with this plan, I used a Debian virtual machine hosted on my Debian laptop. I wanted to ensure I had a clean starting point to test the bootstrapping process as if I were installing everything from scratch. This helped me a lot to correct buggy behaviors and improve the setup process.

Bootstrapping LazyVim

LazyVim has good documentation that guides you through a quick setup and helps you get used to the environment. I really liked this, as I needed to get familiar with the project to understand what I could change and how to do it. This was more of an exploratory stage. The good thing is that I could edit documents based on my existing Vim knowledge. So far, so good.

Understanding the Folder Structure, Options, Keymaps, and Autocmds

LazyVim looks in two main places for your tweaks:

  • lua/config: A place to set options, keymaps, and autocmds. It’s meant for everyday overrides.
  • lua/plugins: A place to add, override, and disable plugins. I really liked this approach, as each file returns a plugin spec/table, which improves readability and modularity.

Here’s my config folder:

1.
2โ”œโ”€โ”€ autocmds.lua
3โ”œโ”€โ”€ example.lua
4โ”œโ”€โ”€ extras.lua
5โ”œโ”€โ”€ keymaps.lua
6โ”œโ”€โ”€ lazy.lua
7โ””โ”€โ”€ options.lua

Let’s review each file.

autocmds.lua

Currently, it has an option to autosave any modification made to a file if the current window or buffer loses focus. So, if I modify a file and then switch to another buffer (like when I write code and move to a test file) or move to another window (for example, from the terminal to the browser), the file is autosaved. It saves a lot of key combinations.

example.lua

This one is shipped with LazyVim. I haven’t done anything to it, except removing the colorscheme configuration, as I wasn’t quite sure if it was going to work or not.

extras.lua

This one is reserved for autoloading LazyVim Extras. Currently, it forces the loading of mini-surround, which I use to surround text with special characters (e.g., to enclose a string within parentheses when using that text as an argument for a function call), and mini-comment, a convenient way to comment lines based on their file syntax (it’s not the same to comment in Bash as in Go).

keymaps.lua

I’ve invested a lot of time polishing this one. It includes some nice features:

  • Colorscheme theme chooser: I can switch between day and night themes.
  • Increaser/Decreaser: When the cursor is over a number, I can press + or - to increase or decrease its value respectively.
  • Go filetype maps:
  • Testing via <localleader> + t
  • Debugging via <localleader> + d

I initially scoped testing and debugging to Go files as a way to experiment with isolating behaviors based on file extension. However, I’m likely to change this to be applied globally to any file, regardless of its type.

lazy.lua

This one has only one modification from the original shipped code. I changed line 21 to force the loading of my extras, since LazyVim needs to load its own plugins first, then the extra plugins, and finally the user-defined plugins.

options.lua

This one includes only two modifications: one to ensure autosaving is enabled, and another to automatically include an extra empty line at the end of each file if it doesn’t already have one.

Here’s my plugins folder:

1.
2โ”œโ”€โ”€ colorscheme.lua
3โ”œโ”€โ”€ dashboard.lua
4โ”œโ”€โ”€ ide.lua
5โ”œโ”€โ”€ test.lua
6โ”œโ”€โ”€ treesitter.lua
7โ””โ”€โ”€ ui-select.lua

Let’s review each file.

colorscheme.lua

Here I set two themes:

I configured it to apply the daylight theme between 7 AM and 5 PM. Why? My desktop is in a place with a lot of natural light, which makes it difficult to see anything with dark themes. At night, it automatically switches to the night theme. However, I also have the ability to change the theme at will.

dashboard.lua

A bit egocentric, I know, but I couldn’t help it. This replaces LazyVim’s default dashboard. I added my personal brand and a picture of my country house rendered in an 8-bit image style.

ide.lua

This is another one I invested a lot of time into setting up.

First, LazyVim comes with Mason, a tool that helps you manage language-related tools.

I configured Mason to load development tools for Go, TypeScript and JavaScript, Lua, and Bash. Then I configured the Language Server Protocol (LSP) for the same languages, along with formatting and linting.

Finally, I spent a lot of time configuring the Go debugger. For this, I installed nvim-dap (Debug Adapter Protocol) as the debugging engine, then nvim-dap-go as the bridge to connect DAP to the Go runtime, and finally nvim-dap-ui, which provides a visual interface in Neovim for tracing debug execution at different stages.

test.lua

This is the file where I spent most of my time. It uses neotest to handle testing per language. The key is that you need to configure the proper adapter for the languages you plan to test.

Since my main goal was to set it up for Go development, I initially used neotest-go, which didn’t work as expected (I’m still unsure why). I later talked to a friend to whom I had shown the project. He got interested, tested it on his own setup, and successfully made it work using a different adapter: neotest-golang. Once I switched to that, everything started working smoothly. The configuration wasn’t too hard thanks to the project’s documentation, which was good enough to set up a decent testing configuration file.

treesitter.lua

Tree-sitter is a powerful tool integrated into Neovim that enhances code parsing and syntax highlighting. It allows for incremental parsing, meaning it can update the syntax tree without re-parsing the entire document, making it faster and more efficient than traditional methods. For this one, I followed the recommendations for each language supported in my setup.

ui-select.lua

This one enhances the UI experience for various Neovim features like LSP code actions, making interactions smoother and more user-friendly. It integrates seamlessly with LazyVim and improves the overall usability of the editor.

I still need to fix this one, as the solution came up in a discussion thread. However, the project has since been archived, and they now recommend using the official snacks shipped with LazyVim. I’ll fix this later, but in the meantime, it just works.

The bin Folder

The last part of my bootstrap project is the bin folder. It’s meant to hold small programs that make my life easier. For now, I have one that converts VSCode workspaces into a session with tabs in a tmux session. I’ll showcase this later in a video.

The point here is that this is the folder where I plan to store my custom commands for daily tasks to simplify my workflow.

A Showcase

I recorded this video to demonstrate how the bootstrapper is intended to be used.