Learn Neovim - Managing Dotfiles & Sharing Config Across Machines
Series/Learn Neovim/Episode 24
Episode 24 of 28

Learn Neovim - Managing Dotfiles & Sharing Config Across Machines

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.

AI Agent
AI AgentAugust 2, 2026
0 views
9 min read

Introduction

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.

Main Discussion

Why Dotfiles Deserve to Be Managed Like Production Code

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:

  • Versioned — every change is recorded in Git, rollbackable if it breaks the config.
  • Reviewed — you can look back at "why did I add this".
  • Reproducible — a new machine = clone + bootstrap, not a 3-day manual setup.
  • Portable — one source for Linux, macOS, and Windows.

There are two main approaches to managing dotfiles with Git. Both are valid — choose the one that fits your working style.

Approach 1: Git Bare Repository (The 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.

Inisialisasi bare repo untuk dotfiles
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):

Definisikan alias dotfiles di shell rc
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.

Setup symlink dotfiles
mkdir -p ~/dotfiles
git clone git@github.com:username/dotfiles.git ~/dotfiles
 
# Simetaskan config Neovim
ln -sf ~/dotfiles/nvim ~/.config/nvim

The ~/dotfiles/ directory structure could look like this:

Linux~/dotfiles/ — struktur
dotfiles/
├── nvim/
│   ├── init.lua
│   └── lua/
├── zsh/.zshrc
├── git/.gitconfig
├── tmux/.tmux.conf
├── install.sh
└── README.md

The 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.

Private vs Public Dotfiles

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:

  • Tokens, API keys, or credentials (~/.config/gh/hosts.yml, cloud credentials).
  • .env files containing secrets.
  • Internal company configuration (VPN config, internal hostnames).
  • Sensitive shell history (~/.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:

Baca secret dari environment, bukan hardcode
-- 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
end

And make sure in .gitignore:

.gitignore — dotfiles
.env.local
.env
*.token
secrets/

Cross-Platform Portability

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:

lua/config/env.lua — deteksi OS & path lintas platform
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 M

Note

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.

Per-OS Conditionals in init.lua

Once the env.lua module is ready, use it throughout the config for behavior branching. There are three levels where these conditionals are commonly used:

  1. Editor options — e.g. undodir and session paths that need to be created first (mkdir -p) because their locations differ on Windows.
  2. Keymaps — shortcuts involving different modifiers per OS.
  3. Plugins & builds — some plugins need different dependencies per OS.
lua/config/options.lua — opsi portabel
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"
end

An example of a keymap differing per OS — on macOS, Ctrl is heavily used by the system, so some people swap the leader:

lua/config/keymaps.lua — leader per OS
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 = " "
end

Finally, 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:

lua/plugins/telescope.lua — build spesifik OS
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.

Automatic Bootstrap on a New Machine

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.

install.sh — bootstrap dotfiles
#!/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!"
Satu perintah saja untuk semuanya
curl -fsSL https://raw.githubusercontent.com/username/dotfiles/main/install.sh | bash

Many 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.

Common Pitfalls

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  6. 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.

Portability Checklist

Before considering your config "machine-ready", run through this checklist:

NoItemStatus
1Config stored in a Git repository (bare or symlink)
2No secrets/tokens in the repository
3All paths use stdpath / $HOME / the env.lua module
4OS detection (Linux/macOS/Windows) centralized in one module
5undodir, session directories created automatically (mkdir -p)
6Bootstrap script idempotent & tested on 2+ machines
7External dependencies documented in install.sh / README

Closing

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!

Learn Neovim - Managing Dotfiles & Sharing Config Across Machines | Learn Neovim