My Dotfiles Aren't Aesthetic. They're Operational.
How I use a disciplined dotfiles strategy to turn any new machine into a familiar, mistake-proof engineering environment in under an hour.
On this page
I’m fundamentally lazy. Not the “I don’t want to work” kind of lazy—the “I refuse to do the same thing twice” kind. And nothing triggers that reflex faster than sitting down at a fresh machine and realizing I have to re-configure everything from scratch. Again.
So I built my dotfiles. Not because I wanted a pretty terminal. Because I wanted to never suffer through a new machine setup again. Laziness, it turns out, is a powerful engineering motivator.
This isn’t a tutorial. I’m not here to tell you how to write your dotfiles. I’m here to show you mine, explain why I made the choices I did, and hopefully convince you that dotfiles are genuinely one of the most underrated tools in a developer’s arsenal. What you do with that is entirely your call.
This is Part 1 of a two-part series. Part 1 is the foundation: stow, the shell, Tmux, Git defaults, the guardrails — everything that makes the terminal feel like home. Part 2 zooms out to the whole desktop: window tiling, a custom HUD, infrastructure tooling, and a Neovim IDE — all driven by the same repo.
The Lazy Person’s Philosophy
Here’s the thing about new machines: they’re hostile. The default shell feels wrong. Your aliases don’t exist. Your Git config has someone else’s email. Every small friction stacks up until you’re spending a full day just getting back to baseline productivity. That’s a day of real work lost to setup theater.
My dotfiles philosophy is simple: version control the “how,” not the “what.”
What belongs in dotfiles: Shell config (.zshrc), Git settings (.gitconfig), editor setup (nvim/), terminal multiplexer config (.tmux.conf), and scripts that encode repeatable workflows. These define how I work, independent of what I’m working on.
What stays out: Secrets, API keys, project-specific dependencies, large binaries. I use 1Password for secrets. Project dependencies belong in package.json and requirements.txt—with the project, where they live and die with it.
The separation sounds obvious. But you’d be amazed how many developers have their AWS keys symlinked into their home directory via a public dotfiles repo. Don’t be that person.
Why GNU Stow: The One Tool I Actually Need
Before Stow, I manually managed symlinks. It worked fine until I forgot which files I’d linked, created stale pointers after repo updates, or—my personal highlight reel—symlinked the wrong version of a config at 1 AM and spent an hour wondering why my shell looked wrong.
The real problem wasn’t the symlinks. It was that without a manager, my configs and their symlinks drifted apart over time. Push a change to the repo? Great. But the symlink is still pointing at the old file. Clone on a new machine? Fresh batch of stale pointers.
Enter GNU Stow. A symlink farm manager from the 1990s that’s still the gold standard. Here’s why I chose it over writing my own script (I did write my own script first, it was bad):
- Idempotent by design. Run
stow --adopt .ten times. Nothing breaks, nothing duplicates. - Adopt mode is magic. If
~/.zshrcalready exists but isn’t in my repo yet,stow --adoptwill move it into the repo and symlink it back. I can migrate existing configs into version control without touching them. - Per-package control. I can stow
zsh,git, andnvimwhile keeping anexperimentaldirectory un-stowed. Surgical precision. - Completely transparent.
ls -la ~/.zshrcshows exactly where it points. No magic, no abstraction, no surprises.
Think of the dotfiles repo as a blueprint office. Your home directory is the construction site. Without Stow, you’re hand-delivering photocopies of blueprints to every contractor. Update the blueprint, now you have three different versions in the wild. Stow installs direct portals to the original. Change the source, every portal reflects it instantly.
View diagram source
graph LR
subgraph REPO["~/.dotfiles/"]
A[".zshrc"]
B[".gitconfig"]
C["nvim/init.lua"]
D[".tmux.conf"]
end
subgraph HOME["~/"]
E[".zshrc → symlink"]
F[".gitconfig → symlink"]
G[".config/nvim/init.lua → symlink"]
H[".tmux.conf → symlink"]
end
A -->|"stow ."| E
B -->|"stow ."| F
C -->|"stow ."| G
D -->|"stow ."| H
#!/usr/bin/env bash
# install.sh — the laziest machine setup possible
# 1. Clone the repo
git clone https://github.com/PhuongTMR/dotfiles.git ~/.dotfiles
cd ~/.dotfiles
# 2. Install prerequisites (macOS example; adjust for your OS)
brew install stow zsh tmux neovim
# 3. One command to link everything
stow --adopt .
# 4. Switch to Zsh
chsh -s /bin/zsh
echo "Done. Go make coffee. You're already set up."
Run it once. Every config file is symlinked. Push a change to the repo anywhere, git pull in ~/.dotfiles, and every machine reflects it. That’s the whole setup.
The trap I fell into: Writing a massive provisioning script that tried to detect the OS, install packages, configure system settings, and handle every edge case. I spent a week on it. It worked on exactly one machine. Stow plus a ten-line shell script does 90% of the job with none of the maintenance burden.
Zsh + Oh My Zsh: Where I Live
Bash is everywhere. Bash is reliable. But living in Bash all day is like working in an office where every drawer is unlabeled and the lights are always slightly too dim. Technically functional. Quietly miserable.
Zsh with Oh My Zsh doesn’t change what I do. It changes how I feel doing it.
Here’s the difference in one glance:
# Before: default bash prompt
user@MacBook-Pro ~ %
# After: Zsh + autosuggestions + syntax highlighting + Starship
~/projects/myapp on main [!] via 🐍 v3.11
❯ git checkout fea█ture/auth ← ghost text shows last matching command
~~~~~~~~~~~ ← green = valid command
Most people install Oh My Zsh and immediately load every plugin they can find. Then they wonder why their shell takes two seconds to start. I’ve been that person. I’ve debugged that shell. Never again.
I run exactly three plugins:
git— Not for aliases (those live in.gitconfig). For completion.git che<TAB>knows about your branches, your remotes, your stash. Essential.zsh-autosuggestions— Ghost text showing the last matching command as you type. Press→to accept. Cuts repeated-command typing in half. The ROI on this one is embarrassingly high.zsh-syntax-highlighting— Your command turns green if it’s valid, red if it’s not. Before you press Enter. I’ve caught more typos with this than I care to admit.
# ~/.zshrc
export ZSH="$HOME/.oh-my-zsh"
ZSH_THEME="" # Starship handles the prompt — more on that next
plugins=(git zsh-autosuggestions zsh-syntax-highlighting)
source $ZSH/oh-my-zsh.sh
# The aliases I actually use, not the ones I thought I'd use
alias ..="cd .."
alias ...="cd ../.."
alias ll="eza -lah --group-directories-first" # eza: ls with opinions
alias k="kubectl"
alias g="lazygit" # lazygit: Git UI that doesn't suck
alias gs="git status"
alias gp="git push"
alias vim="nvim" # muscle memory override
eval "$(starship init zsh)"
Notice I don’t have docker or kubectl as full Oh My Zsh plugins. Those plugins load heavy completion systems at startup, which is expensive. Instead, I lazy-load them on first use:
# Lazy-load heavy completions — only pay the cost when you need them
if [[ $commands[kubectl] ]]; then
source <(kubectl completion zsh 2>/dev/null)
fi
if [[ $commands[docker] ]]; then
source <(docker completion zsh 2>/dev/null)
fi
Shell starts in ~200ms. Full completions available. The first kubectl invocation loads them once, and they’re cached. That’s the deal.
The mistake I keep seeing: Loading plugins because they sound useful. “Oh, this one adds AWS completions.” Sure, but do you run aws commands twenty times a day? No? Then you’ve added startup overhead for something you’ll use twice a month. Audit your plugins quarterly. Delete mercilessly.
Starship: My Terminal Dashboard
The default shell prompt tells you three things: who you are, where you are, and what machine you’re on. Useful when you’re a sysadmin in 1998. For modern development, it’s like having a car dashboard that only shows the odometer.
I need to know what Git branch I’m on. I need to know if my last command failed. I need to know if I’ve accidentally entered a Python virtualenv I forgot about. Default prompt: none of that. Starship: all of it.
Starship is a prompt renderer written in Rust, which means it’s fast enough that you’ll never notice the overhead. It’s modular—each piece shows up only when it’s relevant.
Here’s what mine looks like in practice:
~/projects/myapp on main via Python v3.11.2 took 2.3s
❯
Branch. Python context. How long the last command took. The ❯ turns red if the last command failed. That’s all I need. No noise, maximum signal.
# ~/.config/starship.toml
format = """
$directory\
$git_branch\
$git_status\
$python\
$nodejs\
$cmd_duration\
$line_break\
$character"""
[character]
success_symbol = "[❯](bold green)"
error_symbol = "[❯](bold red)"
[directory]
style = "bold cyan"
truncation_length = 3
fish_style_pwd_dir_length = 2
[git_branch]
symbol = " "
style = "bold purple"
format = "on [$symbol$branch]($style) "
[git_status]
style = "bold red"
format = "([$all_status$ahead_behind]($style) )"
[python]
symbol = "🐍 "
style = "bold yellow"
detect_files = [".python-version", "pyproject.toml"]
[cmd_duration]
min_time = 2000 # Only show if the command took >2 seconds
format = "took [$duration](bold yellow) "
Everything is conditional. The Python version only appears inside Python projects. Git status only renders inside a repo. Command duration only shows up when a command took more than 2 seconds—so it doesn’t clutter fast commands but always tells me when something was unexpectedly slow.
One thing worth knowing: Starship queries Git for status info. In a massive monorepo, that query can take seconds. If your prompt suddenly feels sluggish, the fix is almost always Git, not Starship. git config core.preloadindex true and git maintenance start solve most of it.
Tmux: The Tool That Saved Me From Myself
I lost work to a closed terminal window once. Just once. That was enough.
Tmux is a terminal multiplexer. You split your screen into panes, open multiple windows, and—the part that actually matters—detach from a session and come back to it later. Your session is a process running on the server, completely independent of your SSH connection.
Without Tmux, remote work is precarious. You’re one flaky connection away from losing your context. With Tmux, you SSH in, start working, detach, go home, reattach from your laptop the next morning. Everything is exactly where you left it. The deploy you kicked off three hours ago is still running, happily logging to a pane you can check on.
# ~/.tmux.conf
# Ctrl-B is default but anatomically inconvenient at speed
unbind C-b
set -g prefix C-a
bind C-a send-prefix
# Reload config without nuking the session
bind r source-file ~/.tmux.conf \; display "Config reloaded!"
# Split with characters that make visual sense
bind | split-window -h # | for vertical split
bind - split-window -v # - for horizontal split
unbind '"'
unbind %
# Move between panes with Alt-Arrow — no prefix required
# This is the one that changed how I use Tmux
bind -n M-Left select-pane -L
bind -n M-Right select-pane -R
bind -n M-Up select-pane -U
bind -n M-Down select-pane -D
set -g default-terminal "tmux-256color"
set -ag terminal-overrides ",xterm-256color:RGB"
set -g base-index 1 # Start numbering at 1, not 0
setw -g pane-base-index 1
set -g mouse on # Yes, mouse support. I know.
set -g history-limit 10000
The Alt-Arrow bindings are the thing I recommend most. Prefix-free pane navigation means you actually use panes. Before I had this, I’d open a split, navigate back with Ctrl-A Left, and immediately think “this is too much work” and just open a new window instead. Now I have three panes open for every project: one for the editor, one for tests, one for logs. It took exactly one config line to make splits feel natural.
┌──────────────────────┬─────────────────┐
│ │ │
│ │ Tests │
│ Editor (nvim) │ $ npm test │
│ │ │
│ ├─────────────────┤
│ │ │
│ │ Logs / Server │
│ │ $ npm run dev │
│ │ │
└──────────────────────┴─────────────────┘
Ctrl-A + | Ctrl-A + -
(vertical) (horizontal)
# The only Tmux workflow you need to internalize
tmux new-session -s work # Start a named session
# Ctrl-A + | # Split vertically
# Ctrl-A + - # Split horizontally
# Alt-Arrow # Move between panes (no prefix!)
# Ctrl-A + d # Detach (session keeps running)
tmux attach-session -t work # Come back to it anytime
What I see people do wrong: They set up elaborate keybindings they can never recall when it matters. The whole point of Tmux is that it’s there when you need it most—debugging production at midnight. If you can’t remember the binding in that moment, it doesn’t exist. Keep it simple. Ctrl-A + |, Ctrl-A + -, Alt-Arrow, and Ctrl-A + d. Five things. That’s the whole system.
Neovim: My Chosen Rabbit Hole
I want to be clear about something: I am not here to convince you to use Neovim. VS Code is great. JetBrains IDEs are great. Use what makes you productive.
But I live in Neovim, and my dotfiles wouldn’t be honest without it. I’ve been configuring it for years. My fingers know it the way a pianist knows their instrument. I don’t think about key bindings anymore; I just edit.
The modern Neovim story is lazy.nvim: a plugin manager that doesn’t load plugins at startup. They load on demand. This keeps startup time under 50ms while giving me LSP (real-time error checking, go-to-definition, autocomplete), fuzzy finding, Git integration, and everything else a full IDE offers.
-- ~/.config/nvim/init.lua
local lazypath = vim.fn.stdpath("data") .. "/lazy/lazy.nvim"
if not vim.loop.fs_stat(lazypath) then
vim.fn.system({
"git", "clone", "--filter=blob:none",
"https://github.com/folke/lazy.nvim.git",
"--branch=stable", lazypath,
})
end
vim.opt.rtp:prepend(lazypath)
require("lazy").setup({
-- Code intelligence: go-to-definition, hover docs, error checking
{
"neovim/nvim-lspconfig",
event = { "BufReadPre", "BufNewFile" },
dependencies = { "williamboman/mason.nvim", "williamboman/mason-lspconfig.nvim" },
config = function()
require("mason").setup()
require("mason-lspconfig").setup({
ensure_installed = { "pyright", "tsserver", "rust_analyzer" },
automatic_installation = true,
})
local lspconfig = require("lspconfig")
lspconfig.pyright.setup({})
lspconfig.tsserver.setup({})
-- The only LSP bindings I use daily
vim.keymap.set("n", "gd", vim.lsp.buf.definition) -- Go to definition
vim.keymap.set("n", "K", vim.lsp.buf.hover) -- Inline docs
vim.keymap.set("n", "gi", vim.lsp.buf.implementation)
end,
},
-- Find anything, fast
{
"nvim-telescope/telescope.nvim",
event = "VimEnter",
dependencies = { "nvim-lua/plenary.nvim" },
config = function()
local builtin = require("telescope.builtin")
vim.keymap.set("n", "<leader>ff", builtin.find_files) -- Find files
vim.keymap.set("n", "<leader>fg", builtin.live_grep) -- Search content
vim.keymap.set("n", "<leader>fb", builtin.buffers) -- Switch buffers
end,
},
-- Git diff markers in the gutter
{
"lewis6991/gitsigns.nvim",
event = { "BufReadPre", "BufNewFile" },
config = true,
},
-- Autocomplete
{
"hrsh7th/nvim-cmp",
event = "InsertEnter",
dependencies = { "hrsh7th/cmp-nvim-lsp", "hrsh7th/cmp-buffer", "L3MON4D3/LuaSnip" },
config = function()
local cmp = require("cmp")
cmp.setup({
snippet = { expand = function(args) require("luasnip").lsp_expand(args.body) end },
mapping = cmp.mapping.preset.insert({
["<C-Space>"] = cmp.mapping.complete(),
["<CR>"] = cmp.mapping.confirm({ select = true }),
}),
sources = { { name = "nvim_lsp" }, { name = "buffer" } },
})
end,
},
})
vim.opt.number = true
vim.opt.relativenumber = true -- Relative numbers for fast jump targeting
vim.opt.expandtab = true
vim.opt.shiftwidth = 2
vim.opt.clipboard = "unnamedplus"
Each plugin loads exactly when needed. Telescope loads when I hit <leader>ff. LSP loads when I open a file with a supported language. Autocomplete loads when I enter insert mode. The result: Neovim feels instant.
The most important Neovim advice I can give: Don’t clone someone else’s full config. They made choices for their workflow. Their plugin versions are pinned to a point in time. When things break—and things break—you’ll have no idea why, because you didn’t build it. Start with LSP and Telescope. Use them for a week. Add one thing. Understand it. Then add the next. Build it like you’d build any system: incrementally, with intent.
The Guardrails That Pay Off Quietly
The most valuable lines in my dotfiles aren’t the impressive ones. They’re the ones that have saved me from catastrophic stupidity.
alias rm='rm -i', alias cp='cp -i', alias mv='mv -i'. Each one adds a confirmation step before overwriting or deleting. Small friction. Enormous insurance. Opinionated Git defaults that prevent daily frustration:
# ~/.gitconfig
[push]
autoSetupRemote = true # First push to a new branch just works, no --set-upstream needed
[pull]
rebase = true # Linear history, always. Merge commits are noise.
[init]
defaultBranch = main # No more renaming from master on every new repo
[alias]
amend = commit --amend --no-edit # Fix the last commit, keep the message
fixup = commit --fixup HEAD # Prep a commit for interactive rebase
Secrets never touch the repo:
# ~/.zshrc — keeping credentials out of version control
# Source local secrets that live outside the repo
if [[ -f ~/.env.local ]]; then
set -a
source ~/.env.local # AWS keys, tokens, etc. — in .gitignore, never committed
set +a
fi
# 1Password CLI for everything else
eval "$(op signin)"
.env.local is gitignored and lives only on the machine that needs it. The repo stays clean, shareable, and safe to push to a public remote.
What I Deliberately Don’t Automate
Laziness doesn’t mean automate everything. It means automate the things worth automating.
GUI applications: I install Slack, my browser, and Docker Desktop by hand. They change too often, they’re machine-specific, and the ROI on scripting their installation is genuinely negative. A brittle install script that breaks every three months isn’t laziness—it’s just a different kind of maintenance.
System-level settings: Keyboard repeat rate, display resolution, power settings. These go through the OS GUI. Five minutes per machine, done. Scripting them is platform-specific, fragile, and the kind of yak-shaving that feels productive but isn’t.
Project dependencies: Those belong with the project. package.json, requirements.txt, go.mod. Dotfiles are personal; dependencies are contextual.
The point: dotfiles are for my engineering workflow, not full machine provisioning. When I need full provisioning, that’s an Ansible playbook or a CloudInit script. Different tool, different job.
From Zero to Productive in Under 15 Minutes
On a fresh machine, here’s the honest timeline:
- Install Git and Homebrew: 3 minutes
- Clone and stow dotfiles: 1 minute
- Neovim plugin install (lazy.nvim): 2 minutes
- Manual tweaks (keyboard rate, display, power): 5 minutes
Total: around 11 minutes. Then I’m in my exact environment. My aliases work. My editor bindings are right. My terminal shows me what I need. No disorientation, no setup tax on the first day of work.
The compound interest on that investment pays out every single day. Five seconds saved on an alias. A Git mistake avoided because the prompt showed the wrong branch. A remote session recovered because Tmux was running.
Start with a .gitconfig and five aliases. Write an install.sh that calls stow. Use it. Then let your actual friction guide what you add next.
Your dotfiles should feel like an extension of how you think—not a portfolio of things you once read were cool. The goal isn’t impressive dotfiles. The goal is never having to suffer through a new machine setup again.
That’s the whole lazy person’s agenda — at least for the terminal.
Because here’s what I left out: the terminal is just one window on a screen full of them. Stow, aliases, and shell config got me a fast terminal. They didn’t solve the rest — window management, the menu bar, infrastructure access, a real editor, or the 80+ CLI tools that have quietly replaced the defaults I grew up with. That’s an entirely different layer of operational dotfiles, and it’s where this series goes next.
Part 2 takes everything above the shell — AeroSpace tiling, a scriptable SketchyBar HUD, three terminals tuned for three different jobs, a Nushell-powered infrastructure cockpit, and a Neovim setup that turned into a full Django IDE — and shows how the same stow . ties it all together.
Part 2 — Dotfiles for macOS: From Terminal to Desktop Environment →