Skip to content
shellhacks.dev
Tooling

How to Keep Secrets Out of Dotfiles With Real Output

Keep secrets out of dotfiles repositories: untracked local files, include directives, encrypted templates, password manager lookups and a scan that proves it.

6 min read
Summarize in AI
Illustration for "How to Keep Secrets Out of Dotfiles With Real Output": a terminal session, gears, a git branch graph.

A public dotfiles repository is a great way to share a setup and a perfect place to leak a token: an API key exported in .zshrc, a credentials line in .npmrc, an email address in .gitconfig that you did not mean to publish. The fix is structural, and it costs a few lines. This post separates configuration from secrets in the usual files and ends with a check. It extends syncing dotfiles and the habits in secrets in shell history.

Source a local file

~/.bashrcShell
# tracked: no secrets here
export EDITOR=vim
export PATH="$HOME/.local/bin:$PATH"

# untracked: secrets and machine-specific values
[ -r "$HOME/.config/shell/local.sh" ] && . "$HOME/.config/shell/local.sh"
.gitignoreText
.config/shell/local.sh
.npmrc
.netrc
*.env
~/.config/shell/local.shShell
export GITHUB_TOKEN="ghp_xxxxxxxx"
export OPENAI_API_KEY="sk-xxxxxxxx"

The tracked file stays shareable, and the real values live in a file that the ignore list keeps out of Git. Create it with mode 600 (install -m 600 /dev/null ~/.config/shell/local.sh). The guard makes a fresh machine start cleanly before the local file exists. The ignore list is a safety net, not the design: do not keep the local file inside the repository directory at all.

Git and SSH includes

~/.gitconfigINI
[include]
    path = ~/.gitconfig.local
[includeIf "gitdir:~/work/"]
    path = ~/.gitconfig.work
~/.ssh/configText
Include ~/.ssh/config.local
Host *
    AddKeysToAgent yes
    ServerAliveInterval 60

The tracked .gitconfig holds aliases and preferences; ~/.gitconfig.local holds user.name, user.email, signing keys and credential helpers. includeIf "gitdir:~/work/" applies a different identity inside one directory tree, which also prevents committing to work repositories under a personal address. SSH's Include does the same for host entries, which often reveal internal host names.

Encrypted dotfiles

Shell
chezmoi init --apply https://github.com/example/dotfiles
age-keygen -o ~/.config/chezmoi/key.txt
chezmoi add --encrypt ~/.config/shell/local.sh
chezmoi git add .

chezmoi, a dotfiles manager, can encrypt chosen files with age or GPG before they enter the repository, with the recipient configured in chezmoi.toml. The encrypted file is safe to push; the private key (key.txt) must never be, and has to be copied to each new machine by another route, such as a password manager. This suits secrets you need on every machine. Encryption protects only as long as the key does, and re-encrypting after a leak does not help, as old commits keep the old ciphertext.

Fetch at use time

Shell
export GITHUB_TOKEN="$(op read 'op://Private/GitHub token/credential')"
alias deploy='AWS_SECRET_ACCESS_KEY="$(pass show aws/deploy)" ./deploy.sh'

A password manager CLI (1Password's op, pass, gopass, Bitwarden's bw) can hand a value to a single command. Nothing is stored in a file, and a locked vault means no access. Do not run the lookup in your shell's startup file for every new terminal: it slows each one down and prompts repeatedly. Do it in a function called when needed, or per project with direnv. See pass, gopass and age.

Prove nothing leaked

Shell
gitleaks git --redact -v
git ls-files | xargs grep -nEi '(token|secret|passw|api[_-]?key)\s*[=:]' | head
git log -p --all -S'ghp_' --oneline | head

Run a secret scanner over the whole history, because deleting a file does not remove it from earlier commits. The grep over tracked files and the pickaxe search for a token prefix are quick manual checks. If something is found, rotate it first; history rewriting comes after, as described in gitleaks and trufflehog. Add the scanner as a pre-commit hook so the next mistake is caught on your machine.

Where this does not apply

Environment variables are visible to every child process and, on Linux, to the same user through /proc; see environment variable leaks. A private repository is not a secrets store: access changes, forks and backups spread copies. Shell history records pasted tokens, and HISTCONTROL=ignorespace with a leading space keeps a command out of it.

How secrets leak from a dotfiles repository

A dotfiles repository is meant to be shared, backed up and often public, and the files in it are exactly where people put things they should not: an API token exported in .zshrc, a password in a Git credential helper line, a private host name in an SSH configuration, a key in a rarely-opened script. The danger is that Git remembers. A secret committed once stays in the history after the file is edited, in every clone and fork, and in any cache the hosting service keeps. Deleting the line in a later commit does nothing for readers of the history, so the response to a leak is always to revoke and rotate the secret, and the cleanup of history is optional and incomplete. That makes prevention the real work. Decide before the first commit what the repository will never contain, and set up the structure so the safe thing is also the easy thing: secrets live in a separate place, loaded by the files that are committed, and the separate place is ignored by Git by default.

Public repositories are scanned by automated tools within minutes of a push, and a leaked cloud key is often abused before the owner notices. Treat a push as irreversible.

Layering a local file under the shared one

The simplest structure is a committed file that sources an uncommitted one. In the shell startup file, add a line that loads ~/.config/shell/local.sh if it exists, and list that path in the ignore rules, outside the repository directory entirely if you can, so no wrong git add can pick it up. The same pattern exists for the tools that matter. Git reads an include with [include] path = ~/.gitconfig.local, so your name, email and signing key can differ per machine, and credentials stay out of the shared file. OpenSSH reads Include ~/.ssh/config.local, which keeps private host names and key paths local. Set the mode of the local files to 600 and the directory to 700, since they hold sensitive data, and keep an example file in the repository with the variable names and placeholder values, so a new machine knows what to fill in. A fresh checkout then works with the example and the real values appear only on machines you control.

Check how a shell file is loaded. Variables exported in a startup file are inherited by every program you start, including ones that log their environment, so a secret in the environment is wider exposure than it looks.

Encryption, fetching on demand and proving the result

Some people prefer to keep encrypted secrets in the repository. Tools such as age, sops and git-crypt, and managers like chezmoi that integrate with them, encrypt files before they are committed and decrypt them on a machine that has the key. That is sound if the key is kept out of the repository and backed up elsewhere, and a mistake in setup, such as committing a file before it was marked for encryption, leaks it in plain text. The alternative is to store nothing and fetch secrets when needed from a password manager command, such as pass, gopass or a manager's command-line tool, in a shell function that reads the value into a variable only for one command. Then no file holds the secret at rest. To prove nothing leaked, scan the whole history with gitleaks or trufflehog, search for known token prefixes with git log -p -S, and run a scanner as a pre-commit hook so new mistakes are caught before they exist.

See the chezmoi age encryption guide and git-config includes.

Also inSecurity
Share

Keep reading

Get new posts by email

One email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.