Managing dotfiles across multiple machines, fresh installations, and different Linux distributions can quickly become a nightmare. Over time, I’ve built a system that handles everything from symlinking configuration files to fully provisioning a new development environment with a single command.
In this post, I’ll walk you through my setup that combines GNU Stow for dotfile management, Ansible for system automation, and a custom Python TUI for installing optional tools.
The Problem
Every developer faces these challenges at some point:
- Setting up a new machine takes hours of manual configuration
- Keeping dotfiles in sync across multiple systems is error prone
- Fresh OS installations mean reinstalling dozens of tools
- WSL and native Linux environments have subtle differences
- Different distros (Debian vs RedHat) require different package managers
My solution addresses all of these with a modular, tested, and automated approach.
Repository Structure
dotfiles/
├── ansible/ # Ansible playbooks and roles
│ ├── roles/ # Modular roles for each component
│ ├── molecule/ # Molecule test configuration
│ ├── setup.yml # Main deployment playbook
│ └── bootstrap.yml # Bootstrap for remote servers
├── alacritty/ # Alacritty terminal config
├── bash/ # Bash shell configuration
├── git/ # Git configuration
├── lazygit/ # Lazygit configuration
├── neofetch/ # Neofetch config
├── nvim/ # Neovim configuration
├── opencode/ # OpenCode configuration
├── package-selector/ # Interactive package installer
├── starship/ # Starship prompt config
├── tmux/ # Tmux configuration
├── topgrade/ # Topgrade config
├── zellij/ # Zellij config
└── zsh/ # Zsh shell configuration
Part 1: Dotfile Management with GNU Stow
GNU Stow is a symlink farm manager that makes organizing dotfiles elegant and simple. Each top-level directory in my repo is a “stow package” that mirrors the target directory structure.
How It Works
Take my zsh/ package:
zsh/
├── .zshenv # -> ~/.zshenv
└── .config/
└── zsh/
├── .zshrc # -> ~/.config/zsh/.zshrc
├── shared-aliases # -> ~/.config/zsh/shared-aliases
└── starship_comp # -> ~/.config/zsh/starship_comp
Running stow zsh -t $HOME creates symlinks from these files to their corresponding locations in my home directory. The beauty is that I can version control my configurations in one place while they appear in their expected locations.
My Stow Packages
| Package | What It Configures |
|---|---|
| alacritty | GPU accelerated terminal with Nord, Dracula, Monokai Pro themes |
| git | Global config with GPG signing, delta pager, neovim as diff tool |
| lazygit | Custom keybindings, delta integration |
| nvim | LazyVim based config with 40+ LSPs, treesitter, iron.nvim for REPL |
| starship | Catppuccin Mocha prompt with Nerd Font icons |
| tmux | Oh My Tmux with Ctrl+a prefix, tmuxifier layouts |
| zellij | Terminal multiplexer with vim keybindings |
| zsh | Oh My Zsh, syntax highlighting, autosuggestions, zoxide |
| topgrade | System update automation |
| opencode | AI coding assistant configuration |
Stow All at Once
To symlink everything:
stow --adopt */ -t "$HOME"
The --adopt flag is clever: if a file already exists at the target location, Stow moves it into the package directory, then creates the symlink. This prevents conflicts on fresh systems.
Part 2: System Automation with Ansible
While Stow handles configurations, Ansible handles everything else: installing packages, setting up GPG keys, configuring the shell, and more.
The Playbook
My setup.yml orchestrates 9 roles in sequence:
roles:
- discover # Detect OS, WSL, validate environment
- base # System packages, Rust, Python (uv)
- git # GPG keys, SSH keys, git configuration
- shell # Zsh, Oh My Zsh, plugins
- github # GitHub CLI setup
- cargo # Rust CLI tools (exa, delta, zoxide, etc.)
- tools # Developer tools (neovim, lazygit, tmux, etc.)
- dotfiles # Stow all packages
- docker # Docker (Debian) or Podman (RedHat)
Cross Distro Support
Each role adapts to the target system. The discover role detects:
- OS Family: Debian (apt) vs RedHat (dnf)
- WSL: Skips GUI applications like Alacritty
- Distribution: Ubuntu, Debian, Rocky Linux, Fedora, CentOS
Role tasks are split into OS specific files:
roles/base/tasks/
├── main.yml # Entry point
├── Debian.yml # apt based systems
└── RedHat.yml # dnf based systems
Secrets Management
Sensitive data like email, GPG passphrase, and full name are stored in an Ansible Vault encrypted file:
# Create encrypted secrets
EDITOR=nano uv run ansible-vault create secrets.yml
# secrets.yml structure
user_email: "your@email.com"
user_fullname: "Your Name"
user_passphrase: "gpg_key_passphrase"
What Gets Installed
System packages (via apt/dnf):
- Build essentials (gcc, make, cmake)
- Development libraries (libssl, libffi)
- Utilities (curl, wget, jq, tree, htop)
Via Rust/Cargo:
-
exa: Modern ls replacement -
git-delta: Beautiful git diffs -
rm-improved: Safer rm with trash -
topgrade: Universal updater -
xcp: Extended cp -
zoxide: Smarter cd
Developer Tools:
- Neovim (stable AppImage)
- Lazygit (git TUI)
- Tmux + Oh My Tmux
- Starship prompt
- AWS CLI v2
- Terraform
- SOPS (secrets management)
- NVM (Node.js version manager)
Running the Playbook
cd ~/dotfiles/ansible
# Install dependencies with uv
uv sync
# Deploy everything
uv run ansible-playbook setup.yml -i hosts \
--ask-become-pass --ask-vault-pass
Testing with Molecule
I test my Ansible roles using Molecule with Docker:
# Tested platforms
platforms:
- name: ubuntu2204
- name: ubuntu2404
- name: rockylinux9
- name: fedora41
- name: fedora43
This ensures the playbook works across all supported distributions before I deploy to real systems.
Part 3: Interactive Package Selector
Not every tool is needed on every machine. For optional installations, I built a TUI using Python’s Textual library.
The Interface
┌─────────────────────────────────────────────────────────┐
│ Select packages to install │
├─────────────────────────────────────────────────────────┤
│ [ ] alacritty [ ] fzf [ ] ripgrep │
│ [ ] awscli [ ] lazygit [ ] sops │
│ [ ] bat [x] neovim [ ] starship │
│ [ ] exa [x] neofetch [ ] terraform │
│ [ ] fd [ ] nvm [x] tmux │
│ ... │
├─────────────────────────────────────────────────────────┤
│ Navigation: h/j/k/l or arrows | Space: toggle │
│ a: toggle all | Enter: confirm | q: quit │
└─────────────────────────────────────────────────────────┘
How It Works
Each package has a corresponding installation script in package-selector/scripts/:
# Example: install_neovim.sh
#!/bin/bash
curl -LO https://github.com/neovim/neovim/releases/latest/download/nvim.appimage
chmod u+x nvim.appimage
sudo mv nvim.appimage /usr/local/bin/nvim
The TUI runs selected scripts sequentially and streams output in real time, showing success/failure for each installation.
Running Package Selector
cd ~/dotfiles/package-selector
uv run python main.py
Part 4: Quick Setup Script (Debian Only)
For simpler scenarios on Debian based systems where Ansible feels like overkill, I have configure.sh:
./configure.sh
This interactive script:
- Updates system packages
- Installs build tools
- Installs Zsh and Oh My Zsh with plugins
- Installs Rust via rustup
- Installs uv (modern Python package manager)
- Installs GNU Stow
- Optionally symlinks all dotfiles
- Changes default shell to Zsh
- Optionally launches package selector
Note: This script currently only supports Debian based distributions. For RedHat based systems, use the Ansible playbook.
Highlights from My Configurations
Zsh Setup
My Zsh configuration follows XDG standards, storing everything in ~/.config/zsh/:
# .zshenv sets ZDOTDIR
export ZDOTDIR="$HOME/.config/zsh"
Key features:
- 100,000 lines of history, shared across sessions
-
Auto switches Node.js versions based on
.nvmrcfiles -
Modern CLI replacements:
exaforls,batforcat,zoxideforcd - Unified package manager aliases that work on Debian and RedHat
# Works on any distro
alias update="sudo $PACKAGER update"
alias install="sudo $PACKAGER install"
Git Configuration
GPG signed commits are automatic:
[commit]
gpgsign = true
[tag]
gpgsign = true
Delta provides beautiful diffs:
[core]
pager = delta
[delta]
navigate = true
side-by-side = true
line-numbers = true
Starship Prompt
A Catppuccin Mocha themed prompt with:
- OS specific icons (Ubuntu, Fedora, etc.)
- Git branch and status
- Language versions (Python, Node, Rust)
- Docker context when active
- Current time
Neovim
Built on LazyVim with:
- 40+ language servers via Mason
- Treesitter for syntax highlighting
- Iron.nvim for REPL integration
- TypeScript and JSON extras
Getting Started
# Install git and uv
sudo apt-get update && sudo apt-get install -y git # Debian/Ubuntu
# or: sudo dnf install -y git # Fedora/RHEL
curl -LsSf https://astral.sh/uv/install.sh | sh
# Clone the repository
cd ${HOME} && git clone https://github.com/emrecanaltinsoy/dotfiles && cd dotfiles/ansible/
# Install dependencies
uv sync
# Create encrypted secrets file
EDITOR=nano uv run ansible-vault create secrets.yml
# Run the setup playbook
uv run ansible-playbook setup.yml -i hosts --ask-become-pass --ask-vault-pass
After deployment, logout and login again to use Zsh as your default shell, then:
source ${HOME}/.zshrc
Design Principles
- Separation of concerns: Dotfiles (Stow) vs System setup (Ansible) vs Optional tools (package selector)
- Cross distro compatibility: Debian and RedHat families, plus WSL awareness
- Idempotency: Run the playbook multiple times without side effects
- Security: Vault encrypted secrets, GPG signed commits by default
- Modern tooling: uv for Python, rustup for Rust, nvm for Node.js
-
XDG compliance: Configurations in
~/.config/where possible - Tested: Molecule tests across 5 distribution variants
Conclusion
This setup has saved me countless hours. A fresh WSL installation or new Linux machine goes from zero to fully configured development environment in about 15 minutes, most of which is just waiting for downloads.
The modular approach means I can:
- Update a single configuration and have it propagate everywhere via git
- Add new tools without touching existing configurations
- Test changes in containers before deploying to real systems
- Support team members on different distributions
Feel free to explore the repository and adapt it to your own needs. The beauty of this approach is its flexibility: start with what you need and grow from there.