Dotfiles are the hidden configuration files that define how your shell, editor, Git, and dozens of other tools behave. Environment variables are the runtime values those files set — PATH, EDITOR, API keys, locale settings. Together, they are your development environment, and managing them well means you can recreate your setup on any machine in minutes.
In short: put your dotfiles in a Git repo, use GNU Stow or chezmoi to symlink them into place, follow the XDG Base Directory spec to keep $HOME clean, and use direnv for project-specific variables. This guide walks through each step with real examples.
Why Your Shell Setup Matters
Every time you open a terminal, the shell reads a set of configuration files to set up your environment. These files control everything from your prompt appearance to which commands are available. If you've never intentionally managed them, you've probably accumulated years of random additions — aliases from Stack Overflow, PATH entries from installers, and export lines you copied without understanding.
The problem shows up when you:
- Set up a new machine — you spend hours recreating your environment from memory, missing half the customizations
- SSH into a server — none of your aliases or functions exist, and you feel lost
- Debug a CI pipeline — the build fails because an environment variable is set differently than on your local machine
- Collaborate — your coworker's shell behaves completely differently, making pair debugging painful
I hit this wall when I set up 32blog's development environment across three machines — my main workstation, a laptop, and a VPS. I had no idea which .bashrc was the "real" one. Some machines had aliases I'd forgotten about, others were missing tools I relied on daily. That's when I started managing dotfiles properly.
Shell Initialization: Which File Loads When
This is where most people get confused. Bash and Zsh load different files depending on how the shell is invoked, and getting this wrong means your environment variables silently don't apply.
Bash Loading Order
Bash distinguishes between login shells (when you sign into a machine via SSH, TTY, or bash --login) and non-login interactive shells (when you open a terminal emulator):
# Login shell reads these files:
# 1. /etc/profile — system-wide settings (always read)
# Then looks for ONE of these (stops at the first one found):
# 2. ~/.bash_profile — user login config
# 3. ~/.bash_login — fallback if .bash_profile doesn't exist
# 4. ~/.profile — fallback if neither of the above exist
# Non-login interactive shell reads:
# 1. /etc/bash.bashrc — system-wide (some distros)
# 2. ~/.bashrc — your personal config
The critical gotcha: .bashrc is NOT read by login shells, and .bash_profile is NOT read by non-login shells. This is why you'll see most dotfiles guides recommend adding this line to .bash_profile:
# ~/.bash_profile
# Source .bashrc so login shells get the same config as interactive shells
[[ -f ~/.bashrc ]] && source ~/.bashrc
This way, you put all your actual configuration in .bashrc and use .bash_profile only to source it. One source of truth.
Zsh Loading Order
Zsh is a bit more predictable:
# All Zsh sessions:
# 1. /etc/zshenv → ~/.zshenv — always loaded, even for scripts
# Login shells also load:
# 2. /etc/zprofile → ~/.zprofile — login-specific setup
# 3. /etc/zshrc → ~/.zshrc — interactive config
# 4. /etc/zlogin → ~/.zlogin — after .zshrc
# Interactive non-login shells:
# 1. /etc/zshenv → ~/.zshenv
# 2. /etc/zshrc → ~/.zshrc
# On logout:
# ~/.zlogout → /etc/zlogout
The key insight: .zshenv is always loaded — even for non-interactive script execution. This makes it the right place for environment variables that should always be available. .zshrc is for interactive settings like aliases, prompt, and key bindings.
# ~/.zshenv — environment variables (always loaded)
export EDITOR="nvim"
export LANG="en_US.UTF-8"
# ~/.zshrc — interactive only (prompt, aliases, completions)
autoload -Uz compinit && compinit
alias ll='ls -lah'
Visual Summary
┌──────────────────────────────────────────────────┐
│ BASH │
│ │
│ Login shell: /etc/profile → ~/.bash_profile │
│ Interactive: /etc/bash.bashrc → ~/.bashrc │
│ Script: (none by default) │
├──────────────────────────────────────────────────┤
│ ZSH │
│ │
│ Always: /etc/zshenv → ~/.zshenv │
│ Login: + ~/.zprofile → ~/.zshrc │
│ Interactive: + ~/.zshrc │
│ Script: only ~/.zshenv │
└──────────────────────────────────────────────────┘
If you're writing shell scripts, check out the shell scripting guide for more on how the shell processes these files when running scripts.
Environment Variables in Practice
Environment variables are key-value pairs inherited by child processes. When you export a variable in your shell, every command you run after that can read it.
Setting and Exporting
# Set a variable (local to this shell only)
MY_VAR="hello"
# Export it (child processes inherit it)
export MY_VAR="hello"
# Set and export in one line (most common)
export EDITOR="nvim"
export GOPATH="$HOME/go"
# View all environment variables
env
# or
printenv
# Check a specific variable
echo $PATH
printenv PATH
The distinction between set and export trips people up. A variable that isn't exported exists only in the current shell — it won't be visible to commands you run or scripts you execute.
PATH: The Most Important Variable
PATH is a colon-separated list of directories where the shell looks for executables. When you type git, the shell searches each directory in PATH from left to right until it finds a match.
# Typical PATH on a Linux system
echo $PATH
# /home/furuya/.local/bin:/usr/local/bin:/usr/bin:/bin
# Add a directory to PATH (prepend — higher priority)
export PATH="$HOME/.local/bin:$PATH"
# Add to the end (lower priority)
export PATH="$PATH:$HOME/go/bin"
A common pattern in my .bashrc:
# ~/.bashrc — PATH construction
# Only add directories that exist
add_to_path() {
[[ -d "$1" ]] && [[ ":$PATH:" != *":$1:"* ]] && export PATH="$1:$PATH"
}
add_to_path "$HOME/.local/bin"
add_to_path "$HOME/.cargo/bin"
add_to_path "$HOME/go/bin"
add_to_path "$HOME/.npm-global/bin"
This add_to_path function prevents duplicates and skips non-existent directories. After a few years of .bashrc modifications, PATH duplication is a real problem — I once had the same directory in PATH six times because every installer appended it without checking.
Common Environment Variables
| Variable | Purpose | Example |
|---|---|---|
PATH | Executable search path | /usr/local/bin:/usr/bin |
HOME | User's home directory | /home/furuya |
EDITOR | Default text editor | nvim |
VISUAL | Visual editor (GUI-capable) | code --wait |
SHELL | User's login shell | /bin/zsh |
LANG | Locale setting | en_US.UTF-8 |
TERM | Terminal type | xterm-256color |
XDG_CONFIG_HOME | User config directory | ~/.config |
PAGER | Default pager for man, etc. | less |
GPG_TTY | TTY for GPG signing | $(tty) |
Secrets and .env Files
Never put secrets in your shell configuration files. .bashrc and .zshrc often end up in Git repos (your dotfiles), and secrets in version control are a disaster waiting to happen.
For project secrets, use .env files:
# .env (NOT committed to Git)
DATABASE_URL="postgres://user:pass@localhost:5432/32blog_dev"
STRIPE_SECRET_KEY="sk_test_abc123"
NEXTAUTH_SECRET="random-secret-here"
Make sure .env is in your .gitignore, and create a .env.example with placeholder values:
# .env.example (committed to Git — no real values)
DATABASE_URL="postgres://user:pass@localhost:5432/mydb"
STRIPE_SECRET_KEY="sk_test_..."
NEXTAUTH_SECRET="generate-with-openssl-rand"
For personal secrets that need to be in your shell environment (like API tokens for CLI tools), consider using your OS keychain or a tool like pass (the standard Unix password manager):
# Store a secret
pass insert dev/github-token
# Use it in your shell config
export GITHUB_TOKEN=$(pass show dev/github-token)
XDG Base Directory: Cleaning Up $HOME
Run ls -la ~ and you'll probably see a mess of dotfiles. The XDG Base Directory Specification solves this by defining standard locations for different types of files:
| Variable | Default | Purpose |
|---|---|---|
XDG_CONFIG_HOME | ~/.config | Configuration files |
XDG_DATA_HOME | ~/.local/share | Application data |
XDG_STATE_HOME | ~/.local/state | State data (logs, history) |
XDG_CACHE_HOME | ~/.cache | Non-essential cached data |
XDG_RUNTIME_DIR | /run/user/$UID | Runtime files (sockets, locks) |
Many modern tools already respect XDG. For example:
~/.config/git/config # instead of ~/.gitconfig
~/.config/htop/htoprc # htop config
~/.config/btop/btop.conf # btop config
~/.config/nvim/init.lua # Neovim config
Set these in your .zshenv or .bash_profile:
export XDG_CONFIG_HOME="$HOME/.config"
export XDG_DATA_HOME="$HOME/.local/share"
export XDG_STATE_HOME="$HOME/.local/state"
export XDG_CACHE_HOME="$HOME/.cache"
For tools that don't natively support XDG, you can often redirect them with environment variables:
# Force tools to use XDG directories
export HISTFILE="$XDG_STATE_HOME/bash/history"
export LESSHISTFILE="$XDG_STATE_HOME/less/history"
export NPM_CONFIG_USERCONFIG="$XDG_CONFIG_HOME/npm/npmrc"
export DOCKER_CONFIG="$XDG_CONFIG_HOME/docker"
export CARGO_HOME="$XDG_DATA_HOME/cargo"
export GOPATH="$XDG_DATA_HOME/go"
export RUSTUP_HOME="$XDG_DATA_HOME/rustup"
The Arch Wiki's XDG page maintains a comprehensive list of which tools support XDG and how to configure those that don't. I reference it every time I install a new CLI tool.
Managing Dotfiles with Git and GNU Stow
Once your dotfiles are organized, you need a way to track changes, share them across machines, and deploy them with one command. There are three popular approaches.
Approach 1: GNU Stow (Recommended for Simplicity)
GNU Stow is a symlink manager. You organize dotfiles in a directory that mirrors your home directory structure, and Stow creates symlinks for you.
# Install GNU Stow (2.4.1 as of 2024)
sudo apt install stow # Debian/Ubuntu
sudo pacman -S stow # Arch
brew install stow # macOS
Set up the directory structure:
mkdir -p ~/dotfiles
cd ~/dotfiles
git init
# Create directories that mirror where the files should live
mkdir -p bash/.config
mkdir -p git/.config/git
mkdir -p nvim/.config/nvim
mkdir -p tmux/.config/tmux
Move your actual config files into the Stow directory:
# Move .bashrc into the bash package
mv ~/.bashrc ~/dotfiles/bash/
# Move Git config
mv ~/.config/git/config ~/dotfiles/git/.config/git/config
# Move Neovim config
mv ~/.config/nvim/init.lua ~/dotfiles/nvim/.config/nvim/init.lua
Now deploy with Stow:
cd ~/dotfiles
# Stow each package — creates symlinks in $HOME
stow bash # ~/.bashrc → ~/dotfiles/bash/.bashrc
stow git # ~/.config/git/config → ~/dotfiles/git/.config/git/config
stow nvim # ~/.config/nvim/init.lua → ~/dotfiles/nvim/.config/nvim/init.lua
stow tmux
# Stow everything at once
stow */
# Remove symlinks for a package
stow -D bash
# Re-stow (remove then re-create — useful when you add files)
stow -R bash
The beauty of Stow is that it's transparent — your tools don't know they're reading symlinked files. And because the dotfiles directory is a normal Git repo, you get full version history.
cd ~/dotfiles
git add -A
git commit -m "feat: add initial dotfiles"
git remote add origin git@github.com:your-username/dotfiles.git
git push -u origin main
Setting up a new machine becomes:
git clone git@github.com:your-username/dotfiles.git ~/dotfiles
cd ~/dotfiles
stow */
Approach 2: chezmoi (For Complex Setups)
chezmoi (v2.70.0) is a Go-based dotfile manager with built-in templating, secret management, and multi-machine support. It's more complex than Stow but handles scenarios Stow can't:
# Install chezmoi
sh -c "$(curl -fsLS get.chezmoi.io)"
# Initialize from an existing dotfiles repo
chezmoi init --apply your-username
# Add a file to chezmoi management
chezmoi add ~/.bashrc
# Edit a managed file
chezmoi edit ~/.bashrc
# See what would change
chezmoi diff
# Apply changes
chezmoi apply
chezmoi's killer feature is templating. You can have one .bashrc template that produces different output on different machines:
# ~/.local/share/chezmoi/dot_bashrc.tmpl
export EDITOR="nvim"
{{ if eq .chezmoi.hostname "work-laptop" }}
export HTTP_PROXY="http://proxy.corp.example.com:8080"
{{ end }}
{{ if eq .chezmoi.os "darwin" }}
eval "$(/opt/homebrew/bin/brew shellenv)"
{{ end }}
Approach 3: Bare Git Repository
The bare repo approach uses Git directly without any extra tools:
# Initialize
git init --bare $HOME/.dotfiles
# Create an alias
alias dotfiles='git --git-dir=$HOME/.dotfiles --work-tree=$HOME'
# Ignore untracked files
dotfiles config --local status.showUntrackedFiles no
# Use it like normal git
dotfiles add ~/.bashrc
dotfiles commit -m "add bashrc"
dotfiles push
This is the lightest approach — no dependencies beyond Git. But it gets awkward when you need to exclude files or manage multiple machines.
Which to Choose?
| Feature | GNU Stow | chezmoi | Bare repo |
|---|---|---|---|
| Dependencies | Perl (pre-installed) | Go binary | Git only |
| Templating | No | Yes (Go templates) | No |
| Secret management | No | Yes (age, gpg, keyring) | No |
| Multi-machine | Manual | Built-in | Manual |
| Learning curve | Low | Medium | Low |
| Rollback | Git history | Built-in diff/apply | Git history |
My recommendation: start with GNU Stow. It does one thing well (symlinks), and you can always migrate to chezmoi later if you need templating or secret management. I've been using Stow for my personal dotfiles for two years now and haven't needed anything more.
direnv: Per-Project Environment Variables
direnv (v2.37.1) automatically loads and unloads environment variables when you cd into a project directory. Instead of scattering export statements across your shell config, each project gets its own .envrc file.
Installation and Setup
# Install direnv
sudo apt install direnv # Debian/Ubuntu
sudo pacman -S direnv # Arch
brew install direnv # macOS
# Hook into your shell (add to .bashrc or .zshrc)
eval "$(direnv hook bash)" # for Bash
eval "$(direnv hook zsh)" # for Zsh
Basic Usage
# Create a .envrc in your project
cd ~/projects/32blog
echo 'export NODE_ENV="development"' > .envrc
# direnv blocks it until you approve (security feature)
direnv: error /home/furuya/projects/32blog/.envrc is blocked.
Run `direnv allow` to approve its content.
# Allow it
direnv allow
# Now NODE_ENV is set when you're in this directory
echo $NODE_ENV
# development
# Leave the directory — variable is automatically unloaded
cd ~
echo $NODE_ENV
# (empty)
Real-World .envrc Patterns
# ~/projects/32blog/.envrc
# Load .env file (common pattern)
dotenv
# Add project-local binaries to PATH
PATH_add node_modules/.bin
PATH_add .bin
# Set project-specific tool versions
export NODE_VERSION="22.14.0"
use node
# Source a shared team configuration
source_env ../.shared-env
# Set AWS profile for this project
export AWS_PROFILE="32blog-prod"
The dotenv command loads a .env file automatically. This means your .env file (with secrets, not committed) and .envrc file (with non-secret config, committed) work together:
# .envrc (committed to Git)
dotenv
PATH_add node_modules/.bin
export NODE_ENV="development"
# .env (NOT committed — in .gitignore)
DATABASE_URL="postgres://..."
API_SECRET="..."
direnv with Node.js Projects
When working on 32blog (a Next.js project), my .envrc looks like this:
# ~/projects/32blog/.envrc
dotenv # load .env
PATH_add node_modules/.bin # use local eslint, prettier, etc.
export NEXT_TELEMETRY_DISABLED=1
This means I can run next dev or eslint . without npx — the local node_modules/.bin is in my PATH only when I'm inside the project directory.
FAQ
What's the difference between .bashrc and .bash_profile?
.bash_profile is read by login shells (SSH sessions, TTY login, bash --login). .bashrc is read by non-login interactive shells (opening a terminal emulator). The standard practice is to source .bashrc from .bash_profile so you only maintain one file.
Should I use Bash or Zsh?
Either works. Zsh has better completion, globbing, and plugin support out of the box. macOS has shipped Zsh as default since Catalina (2019). Bash is more portable and is the standard shell on most Linux distributions. If you write shell scripts meant for others, target Bash. For your personal interactive shell, use whichever feels better. See the shell scripting guide for more on script portability.
How do I share dotfiles across machines with different OSes?
Use chezmoi's templating to conditionally include OS-specific config. Or with GNU Stow, create separate packages (bash-linux, bash-macos) and stow only the relevant one. Another approach: use if statements in your .bashrc:
if [[ "$(uname)" == "Darwin" ]]; then
eval "$(/opt/homebrew/bin/brew shellenv)"
fi
Can I use direnv with Docker?
direnv runs in your host shell, not inside containers. But you can use the .env file loaded by direnv's dotenv command with Docker Compose's env_file directive — they use the same format:
# docker-compose.yml
services:
app:
env_file: .env
Where should I set PATH additions?
In .zshenv if you use Zsh (it loads for all shell types including scripts). In .bashrc if you use Bash (and make sure .bash_profile sources .bashrc). Avoid setting PATH in multiple files — that causes duplicates and makes it hard to debug.
How do I keep secrets out of my dotfiles repo?
Use .gitignore to exclude .env and other secret files. For secrets needed in your shell environment, use a password manager like pass or your OS keychain. chezmoi has built-in support for age encryption, 1Password, Bitwarden, and other secret managers.
What is XDG and do I need it?
XDG Base Directory is a freedesktop.org specification that defines where config, data, cache, and state files should live. You don't strictly need it, but following it keeps your $HOME directory clean and makes dotfile management easier. Start by setting the XDG variables and gradually migrating tools.
How do I manage tool-specific configs like .gitconfig or .vimrc?
Move them into your dotfiles repo under the appropriate Stow package. For Git specifically, the XDG-compliant path is ~/.config/git/config. For tmux, it's ~/.config/tmux/tmux.conf (tmux 3.2+). See each tool's documentation for XDG support — the Arch Wiki XDG page is the best reference.
Wrapping Up
Dotfiles and environment variables are the foundation of your development environment. Getting them organized pays off every time you set up a new machine, debug a CI pipeline, or onboard someone to your project.
Start simple: put your .bashrc or .zshrc and .gitconfig in a Git repo, use GNU Stow to symlink them, and add direnv for project-specific variables. That's 90% of the value for 10% of the effort. Graduate to chezmoi when you need templating or multi-OS support.
The shell initialization order is worth memorizing — it explains most "why isn't my variable set?" mysteries. And XDG Base Directory is worth adopting gradually, even if just for the satisfaction of a clean ls ~.
For related topics, check out the alias guide for organizing shell aliases, the tmux guide for terminal multiplexer configuration, and the shell scripting guide for writing portable scripts that respect these conventions.