All the hard work building your Neovim config is meaningless if you have to start from scratch every time you change machines. This episode covers managing dotfiles with Git, making a portable cross-platform config, and automating bootstrap on new laptops or servers.

After discussing startup time optimization in episode 23 — making sure Neovim stays flying even with many plugins — in this episode we solve a problem more painful than a slow editor: lost config. You have spent dozens of hours tuning keymaps, choosing a colorscheme, and setting up lazy-loading — then suddenly you have to set up on a new work laptop, or a VPS server without a GUI. All that hard work vanishes if not stored properly.
This is why dotfiles management is one of the most undervalued DevOps skills. Dotfiles are the collection of config files living in the home directory (~/.config/nvim/, ~/.zshrc, ~/.gitconfig, and so on) — they are the "soul" of your environment. In real work, an engineer who switches machines and immediately gets an identical environment in 10 minutes saves themselves days of manual adjustments. Many companies even encourage their teams to version their dotfiles so all team members have a consistent setup.
This episode covers three things: first, how to store ~/.config/nvim/ in a Git repository safely (and what must be kept private); second, how to make the config portable across platforms — running smoothly on Linux, macOS, and Windows from a single source; third, how to create a bootstrap script that installs everything on a new machine automatically.
Think of dotfiles as the infrastructure code for your workstation. Just as Terraform writes infrastructure as code so it can be reviewed, rolled back, and reproduced — your dotfiles should also be:
There are two main approaches to managing dotfiles with Git. Both are valid — choose the one that fits your working style.
git init --bare Trick)The basic idea: create one "bare" Git repository (without a working tree) that uses $HOME as its working tree. This way, ~/.config/nvim/ can be tracked without having to move the files — they stay in their original location, and Git only records them.
git init --bare $HOME/.dotfiles
alias dotfiles='/usr/bin/git --git-dir=$HOME/.dotfiles --work-tree=$HOME'
dotfiles config status.showUntrackedFiles no
dotfiles config core.excludesFile '~/.gitignore_global'
# Mulai tracking config Neovim
dotfiles add ~/.config/nvim
dotfiles commit -m "feat: add neovim config"Important
Note the dotfiles config status.showUntrackedFiles no line. Without this line, dotfiles status will try to show the entire contents of $HOME as untracked files — that is extremely noisy and dangerous, because $HOME contains everything. Always set this option before using the repo.
So the dotfiles alias does not disappear every time you switch shells, save it in your rc file (~/.zshrc, ~/.bashrc, or ~/.config/fish/config.fish):
alias dotfiles='/usr/bin/git --git-dir=$HOME/.dotfiles --work-tree=$HOME'The advantage of this approach: dotfiles stay in their original location — no symlinks that can break, and all applications read config from standard paths. This is the approach many engineers use because it is closest to "how Git works" — except the working tree is $HOME.
The second approach: store all dotfiles in one directory (e.g. ~/dotfiles/), then create symlinks to the needed locations.
mkdir -p ~/dotfiles
git clone git@github.com:username/dotfiles.git ~/dotfiles
# Simetaskan config Neovim
ln -sf ~/dotfiles/nvim ~/.config/nvimThe ~/dotfiles/ directory structure could look like this:
dotfiles/
├── nvim/
│ ├── init.lua
│ └── lua/
├── zsh/.zshrc
├── git/.gitconfig
├── tmux/.tmux.conf
├── install.sh
└── README.mdThe advantage: all dotfiles are neatly collected in one directory, easy to explore, backup, and clone. The drawback: symlinks can break if the directory structure changes, and you have to remember to create a new symlink every time you add a new dotfile.
One common question: are dotfiles public or private? The honest answer: it depends on the contents. Neovim configs are generally safe to make public — in fact, many famous engineers publish their dotfiles as learning references. But there are some things that must never go into a public repository:
~/.config/gh/hosts.yml, cloud credentials)..env files containing secrets.~/.zsh_history).Warning
A common safe pattern: one public repository for config you want to share, and one private repository for the sensitive stuff — or use a single private repository with a gitignored .env.local for secrets. In Neovim, this pattern is represented by files like .env.local that are only loaded if they exist, and added to .gitignore. Never put secrets directly in init.lua.
Example: a Neovim config that needs an AI token (e.g. for Copilot or an LLM provider). Do not write the token directly — read it from an environment variable or an ignored file:
-- Token dibaca dari environment, tidak pernah di-commit
local api_key = vim.env.CODIUM_API_KEY
if not api_key then
-- fallback: file .env.local yang di-gitignore
local env_file = vim.fn.stdpath("config") .. "/.env.local"
if vim.fn.filereadable(env_file) == 1 then
for line in io.lines(env_file) do
local k, v = line:match("^([^=]+)=(.*)$")
vim.env[k] = v
end
end
endAnd make sure in .gitignore:
.env.local
.env
*.token
secrets/A config that only runs on Linux is a time bomb. Many engineers work on office machines (Windows/Linux), personal laptops (macOS), and production servers (Linux) — with the same config. The key is detecting the OS dynamically, not hardcoding paths.
There are two most commonly used APIs: vim.loop.os_uname().sysname (values: Linux, Darwin, Windows_NT) and jit.os (values: Linux, OSX, Windows). We can create a small module that becomes the "single source of truth" for OS detection:
local M = {}
local sysname = vim.loop.os_uname().sysname
M.is_windows = sysname == "Windows_NT"
M.is_mac = sysname == "Darwin"
M.is_linux = sysname == "Linux"
-- stdpath sudah menangani perbedaan lokasi config/data antar OS
M.config_dir = vim.fn.stdpath("config")
M.data_dir = vim.fn.stdpath("data")
M.state_dir = vim.fn.stdpath("state")
-- Lokasi yang sama antar OS tapi disimpan agar mudah di-override
M.undo_dir = M.data_dir .. "/undo"
M.session_dir = M.data_dir .. "/sessions"
M.autoload_dir = M.data_dir .. "/autoload"
return MNote
vim.fn.stdpath("config") and vim.fn.stdpath("data") are the portability keys in Neovim. On Linux both are ~/.config/nvim/ and ~/.local/share/nvim/, on macOS the same as Linux, and on Windows they automatically point to %LOCALAPPDATA%\nvim\ and %LOCALAPPDATA%\nvim-data\. By using this API, you do not need to write different absolute paths per OS.
init.luaOnce the env.lua module is ready, use it throughout the config for behavior branching. There are three levels where these conditionals are commonly used:
undodir and session paths that need to be created first (mkdir -p) because their locations differ on Windows.local env = require("config.env")
vim.opt.undofile = true
vim.opt.undodir = env.undo_dir
vim.opt.sessionoptions = "blank,buffers,curdir,folds,help,tabpages,winsize,winpos,terminal,localoptions"
-- Pastikan direktori undo & sessions ada di semua OS
for _, dir in ipairs({ env.undo_dir, env.session_dir }) do
vim.fn.mkdir(dir, "p")
end
-- Clipboard: Windows & macOS butuh perlakuan khusus
if env.is_windows then
vim.opt.clipboard = "" -- gunakan win32yank/win32yank.exe via plugin
else
vim.opt.clipboard = "unnamedplus"
endAn example of a keymap differing per OS — on macOS, Ctrl is heavily used by the system, so some people swap the leader:
local env = require("config.env")
-- Di macOS, banyak yang memakai Leader = "," atau tetap " " (spasi)
if env.is_mac then
vim.g.mapleader = ","
else
vim.g.mapleader = " "
endFinally, an example of a conditional in a plugin spec — some plugins need external binaries with different names per OS (e.g. rg.exe vs rg), or different build commands:
local env = require("config.env")
{
"nvim-telescope/telescope.nvim",
dependencies = {
-- fzf-native adalah C-extension; build-nya sama di semua OS, tapi
-- membutuhkan toolchain C. Di Windows, pastikan ada `make` (e.g. via MSYS2)
{ "nvim-telescope/telescope-fzf-native.nvim", build = "make" },
},
keys = {
{ "<leader>ff", "<cmd>Telescope find_files<CR>", desc = "Find Files" },
{ "<leader>fg", "<cmd>Telescope live_grep<CR>", desc = "Live Grep" },
},
config = function()
-- rg (ripgrep) wajib ada di semua OS; path-nya dipegang Telescope sendiri
require("telescope").setup({
defaults = {
file_ignore_patterns = { "node_modules", ".git", "dist", "build" },
},
})
end,
}Tip
The DRY (Don't Repeat Yourself) principle also applies to dotfiles: all OS-sensitive paths and flags are centralized in one env.lua module, and other modules just require it. If you later move to Windows, you only fix one file, not ten.
After the dotfiles are in Git, the final step is a bootstrap script: one command that installs Neovim along with all its dependencies on a new machine. This is the script you run first after cloning. The principle is the same as server provisioning: idempotent, fast, and safe to run repeatedly.
#!/usr/bin/env bash
set -euo pipefail
# 1. Deteksi OS & pilih package manager
if [[ "$OSTYPE" == "darwin"* ]]; then
install_cmd="brew install"
deps=(neovim git ripgrep fd lazygit)
elif [[ -f /etc/debian_version ]]; then
sudo apt-get update -y
sudo apt-get install -y neovim git ripgrep fd-find
install_cmd="sudo apt-get install -y"
deps=()
elif [[ "$OSTYPE" == "msys" ]] || grep -qi microsoft /proc/version 2>/dev/null; then
echo "Gunakan winget/scoop di Windows, atau jalankan ini di WSL." >&2
exit 1
fi
for pkg in "${deps[@]}"; do
$install_cmd "$pkg"
done
# 2. Setup bare repo dotfiles (sesuaikan dengan remote kalian)
git clone --bare https://github.com/username/dotfiles.git "$HOME/.dotfiles"
alias dotfiles='/usr/bin/git --git-dir=$HOME/.dotfiles --work-tree=$HOME'
dotfiles config status.showUntrackedFiles no
dotfiles checkout
dotfiles submodule update --init --recursive
# 3. Install plugin Neovim secara headless (tanpa UI)
nvim --headless "+Lazy! sync" +qa
nvim --headless "+MasonInstallAll" +qa 2>/dev/null || true
# 4. Verifikasi
nvim --headless -c 'lua print("dotfiles ready ✔")' -c 'qa'
echo "Setup selesai. Buka Neovim dan nikmati config kalian!"curl -fsSL https://raw.githubusercontent.com/username/dotfiles/main/install.sh | bashMany call this pattern "provisioning as code for workstations". Think of it like terraform apply for your laptop — one source of truth, one command, predictable results. When you switch laptops or add a new server, there is no more "what plugins do I need to install?" question.
Caution
Security warning: running curl | bash from the internet is a risky practice if you are not careful. Make sure you review the script's contents before running it (or at least make sure the repository is your own and the correct branch). For corporate environments, it is safer to download the script, review it, then execute it separately.
Secrets leaking into the repository. The most dangerous and most frequent. Solution: use .gitignore, read secrets from the environment or a .env.local file, and before the first push, inspect dotfiles status carefully. If it has already leaked, rotate the secret immediately — Git history does not forgive.
Hardcoding absolute paths. Writing /home/arman/.local/share/nvim/... will break on other machines. Use vim.fn.stdpath(), vim.fn.expand("~"), or the env.lua module.
Plugin paths differing per OS. Some plugins need external binaries at different locations (e.g. fd vs fdfind on Debian, or make not existing by default on Windows). Detect the OS and document the dependency in install.sh.
init.lua that refuses to run on Windows. Mind the differences: path separators (\ vs /), default shells (pwsh vs bash), and vim.opt.clipboard needing an extra plugin (win32yank). One if env.is_windows condition at the start of the config will save a lot of debugging.
Broken symlinks. If you use the symlink approach and later restructure ~/dotfiles/, all symlinks pointing to old paths will break. Use one stable location, and verify with ls -l after setup.
Forgetting to commit changes. Dotfiles are only useful if always in sync. Make it a habit: after tuning the config until comfortable, immediately dotfiles add -A && dotfiles commit. The more often you commit, the safer.
Before considering your config "machine-ready", run through this checklist:
| No | Item | Status |
|---|---|---|
| 1 | Config stored in a Git repository (bare or symlink) | ☐ |
| 2 | No secrets/tokens in the repository | ☐ |
| 3 | All paths use stdpath / $HOME / the env.lua module | ☐ |
| 4 | OS detection (Linux/macOS/Windows) centralized in one module | ☐ |
| 5 | undodir, session directories created automatically (mkdir -p) | ☐ |
| 6 | Bootstrap script idempotent & tested on 2+ machines | ☐ |
| 7 | External dependencies documented in install.sh / README | ☐ |
In episode 24 we learned that your Neovim config is an asset that must be managed professionally: storing it in Git via the bare repository or symlink approach, separating the private from the public, making it portable across platforms with the env.lua module and vim.fn.stdpath(), and automating the whole setup with a bootstrap script. From now on, moving to a new machine is no longer a nightmare — just clone, bootstrap, and done.
The key point: dotfiles are an investment. The time you spend organizing them today will be repaid many times over every time you switch machines, and at the same time becomes a portfolio showing how you work as an engineer.
In episode 25, we will address the question that haunts every Neovim user: should I build my own config from scratch, or just use a distro like LazyVim, NvChad, or AstroNvim? We will compare the pros and cons of each, when to choose which, and how to make Neovim a true daily driver. See you there!