Module 4: Permissions, Processes, and Environment

7. Configuring your shell: rc files, aliases, and dotfiles

Description

By the end of this lesson you will be able to explain which file your shell reads and when — .bashrc versus .bash_profile, .zshrc versus .zprofile — to diagnose why "my variable works in one terminal but not another"; make a PATH change persistent instead of losing it every time you close the window; create aliases and functions out of commands you already repeat daily; and leave your configuration protected against two silent leaks: a secret saved forever in the command history, and a secret saved forever in a git repository's history.

This is not terminal cosmetics. The first week at any job with a new laptop, or the first time you set up a server from scratch, you are going to go through the same ritual: installing tools, exporting variables, defining shortcuts. Without understanding which file does what, that ritual turns into a list of commands copied from a tutorial that "sometimes work" — and the day you open a new terminal tab and your freshly installed tool throws command not found, you are not going to know whether the problem is the PATH, the wrong file, or something deeper.

Connection to the module: the previous lesson taught you what the PATH is and how to diagnose which binary really runs when two versions are installed — but everything you did there lived only in the open terminal session; the moment you closed it, it disappeared. Today you solve exactly that problem: where the configuration that survives closing the terminal lives, and how to build it professionally. The next lesson is the module's final project, and one of the three failures you are going to repair there — a badly built PATH — you are going to leave permanently fixed using precisely what you learn today.

Every terminal you open rebuilds your work environment from scratch

Think about a shift at a coffee shop with two different routines. When someone opens the shop at the start of the day, they run through a long list: turn on the machines, count the register, check the inventory. When a barista who was already working simply comes back to the counter after a break, they do not repeat that whole list — they just wash their hands and put the apron back on. It is the same person, the same shop, but two completely different startup routines depending on how they "walked in" to work at that moment.

Your shell does exactly that every time it opens, and it uses two independent questions to decide which startup routine to run:

  • Is it a login shell? — equivalent to "opening the shop." It happens when you actually log in: connecting over SSH to a server, or opening a terminal on macOS (Terminal.app and iTerm2 start login shells by default). It reads the long configuration list: environment variables, PATH, everything that needs to exist before you can work.
  • Is it an interactive shell? — means a human is typing and waiting for a prompt, whether or not it was a login. A new tab inside tmux, or typing bash or zsh on their own inside another already-open shell, are interactive non-login shells: "coming back to the counter," not "opening the shop."

These two questions are independent from each other, and every combination triggers a different file:

ShellLogin (full startup)Interactive non-login (counter)
bash/etc/profile, then the first one that exists among ~/.bash_profile, ~/.bash_login, ~/.profile~/.bashrc
zsh~/.zshenv~/.zprofile~/.zshrc~/.zlogin~/.zshenv~/.zshrc

Notice the key difference: in bash, a login shell does not automatically read .bashrc — they are two separate paths that do not touch each other on their own. In zsh, on the other hand, every interactive shell reads .zshrc regardless of whether it is also a login one, so a macOS terminal (login and interactive) ends up reading .zprofile and .zshrc, in that order. That asymmetry between bash and zsh is the exact technical reason behind "this used to work on my other terminal": if you put something only in .bash_profile, any non-login shell (a lot of new tabs on many Linux emulators, a script) is never going to see it.

The standard practice in bash to avoid that problem is having .bash_profile explicitly load .bashrc, so the content lives in a single place no matter how the shell started:

# inside ~/.bash_profile
if [ -f ~/.bashrc ]; then
  source ~/.bashrc
fi

Worked example

You are going to install a tool whose binary lives at ~/bin, and you want any new terminal to find it without you having to export the PATH by hand every time.

First, two commands to know which shell you are standing in and whether it is a login one:

echo $SHELL
echo $0

What to expect:

$ echo $SHELL
/bin/zsh

$ echo $0
-zsh

$SHELL tells you your default shell as assigned on the system (not necessarily the one running right now, if you typed a different one by hand). $0 is the interesting piece of data: the leading dash (-zsh, not zsh) is the convention bash and zsh use to mark a login shell — with no dash, you would know it is an interactive non-login shell. With that confirmed, you know on macOS with login zsh you are going to read .zprofile and then .zshrc.

Now, add the PATH to the right file — .zshrc, because it is the one every interactive zsh session reads with no exceptions, login or not — and apply it without closing the terminal:

echo 'export PATH="$HOME/bin:$PATH"' >> ~/.zshrc
source ~/.zshrc
echo $PATH

What to expect:

$ echo 'export PATH="$HOME/bin:$PATH"' >> ~/.zshrc
$ source ~/.zshrc
$ echo $PATH
/Users/dev/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin

source ~/.zshrc (short equivalent: . ~/.zshrc) rereads the file inside your current shell, with no new process opened — that is why the change shows up immediately in this same window. Opening a new terminal would have achieved the same thing, because it also reads .zshrc from scratch, but with a real difference: you would lose any variable or working directory you had built by hand in that session and that does not live in any file. source applies the file without discarding the rest of the current shell's state.

If your shell is bash instead of zsh, the same change goes in .bashrc (assuming your .bash_profile already loads it as shown above):

echo 'export PATH="$HOME/bin:$PATH"' >> ~/.bashrc
source ~/.bashrc

Aliases, functions, and why sometimes you need the second one

An alias is a text substitution: you tell the shell "when I type this, actually run this other thing." It is a perfect fit for commands you repeat identically, keystroke for keystroke:

alias gs='git status'
alias ll='ls -lah'
alias ..='cd ..'

But an alias accepts no logic or real positional arguments — it is text glued in front of whatever you type after it. The moment you need the command to do something with a value you pass it, you need a function:

mkcd() {
  mkdir -p "$1" && cd "$1"
}

mkcd new-project creates the folder and enters it in one step; $1 is the first argument you passed it, something an alias cannot reliably reference. The practical rule: if the command is fixed, alias; if it needs to receive a piece of data and decide what to do with it, function. Both go in the same file you already identified above (.zshrc or .bashrc), because they are interactive-shell configuration, same as the PATH.

History: a powerful tool, a silent leak

history lists the commands you already ran, numbered. !42 runs command number 42 again with no retyping. And Ctrl+R opens an incremental backward search: you start typing a fragment and the shell shows you, live, the most recent command containing it — press Ctrl+R again to keep searching backward if the first result was not the one you wanted.

The problem is that everything you type on the command line, with no exceptions, gets saved to a plain-text file (~/.bash_history or ~/.zsh_history) — including an export API_KEY=sk_live_... you typed once without thinking. The previous lesson taught you to keep secrets out of code with an .env file at 600 permissions; the history is a different leak, one that care does not cover, because the secret never touched a file — you typed it straight into the prompt.

The standard defense in bash is the HISTCONTROL variable:

# in ~/.bashrc
export HISTCONTROL=ignoreboth

ignoreboth combines two behaviors: ignorespace (any command starting with a blank space does not get saved to the history) and ignoredups (does not repeat identical consecutive lines). With that on, all it takes is prepending a space to a sensitive command:

 export STRIPE_KEY=sk_live_abc123

for that line to never reach the history file. In zsh, the equivalent is the setopt HIST_IGNORE_SPACE option inside .zshrc. It is a habit, not an automatic reflex — you have to remember the space in the moment — but it is the difference between a secret that lives thirty seconds in your memory and one that lives forever in an unencrypted text file.

Version-controlled dotfiles: your configuration as code, not as luck

"Dotfile" is simply any configuration file whose name starts with a dot — .zshrc, .bashrc, .gitconfig, .vimrc — and that by Unix convention stays hidden in normal listings. The professional practice is treating them as code: version-controlled in your own git repository, not as loose files that only exist on your laptop and that you would lose entirely if the disk fails.

A simple, widely used pattern is the bare repository with a dedicated alias, instead of manual symlinks:

git clone --bare <your-repo-url> "$HOME/.dotfiles"
alias dotfiles='/usr/bin/git --git-dir=$HOME/.dotfiles/ --work-tree=$HOME'
dotfiles config --local status.showUntrackedFiles no
dotfiles checkout

With that, dotfiles add ~/.zshrc and dotfiles commit version your configuration exactly like any project, with git never confusing your $HOME with a normal repository. The real benefit: if you switch laptops, or if your team standardizes the development environment, your full configuration gets reproduced with a clone instead of you rebuilding it from memory — and every change stays in a commit history you can review and revert.

Honesty about oh-my-zsh and similar frameworks

Frameworks like oh-my-zsh package prompt themes, dozens of community plugins (git, docker, kubectl autocompletion) and syntax highlighting, ready to turn on with nothing written by you. For someone just starting to build their .zshrc, it is a reasonable starting point: sensible defaults, a huge community, zero manual configuration.

The real cost is startup time: every plugin and every theme you turn on is extra code that runs before showing you the prompt, so an install with several active plugins (git, docker, kubectl, syntax highlighting, autosuggestions) feels noticeably slower opening a new terminal than a framework-free .zshrc with the same three or four lines you actually use. Do not trust a number from memory — measure it yourself on your own machine:

for i in $(seq 1 5); do time zsh -i -c exit; done

What to expect: five lines with a real time each; if that number surprises you compared to what you would expect from an "empty" shell, there is your concrete answer, not someone else's figure. If startup feels slow, the right answer is not "getting used to it" — it is pruning the plugins you do not use, or evaluating lighter plugin managers (Zinit or Antidote, which defer plugin loading until after the prompt shows up) that give equivalent functionality with less work per startup. There is no wrong choice here — there is a cost worth measuring on your own machine before accepting it.

Common mistakes

Editing the wrong file for the type of shell you actually have (conceptual). What happens: you add an export to .bash_profile, confirm it works in your terminal, and then the same change "disappears" in a new tab, in an SSH session, or inside a script. Why it happens: .bash_profile only gets read by a login shell; any interactive non-login shell (a lot of new tabs on Linux, a hand-typed bash) only reads .bashrc, and never touches .bash_profile. They are two files with different audiences, not two names for the same thing. How to spot it: run echo $0 in the session where it fails — if it does not have the leading dash (bash instead of -bash), you confirm it is not a login shell, so .bash_profile never got read there. How to fix it: put the configuration in .bashrc, and have .bash_profile load it with source ~/.bashrc so both cases are covered with no duplicated content.

Assuming an alias works the same inside a script as in the terminal (conceptual). What happens: you define alias ll='ls -lah', use it all day with no problem, and then write a script that starts with ll on the first line — and running it with ./script.sh fails with command not found: ll. Why it happens: aliases, by design, only expand in interactive shells — a script run as a file runs in a non-interactive shell, which does not even load .bashrc or .zshrc by default, so the alias never existed in that context. How to spot it: if a command that "always works" fails specifically inside a script or a cron job, suspect first an alias or a function defined only in your interactive file. How to fix it: inside scripts, write the full command (ls -lah, not ll) — aliases are for you typing, not for code that runs without you.

Storing a secret in plain text inside a version-controlled dotfile (practical and security). What happens: you put export STRIPE_KEY=sk_live_abc123 directly in your .zshrc, and that file lives in your dotfiles repository you pushed to GitHub to have it backed up. Months later you delete that line, but the secret is still exposed. Why it happens: git does not forget — the commit where you added the line with the key still exists in the repository's history even if you delete it in a later commit; anyone with access to the repository (or its public history) can recover that earlier version. How to spot it: check with git log -p whether any commit in your dotfiles repo ever had a key, token, or password in plain text, regardless of whether it is no longer in the file's current version. How to fix it: never put a secret's real value in a file you are going to version — export the variable from a separate file that does not enter the repository (add it to .gitignore) and have your .zshrc or .bashrc load it with source; if a secret already got exposed in git history, consider it compromised and rotate it, because deleting it from the file does not delete it from the history.

Exercises

1. A coworker tells you: "I added export EDITOR=vim to my ~/.bash_profile, it works perfectly on my macOS terminal, but when I SSH into our Ubuntu server and open a new tmux tab inside that same SSH session, $EDITOR shows up empty." They run echo $0 inside that tmux tab and get bash (no leading dash). Explain what is going on and which file they should use instead.

See solution

The original SSH connection really was a login shell (that is why .bash_profile got read and $EDITOR worked there), but a new tab inside tmux, opened once the session is already running, is an interactive non-login shell — the dashless $0 confirms it. That shell never reads .bash_profile; it only reads .bashrc. The fix is moving (or duplicating via source) export EDITOR=vim to ~/.bashrc, and ideally having .bash_profile load .bashrc with source ~/.bashrc so the value stays available no matter how each shell started. This works because login and interactive are two independent questions bash resolves by reading different files, and a tmux tab answers "yes" to the second but "no" to the first.

2. Write a shell function called backup that takes a file's path as an argument and creates a copy with the .bak suffix in the same directory (for example, backup notes.txt should create notes.txt.bak). Explain why this has to be a function and not an alias.

See solution
backup() {
  cp "$1" "$1.bak"
}

It has to be a function because it needs to receive a real argument ($1, the path you pass it) and use it twice inside some logic — once as-is, once with the suffix added. An alias is fixed text substitution: alias backup='cp' would only prepend cp to whatever you type after it, with no reliable way to repeat that argument with a different suffix on the same line. This works because functions do receive full positional parameters ($1, $2, etc.) while aliases have no such mechanism.

3. By accident, you typed directly into the terminal export DB_PASSWORD=hunter2 to test a quick connection, with no leading space that would have kept it from being saved. What configuration should you have active so this does not happen again, and what would you do about this specific line that already ended up in the history?

See solution

For the future, the HISTCONTROL=ignoreboth variable (bash) or setopt HIST_IGNORE_SPACE (zsh), added to .bashrc or .zshrc, makes any command starting with a blank space get excluded from the history — the right habit from then on is prepending a space to any command with a secret in it. For the line that already got saved, "not doing it again" is not enough: hunter2 is already in plain text in ~/.bash_history or ~/.zsh_history, so it is worth treating that password as potentially seen by anyone with access to that file or that machine and rotating it (replacing it with a new one), in addition to being able to delete that specific line from the history with history -d <number> in bash. This works because HISTCONTROL prevents the problem going forward, but does not retroactively clean a file that already wrote the data to disk — the only real defense against an already-exposed secret is rotating it.

4. Explain, in your own words, why version-controlling your dotfiles in a git repository forces you to be more careful with secrets than when those same files only lived loose in your $HOME.

See solution

When a dotfile only lives on your disk, a secret inside it is risky but limited to that machine and disappears if you edit or delete the file. The moment that same file enters a git repository — especially if you push it to a remote service like GitHub — any commit where the secret appears stays permanently preserved in the repository's history, accessible with git log -p even after you delete the line in a later commit, and potentially visible to anyone with access to the repository (collaborators, or the whole public if it is a public repo). This works because git is designed to never lose earlier versions of a file — that is exactly its usefulness for code, but it is the exact reason a secret should never enter a commit, not even "for a moment" with the intention of deleting it afterward.

Summary and next step

Today you saw that every terminal you open rebuilds your environment by reading a different file based on two independent questions — is it a login one? is it interactive? — and that distinction is the exact technical cause behind "my variable works in one terminal and not another." You learned to make a PATH change persistent in the right file, to choose between alias and function based on whether you need arguments, to use source to apply changes without closing your session, to protect your history from secrets with HISTCONTROL, and to treat your dotfiles as version-controlled code instead of fragile configuration that only exists on one laptop.

Before moving on you should be able to: explain from memory the difference between .bash_profile/.bashrc and between .zprofile/.zshrc; add a line to your PATH that survives closing the terminal; write an alias and a function and justify when to use each one; and explain why a secret typed directly into the terminal, or saved in a version-controlled dotfile, is a different leak from an unprotected .env file.

What you learned today is exactly the tool you are missing to close the module: the next lesson is a project where you are going to trigger and repair three real failures, and one of them — a command not found caused by a badly built PATH — you are going to leave permanently fixed in your rc file, with documented evidence, instead of fixing it "just for this session" and breaking it again the next time you open the terminal.

Resources