A pattern for maintaining two separate GitHub identities on one machine (e.g. personal and work), with automatic switching based on which directory tree you are working in. No manual switching required after initial setup.
Git and the gh CLI each have their own concept of "who you are":
- Git embeds a name and email into every commit you make.
ghuses a stored OAuth token to make API calls (creating PRs, viewing issues, etc.).- Git push/pull also needs credentials — if you use
ghas your git credential helper (the default when you rungh auth login), it reads the same stored token.
When you have two GitHub accounts on one machine, you need all three to automatically use the right identity depending on which repo you are in, without you having to remember to switch anything manually.
Three tools work together, each handling one layer:
| Layer | Tool | Mechanism |
|---|---|---|
| Commit identity (name/email) | git | includeIf "gitdir:" in ~/.gitconfig |
| gh CLI auth + git push/pull auth | gh + direnv | GH_CONFIG_DIR env var |
Automatic env switching on cd |
direnv | ~/work/.envrc |
Git's includeIf is evaluated at parse time using the location of the
repo's .git directory — it requires no external tools and has no runtime
cost. It handles identity completely independently of everything else.
gh auth git-credential (the credential helper git calls during push/pull)
is a normal gh subprocess. It inherits the full shell environment from
whatever process invoked git, including GH_CONFIG_DIR. The gh library
reads GH_CONFIG_DIR before anything else during startup, giving it absolute
priority over the default config path. This means a single env var controls
both gh CLI operations and git authentication simultaneously.
direnv sits at the shell level: it hooks into your shell's prompt command
and loads or unloads .envrc files as your working directory changes. This
means GH_CONFIG_DIR is always set correctly for the directory you are in,
regardless of what you do next — run git push, run gh pr create, or open
a subshell.
- git — any reasonably recent version
ghCLI — must be configured as your git credential helper. Verify with:If it is not set, rungit config --list | grep 'credential\..*helper=' # expected output (same on all platforms when gh is configured): # credential.https://git.521000.best.helper= # credential.https://git.521000.best.helper=!/usr/bin/gh auth git-credential
gh auth loginand it will be configured automatically.- direnv (github.com/direnv/direnv) — install via your package manager:
direnv has a wiki with recipes and integrations covering a wide range of tools — worth browsing if you use direnv beyond this pattern. Notable pages include integrations with Python (automatic virtualenv activation per project), Node (per-directory Node version management), VSCode (making the editor pick up direnv-managed environment variables), and Claude Code. These are just a few examples — the wiki also covers editors like Vim and Emacs, cloud tools like Terraform, secret managers like 1Password, and more, so if you use a tool not listed here it is worth checking whether a page exists for it.
# Debian/Ubuntu sudo apt install direnv # macOS brew install direnv # Fedora sudo dnf install direnv
Add to the end of your shell RC file (~/.bashrc, ~/.zshrc, etc.):
eval "$(direnv hook bash)"Restart your shell or source ~/.bashrc.
This is a one-time operation. direnv will now silently watch for .envrc
files as you navigate directories.
The above is for bash. Other shells use different hook syntax — see the direnv hook documentation for the correct snippet for your shell.
Note: IDEs and graphical editors launched outside your terminal may not inherit
GH_CONFIG_DIRset by direnv. See IDEs and graphical editors in Edge Cases.
Your personal account lives in the default ~/.config/gh/ (created when
you first ran gh auth login). Create a separate isolated directory for
your work account and authenticate into it:
GH_CONFIG_DIR=~/.config/gh-work gh auth loginFollow the interactive prompts as normal. This stores your work account's
OAuth token in ~/.config/gh-work/hosts.yml — completely separate from
your personal account's ~/.config/gh/hosts.yml.
Verify both accounts are set up independently:
gh auth status # personal
GH_CONFIG_DIR=~/.config/gh-work gh auth status # workLock down the work config directory and its contents:
chmod 700 ~/.config/gh-work
find ~/.config/gh-work -maxdepth 1 -type f -exec chmod 600 {} \;These two commands together prevent any other user on the machine from looking into the directory or reading the files inside it.
Append the line to ~/work/.envrc (creates the file if it does not already exist):
echo 'export GH_CONFIG_DIR=$HOME/.config/gh-work' >> ~/work/.envrcThen trust the file:
direnv allow ~/workdirenv allow is a one-time trust step. direnv will not execute any
.envrc it has not been explicitly told to trust — this prevents a
rogue .envrc from executing if one appeared somewhere in your filesystem.
After this, any shell session inside ~/work/ or any subdirectory of it
will have GH_CONFIG_DIR set automatically. Moving out of that tree
unsets it, reverting to the default ~/.config/gh/ (your personal account).
Add to ~/.gitconfig (the personal identity should already be there):
[user]
name = YourPersonalName
email = personal@example.com
[includeIf "gitdir:~/work/"]
path = ~/.gitconfig-workCreate ~/.gitconfig-work:
[user]
name = YourWorkName
email = work@yourcompany.comThe includeIf "gitdir:~/work/" directive merges the work config on top of
the global config for any repo whose .git directory is under ~/work/.
The [user] block in ~/.gitconfig-work overrides the global one.
Note: The trailing slash on
~/work/is required for subtree matching, and applies to everyincludeIfblock you add. Without it, git matches only a repo whose.gitis at exactly that path — not any repo inside it. If you later add a third or fourth identity (e.g.~/client-a/,~/client-b/), the same rule applies to each:[includeIf "gitdir:~/work/"] # ✓ matches all repos under ~/work/ path = ~/.gitconfig-work [includeIf "gitdir:~/work"] # ✗ matches only ~/work/.git itself path = ~/.gitconfig-workEach additional identity needs its own
includeIfblock with a trailing slash, its own~/.gitconfig-<name>file, and (if using Pattern B) its own named config directory and.envrc.
Note: If you use commit signing (GPG or SSH), the
signingkeyfield under[user]in~/.gitconfigapplies to all repos unless overridden. Add asigningkeyentry under[user]in~/.gitconfig-workas well, so work commits are signed with the right key.
Git 2.36+ supports includeIf "hasconfig:remote.*.url:<pattern>", which
matches based on the repo's remote URL rather than its location on disk:
[includeIf "hasconfig:remote.*.url:git@github.com:mycompany/*"]
path = ~/.gitconfig-work
[includeIf "hasconfig:remote.*.url:https://github.com/mycompany/*"]
path = ~/.gitconfig-workBoth HTTPS and SSH URL forms need their own entry if you use both. The * in
remote.*.url is a glob matching any remote name (origin, upstream, etc.).
This ties commit identity to something intrinsic to the repo — where it lives
on GitHub — rather than where you cloned it on disk. A work repo cloned
anywhere on your machine automatically gets the work identity. You can mix and
match: use URL-based matching for git identity and still use direnv +
GH_CONFIG_DIR for gh auth, since those are independent layers. Or combine
both includeIf forms if you want belt-and-suspenders coverage.
The trade-off: URL-based matching only covers the git identity layer. It has no
effect on GH_CONFIG_DIR — the direnv setup from Steps 1–3 is still required
for gh CLI operations and git push authentication regardless of which
includeIf form you use.
If you have previously pushed to GitHub from this machine, a credential helper
may have cached a token for github.com. Git tries credential helpers in
order and uses the first one that returns a result — so a cached credential
from an old helper will silently take priority over gh auth git-credential,
regardless of which GH_CONFIG_DIR is active.
First, check which helpers are configured:
git config --list --show-origin --show-scope | grep 'credential\..*helper='The pattern matches both the bare global form (credential.helper) and the
URL-scoped form (credential.https://git.521000.best.helper) that gh auth login
sets. A clean setup — only gh configured — looks like this on all platforms:
global file:~/.gitconfig credential.https://git.521000.best.helper=
global file:~/.gitconfig credential.https://git.521000.best.helper=!/usr/bin/gh auth git-credential
global file:~/.gitconfig credential.https://gist.521000.best.helper=
global file:~/.gitconfig credential.https://gist.521000.best.helper=!/usr/bin/gh auth git-credential
The empty-value entries are reset entries that clear any previously inherited
helper — they are normal. If you see an additional non-empty helper= line
beyond the gh entries, you need to clear it. What this looks like varies by
platform:
- macOS:
credential.helper=osxkeychain - Linux:
credential.helper=libsecretorcredential.helper=/usr/lib/git-core/git-credential-libsecret - Windows 11:
credential.helper=manager - WSL2:
credential.helper=/mnt/c/Program Files/Git/mingw64/bin/git-credential-manager.exeorcredential.helper=manager - Any platform (store):
credential.helper=store
Use the platform-specific instructions below to clear the extra helper before continuing.
Enterprise note: All commands below use
github.comas the hostname. If you are using GitHub Enterprise Server or GitHub Enterprise Cloud with Enterprise Managed Users, replacegithub.comwith your enterprise hostname (e.g.github.mycompany.comormycompany.ghe.com) — credential helpers key stored credentials by hostname, so the hostname must match exactly. This substitution is confirmed to work for GHES and GHEC EMU; for other enterprise configurations it should behave the same way, but we cannot guarantee it.
printf "host=github.com\nprotocol=https\n" | git credential rejectThis tells the active helper chain to erase the stored credential for
github.com. Note that the gh auth git-credential helper intentionally
treats erase as a no-op — it does not revoke or delete anything. This
command only affects legacy helpers (osxkeychain, libsecret, GCM, store, etc.)
that are also present in the chain. If the grep above showed only gh entries,
this command will silently do nothing, which means you are already clean.
Run it, then skip to Verify below. If credentials persist, use the platform-specific instructions.
printf "host=github.com\nprotocol=https\n" | git credential-osxkeychain eraseOr open Keychain Access, search for github.com, and delete any entries
associated with your git username.
printf "host=github.com\nprotocol=https\n" | git credential-libsecret eraseOr open Passwords and Keys (Seahorse), find the github.com entry under
Passwords, and delete it.
Open KWallet Manager (kwalletmanager5), navigate to the Passwords folder,
find any entry for github.com, and delete it. If you have
git-credential-kwallet installed:
printf "host=github.com\nprotocol=https\n" | git credential-kwallet eraseOpen Credential Manager (Start menu → search "Credential Manager"), select
Windows Credentials, find the entry named git:https://github.com, and
click Remove. Or from the command line:
printf "host=github.com\nprotocol=https\n" | git credential-manager eraseGCM internally normalises gist.github.com to github.com, so a single erase
of https://github.com covers both hosts.
WSL2 is commonly configured to forward credentials to the Windows Git Credential Manager. Check which helper is in use:
git config --list --global | grep 'credential\..*helper='
# common output — GCM via full path:
# credential.helper=/mnt/c/Program Files/Git/mingw64/bin/git-credential-manager.exe
# or via name if GCM is in PATH:
# credential.helper=managerIf WSL2 is using the Windows GCM bridge, the credential is stored on the Windows side — clear it via Windows Credential Manager as described above. If WSL2 has its own credential helper configured independently, use the appropriate Linux instructions instead.
If credential.helper = store appears in your config, credentials are stored
in plaintext in ~/.git-credentials. Remove the github.com line:
[ -f ~/.git-credentials ] && \
grep -v 'github\.com' ~/.git-credentials > ~/.git-credentials.tmp && \
mv ~/.git-credentials.tmp ~/.git-credentialsA daemon-based in-memory cache that expires automatically after a configurable timeout (default 15 minutes) and also clears on reboot. To clear immediately:
git credential-cache exitConfirm the correct git identity is active from inside any repo under ~/work/:
git config --show-origin --show-scope user.name
git config --show-origin --show-scope user.email
# expected: global file:~/.gitconfig-work work@yourcompany.comNote: includeIf only fires inside a git repository — running this from
~/work/ itself (not inside a repo) will show the global value, not the work
override.
Confirm the correct gh account is active with:
gh auth statusOutside ~/work/ this should show your personal account; inside ~/work/
(with GH_CONFIG_DIR set by direnv) it should show your work account.
If you want to verify the full credential pipeline end-to-end:
printf "host=github.com\nprotocol=https\n" | git credential fillNote: This prints
password=<live token>to stdout. It confirms thatgh auth git-credentialis being invoked and respectsGH_CONFIG_DIR, but it exposes a live credential in your terminal. Avoid running it in sessions with terminal logging or recording enabled, and clear your terminal afterwards.
- Your shell has
GH_CONFIG_DIR=~/.config/gh-work(set by direnv). git pushneeds credentials. It forksgh auth git-credential get.- The forked subprocess inherits the environment, including
GH_CONFIG_DIR. ghresolves its config directory:GH_CONFIG_DIRis set, so it reads~/.config/gh-work/hosts.yml.- It returns the work account's OAuth token to git.
- The push authenticates as your work account.
The commit itself already has the right author because includeIf caused
~/.gitconfig-work to be loaded when git read the repo's config.
- Your shell has
GH_CONFIG_DIR=~/.config/gh-work. gh pr createreads its config from~/.config/gh-work/hosts.yml.- The PR is created under your work account. No switching required.
The setup above uses ~/.config/gh/ as the personal account fallback.
This means any directory without an active .envrc — including one
accidentally shadowed by an intermediate .envrc missing source_up_if_exists / source_up —
silently uses your personal account. For most people this is acceptable:
the failure mode is a push or PR landing on the wrong account, which is
visible and correctable.
If you would prefer that any missing or broken .envrc causes an immediate,
obvious auth failure rather than silently using the wrong account, use this
alternative instead:
Do not authenticate into ~/.config/gh/ at all. Leave it absent or
empty. Create two named config directories and two .envrc files:
# Set up both accounts in named directories
GH_CONFIG_DIR=~/.config/gh-personal gh auth login
GH_CONFIG_DIR=~/.config/gh-work gh auth login
chmod 700 ~/.config/gh-personal ~/.config/gh-work
find ~/.config/gh-personal ~/.config/gh-work -maxdepth 1 -type f -exec chmod 600 {} \;For each directory, append the line to the .envrc (creates the file if it does not already exist):
# ~/code/.envrc
echo 'export GH_CONFIG_DIR=$HOME/.config/gh-personal' >> ~/code/.envrc
direnv allow ~/code
# ~/work/.envrc
echo 'export GH_CONFIG_DIR=$HOME/.config/gh-work' >> ~/work/.envrc
direnv allow ~/workNow, outside of ~/code/ and ~/work/, GH_CONFIG_DIR is unset and
gh falls back to ~/.config/gh/ — which has no authenticated account.
Any gh command or git push in that context fails immediately with an
auth error, making the missing .envrc obvious rather than silent.
The same applies if an intermediate .envrc shadows a parent one without
source_up: GH_CONFIG_DIR gets unset for that subtree, and operations
fail loudly until you fix the .envrc.
Trade-off: You must remember to create an appropriate .envrc (or pass
GH_CONFIG_DIR explicitly) anywhere outside ~/code/ and ~/work/ that
you want to use gh or push to GitHub. This is slightly more friction but
eliminates the silent-wrong-account failure mode entirely.
The git identity side (includeIf) can be made equally fail-loud. By default,
if no user.name or user.email is configured, git attempts to auto-detect
them from your system username and hostname and commits anyway — silently, with
a potentially unrecognisable identity.
Two settings together prevent this entirely:
1. Remove any existing global identity, if you have one set:
git config --global --unset user.name
git config --global --unset user.email2. Set user.useConfigOnly = true in ~/.gitconfig:
git config --global user.useConfigOnly true3. Add includeIf blocks for both directories in ~/.gitconfig:
[includeIf "gitdir:~/code/"]
path = ~/.gitconfig-personal
[includeIf "gitdir:~/work/"]
path = ~/.gitconfig-workCreate ~/.gitconfig-personal:
[user]
name = YourPersonalName
email = personal@example.com(~/.gitconfig-work was already created in Step 4.)
user.useConfigOnly tells git never to auto-detect identity — user.name and
user.email must be explicitly configured at some level or git refuses to
commit. Outside ~/code/ and ~/work/, no identity is configured and
includeIf has no match, so any commit attempt fails immediately with a clear
error. Inside those directories, includeIf provides the explicit identity and
commits work normally.
- Token storage:
hosts.ymlvs OS keyring - HTTPS vs SSH remotes
GH_TOKENtakes precedence overGH_CONFIG_DIR- Repos cloned outside their expected tree
- Git worktrees checked out outside their repo's directory tree
- Unusual environment or tool configurations
- Symlinks
- New terminal windows opened inside
~/work/ - Subshells and scripts
- IDEs and graphical editors
- Adding new
.envrcfiles in subdirectories direnv allowafter editing.envrc- Verifying the active identity at any time
gh auth login may store your token in the OS keyring rather than directly
in hosts.yml, depending on your platform and gh version. GH_CONFIG_DIR
still works in this case: gh reads the active username from hosts.yml
(which is in GH_CONFIG_DIR), then uses that username to look up the token
in the keyring by service name. So the right token is still retrieved, just
via an extra indirection. You do not need to do anything differently.
Caution
Forcing file-based token storage is not recommended. Choosing "store in
plain text" bypasses your OS keyring's at-rest encryption — the token is
written as readable text into hosts.yml, accessible to any process running
as your user, and included verbatim in any unencrypted backup. The OS keyring
exists specifically to prevent this. Only do this if you have a concrete
portability requirement and understand what you are giving up.
If despite the above you need to force file-based storage, you can do so during login:
GH_CONFIG_DIR=~/.config/gh-work gh auth login --git-protocol https
# when asked about credential storage, choose "store in plain text"--git-protocol https tells gh to configure the git credential helper for
HTTPS during login. Without it, gh may default to SSH, which bypasses the
credential helper entirely — the helper is only invoked for HTTPS push/pull
operations. It has no effect on how the token itself is stored; the plaintext
storage choice is made separately via the interactive prompt.
This pattern handles HTTPS remotes (token-based auth via the credential
helper). If a work repo is using an SSH remote, gh auth git-credential
is never invoked — SSH handles authentication instead, using whichever key
matches the github.com entry in ~/.ssh/config. In that case,
GH_CONFIG_DIR has no effect on the push/pull auth (though gh CLI
operations still use it). If you have two GitHub accounts on the same host
and use SSH, you need separate SSH keys and host aliases — that is a
separate setup not covered here.
If GH_TOKEN (or GITHUB_TOKEN) is set in the environment, gh uses it
directly and ignores whatever is in GH_CONFIG_DIR/hosts.yml. If both are
set, GH_TOKEN takes priority over GITHUB_TOKEN.
This is intentional behaviour in CI environments — GitHub Actions automatically
injects GITHUB_TOKEN into every workflow run so that gh and git
authenticate as the Actions bot for that repo, without any manual setup. In
that context the override is exactly what you want.
The hazard in an interactive session is accidental leakage: a CI bootstrap
script sourced into your shell, a tool that sets GH_TOKEN globally, or a
.envrc that exports it — any of these will silently override GH_CONFIG_DIR
for as long as the variable is set. If authentication seems wrong despite the
setup looking correct, check whether the variable is present:
echo $GH_TOKEN
echo $GITHUB_TOKENIf either is set unexpectedly, find the source and remove it from your interactive shell environment.
includeIf "gitdir:~/work/" and direnv .envrc files both trigger on
physical filesystem location. A work repo cloned into ~/code/ or anywhere
outside ~/work/ will use your personal identity and personal gh auth
(Pattern A), or fail with an auth error (Pattern B).
The fix is simply to keep repos in the right directory tree. If you need a repo in an unexpected location, set the identity manually in that repo:
git config user.email work@yourcompany.com
git config user.name YourWorkNameAnd either add a local .envrc pointing to the right config dir, or pass
GH_CONFIG_DIR explicitly when running gh commands there.
git worktree add lets you check out a branch from an existing repo into a
separate directory. The linked worktree contains a .git file (not a
directory) that points back to the original repo's .git directory.
This creates an asymmetry between the two layers of this pattern:
includeIf "gitdir:~/work/"follows the pointer and evaluates against the real.gitlocation — which is still inside~/work/. So the work git identity is applied correctly, even in a worktree checked out elsewhere.- direnv only cares about where your shell currently is. A worktree at
~/tmp/hotfix/is outside~/work/, soGH_CONFIG_DIRis not set. In Pattern A,ghsilently uses your personal account; in Pattern B, it fails with an auth error.
The result: commits get the right work identity, but gh operations and
git push authenticate as the wrong account (or not at all), with no warning.
This only affects worktrees checked out outside their repo's directory tree,
which is uncommon. The straightforward fix is to keep linked worktrees inside
~/work/ alongside their main repo. If you need one elsewhere, add a local
.envrc in the worktree directory:
echo 'export GH_CONFIG_DIR=$HOME/.config/gh-work' > ~/tmp/hotfix/.envrc
direnv allow ~/tmp/hotfixSome tools — IDEs, wrapper scripts, git GUIs — set the GIT_DIR environment
variable explicitly to point git at a specific repository. If GIT_DIR is set
to a path outside ~/work/, includeIf "gitdir:~/work/" will not match, and
the work identity will not be applied, even if you are working in what you
consider a work repo.
This is technically correct behaviour — git is doing exactly what it was told —
but it can produce unexpected results if a tool is misdirecting GIT_DIR
without you realising. If commits are getting the wrong identity, check whether
GIT_DIR is set:
echo $GIT_DIRIf it is set to a path outside ~/work/, the source is whatever tool or script
set it. The fix is at that layer, not in the git config.
includeIf "gitdir:~/work/" expands ~ but does not resolve symlinks in the pattern itself. Git matches against the real (symlink-resolved) path of the .git directory. If ~/work is itself a symlink to another location, the pattern ~/work/ will not match — the pattern stays as the symlink path while the .git path resolves to the real location, so they diverge.
The fix is to use the real path in the pattern:
realpath ~/work # e.g. /opt/projects/work[includeIf "gitdir:/opt/projects/work/"]
path = ~/.gitconfig-workOn macOS, where APFS volumes are case-insensitive by default, you can use gitdir/i: for case-insensitive path matching — uppercase and lowercase in the path are treated as equivalent:
[includeIf "gitdir/i:~/work/"]
path = ~/.gitconfig-workThis is a separate concern from symlink resolution and does not affect it.
Test with git config user.email inside a work repo to confirm the right identity is active.
direnv hooks into the shell prompt, not just cd. When a new terminal
opens directly into a directory inside ~/work/, direnv fires on the first
prompt and loads the .envrc. This works correctly — you do not need to
cd out and back in.
direnv sets env vars in the interactive shell. A subshell (e.g. bash -c 'git push') inherits exported env vars, so GH_CONFIG_DIR is present.
However, scripts invoked via cron, systemd, or other non-interactive
mechanisms do not go through your shell's prompt hook and will not have
GH_CONFIG_DIR set. For automated scripts, pass the variable explicitly:
GH_CONFIG_DIR=~/.config/gh-work gh pr listdirenv hooks into the shell prompt — it only runs when an interactive shell
session changes directory. IDEs and graphical editors launched from a desktop
icon, dock, or application menu start their own process outside your shell, so
they do not go through the direnv hook and will not have GH_CONFIG_DIR set.
This means git identity (name/email) still works correctly — includeIf is
evaluated by git itself against the filesystem every time git runs, with no
shell involvement. But gh CLI operations and git push authentication from
inside the IDE will use the wrong account (Pattern A) or fail with an auth
error (Pattern B), silently.
The simplest fix is to open the IDE from a direnv-activated terminal:
cd ~/work/myrepo
code . # or idea ., or whatever your editor's CLI isThe IDE process inherits the full shell environment, including GH_CONFIG_DIR.
For a more robust solution that works regardless of how you open the IDE, check
the direnv wiki — several editors
have community plugins that load .envrc files directly into the IDE's
environment when you open a project.
If you create a new .envrc inside a subdirectory of ~/work/ (e.g.
~/work/project/.envrc), direnv will use that file instead of
~/work/.envrc for that subtree — they do not merge. If the subdirectory
.envrc does not re-export GH_CONFIG_DIR, it will be unset there. To
inherit the parent while adding more variables, explicitly source the parent
at the top of the subdirectory .envrc using
source_up_if_exists:
# ~/work/project/.envrc
source_up_if_exists # loads nearest parent .envrc if one exists
export SOME_OTHER_VAR=valuesource_up_if_exists silently does nothing if no parent .envrc is found.
If you want the .envrc to fail loudly when there is no parent — for example,
to catch a misconfigured directory layout — use source_up instead, which
errors out if no parent .envrc exists.
Whenever you edit ~/work/.envrc, direnv will block it and require another
direnv allow ~/work. This is intentional — it prevents an edited file
from executing without your explicit sign-off. You will see a warning in
your prompt when this happens.
# Which git identity will be used here?
# Must be run from inside a git repository — includeIf does not fire outside one.
git config --show-origin --show-scope user.name
git config --show-origin --show-scope user.email
# Which gh account is active here?
gh auth statusIf you are not yet inside a repo, create a temporary one to test:
git init ~/work/tmp-identity-test
cd ~/work/tmp-identity-test
git config --show-origin --show-scope user.email
# expected: global file:~/.gitconfig-work work@yourcompany.com
rm -rf ~/work/tmp-identity-testBoth commands are cheap and safe to run any time you are unsure.
| File | Purpose |
|---|---|
~/.bashrc (or shell RC) |
eval "$(direnv hook bash)" |
~/.config/gh/ |
Personal gh config (existing default) |
~/.config/gh-work/ |
Work gh config directory |
~/work/.envrc |
Sets GH_CONFIG_DIR=~/.config/gh-work |
~/.gitconfig |
Personal identity (default) + includeIf for work |
~/.gitconfig-work |
Work identity (name/email override) |
| File | Purpose |
|---|---|
~/.bashrc (or shell RC) |
eval "$(direnv hook bash)" |
~/.config/gh-personal/ |
Personal gh config directory |
~/.config/gh-work/ |
Work gh config directory |
~/code/.envrc |
Sets GH_CONFIG_DIR=~/.config/gh-personal |
~/work/.envrc |
Sets GH_CONFIG_DIR=~/.config/gh-work |
~/.gitconfig |
No default identity; user.useConfigOnly = true; includeIf for both directories |
~/.gitconfig-personal |
Personal identity (name/email) |
~/.gitconfig-work |
Work identity (name/email) |
Note: I have not personally tested the tool-specific instructions in this section. The git +
ghsetup described in the rest of this document is tested and validated; this section was assembled from documentation and community sources and represents a best-effort reference. The environment variables and general patterns described here are accurate to the linked documentation, but there may be edge cases, version-specific behaviour, or subtle interactions that only surface in practice. If you find something that does not work as described, I would appreciate a comment or correction.
The combination of environment variables and direnv is not unique to gh.
Many developer tools follow the same general approach: an environment variable
controls which configuration or credentials the tool uses, and direnv
automatically sets that variable based on your working directory. If you
already have direnv hooked into your shell and a directory-based project
layout, extending this pattern to other tools is usually a one-line addition
to your .envrc.
Tools generally fall into one of two categories:
Config directory redirection — an environment variable points the tool at
an entirely separate configuration directory. This is what GH_CONFIG_DIR
does. The tool reads all of its state — credentials, preferences, cached
data — from the alternate directory. This provides full isolation between
identities: each directory is a self-contained world, and there is no risk of
settings from one identity leaking into another. The trade-off is that you
maintain multiple copies of the full configuration, and any non-identity
settings (output format preferences, default regions, plugin config) are also
separated. This is usually desirable — your work and personal accounts likely
should have different default regions or output formats — but it means
changes to shared preferences must be applied to each directory independently.
Profile selection — an environment variable selects a named profile within a single configuration file. The tool still reads from its default config location, but uses a different section of it. This is simpler to set up and avoids duplicating shared settings, but does not provide full isolation: all profiles share the same file, and a misconfigured profile name silently falls back to the default rather than failing. It also means credentials for all identities live in the same file, which may matter for your threat model.
Both sub-patterns work with direnv. Choose based on how much isolation you need and how many non-identity settings differ between your contexts.
The following is not exhaustive, but covers tools that developers commonly need to use with multiple identities or environments. For each tool, the relevant environment variables are listed along with which sub-pattern they follow, any security considerations, and links to documentation.
| Variable | Sub-pattern | What it controls |
|---|---|---|
AWS_PROFILE |
Profile selection | Selects a named profile from ~/.aws/config and ~/.aws/credentials |
AWS_CONFIG_FILE |
Config directory redirection | Path to an alternate config file |
AWS_SHARED_CREDENTIALS_FILE |
Config directory redirection | Path to an alternate credentials file |
AWS_PROFILE is the simplest approach: define profiles in ~/.aws/config
and ~/.aws/credentials using aws configure --profile <name>, then set
AWS_PROFILE in your .envrc:
export AWS_PROFILE=work
For full isolation, redirect both files:
export AWS_CONFIG_FILE=$HOME/.aws/config-work
export AWS_SHARED_CREDENTIALS_FILE=$HOME/.aws/credentials-work
AWS credentials files contain long-lived access keys in plaintext by default.
Lock down file permissions (chmod 600) and prefer short-lived credentials
via aws sso login or role assumption where possible.
This is a mature, well-documented pattern — the AWS CLI has supported these variables for years and essentially every AWS SDK and tool respects them.
Docs: AWS CLI environment variables, Configuration and credential files
| Variable | Sub-pattern | What it controls |
|---|---|---|
CLOUDSDK_ACTIVE_CONFIG_NAME |
Profile selection | Selects a named configuration |
CLOUDSDK_CONFIG |
Config directory redirection | Path to the entire config directory (default ~/.config/gcloud) |
gcloud has a built-in concept of named configurations (gcloud config configurations create <name>). The easiest approach is to create
configurations for each identity and switch between them:
export CLOUDSDK_ACTIVE_CONFIG_NAME=work
For full isolation — including separate OAuth tokens and cached data — redirect the entire config directory:
export CLOUDSDK_CONFIG=$HOME/.config/gcloud-work
Individual properties can also be overridden via CLOUDSDK_<SECTION>_<PROPERTY>
(e.g. CLOUDSDK_CORE_PROJECT, CLOUDSDK_COMPUTE_REGION), which is useful for
setting project-level defaults without creating a full separate configuration.
Google's own documentation suggests using direnv for this exact purpose. This is a mature pattern.
Docs: Managing gcloud CLI configurations
| Variable | Sub-pattern | What it controls |
|---|---|---|
AZURE_CONFIG_DIR |
Config directory redirection | Path to the entire config directory (default ~/.azure) |
Azure CLI does not have a built-in named-profile mechanism like AWS or gcloud. The only way to maintain multiple authenticated sessions is to redirect the entire config directory:
export AZURE_CONFIG_DIR=$HOME/.azure-work
Then run az login with that variable set to authenticate the work identity
into the alternate directory.
Individual settings can be overridden via AZURE_<SECTION>_<NAME> (e.g.
AZURE_CORE_OUTPUT, AZURE_DEFAULTS_LOCATION), but there is no
AZURE_DEFAULTS_SUBSCRIPTION — the active subscription is stored in the
config directory's profile state, not as a standalone override. This is a
known gap.
The config directory contains cached OAuth tokens. Lock down permissions
on alternate directories the same way you would for ~/.azure.
This is a mature pattern, well-supported by the CLI.
Docs: Azure CLI configuration
| Variable | Sub-pattern | What it controls |
|---|---|---|
KUBECONFIG |
Config directory redirection | Path to one or more kubeconfig files |
KUBECONFIG can point to a single file or a colon-separated list of files
that kubectl merges in memory:
export KUBECONFIG=$HOME/.kube/config-work
Or merge multiple files:
export KUBECONFIG=$HOME/.kube/config-work:$HOME/.kube/config-shared
kubectl also supports --context for per-command context selection without
changing the active context, and tools like
kubectx make interactive switching
more ergonomic.
Kubeconfig files contain cluster certificates and authentication tokens.
The same file-permission caution applies. Note that unlike most tools listed
here, KUBECONFIG supports merging multiple files — this is a feature unique
to kubectl and extremely useful for keeping per-cluster configs separate
while still being able to use them together.
This is the most mature pattern on this list — multi-cluster kubeconfig management is a core part of the Kubernetes workflow.
Docs: Organizing cluster access, Configure access to multiple clusters
| Variable | Sub-pattern | What it controls |
|---|---|---|
TF_CLI_CONFIG_FILE |
Config directory redirection | Path to the CLI configuration file (credentials, plugin cache, etc.) |
TF_WORKSPACE |
Profile selection | Selects a named workspace |
TF_DATA_DIR |
Config directory redirection | Path to the .terraform data directory |
For multi-environment Terraform work, the most common direnv pattern is
setting TF_WORKSPACE and cloud-provider credentials together:
export TF_WORKSPACE=staging
export AWS_PROFILE=staging
TF_CLI_CONFIG_FILE is more relevant when you need separate Terraform Cloud
or Enterprise credentials (stored in the CLI config's credentials block)
for different organizations:
export TF_CLI_CONFIG_FILE=$HOME/.terraformrc-work
Terraform's own documentation warns that TF_WORKSPACE is recommended only
for non-interactive usage, since it is easy to forget the variable is set in
a local shell — exactly the kind of hazard that direnv mitigates by
automatically unsetting it when you leave the directory.
The CLI config file (~/.terraformrc or the path in TF_CLI_CONFIG_FILE)
can contain API tokens for Terraform Cloud. Protect it accordingly.
This is a mature pattern, widely used in CI/CD.
Docs: Terraform CLI environment variables, CLI configuration file
| Variable | Sub-pattern | What it controls |
|---|---|---|
DOCKER_CONFIG |
Config directory redirection | Path to the Docker config directory (default ~/.docker) |
Docker stores registry credentials in $DOCKER_CONFIG/config.json. To
maintain separate registry credentials for different contexts:
export DOCKER_CONFIG=$HOME/.docker-work
Then docker login within that context writes to the alternate config.
This is particularly useful when you need to authenticate to the same registry (e.g. Docker Hub, a company Artifactory) with different accounts — Docker's auth model is one-credential-per-registry within a single config file, so multiple accounts on the same registry require separate config directories.
By default, Docker stores credentials in plaintext in config.json. Use a
credential helper
(docker-credential-osxkeychain, docker-credential-secretservice, etc.) to
store tokens in your OS keyring instead. When using DOCKER_CONFIG with
credential helpers, ensure the helper is configured in each alternate config.
This is a mature pattern.
Docs: Docker CLI configuration
| Variable | Sub-pattern | What it controls |
|---|---|---|
CLAUDE_CONFIG_DIR |
Config directory redirection | Path to the Claude Code config directory (default ~/.claude) |
ANTHROPIC_API_KEY |
Direct credential injection | API key used for authentication |
For maintaining separate Claude Code identities (e.g. personal subscription vs work API key):
export CLAUDE_CONFIG_DIR=$HOME/.claude-work
Or inject a specific API key directly:
export ANTHROPIC_API_KEY=sk-ant-...
Maturity caveat: CLAUDE_CONFIG_DIR is not yet fully documented in
Anthropic's official documentation, and some features do not fully respect it —
Claude Code may still create local .claude/ directories in workspace roots
regardless of this variable, and IDE integrations (e.g. the VS Code extension)
may look in the default location rather than the one specified by the
variable. The core CLI respects it for credentials and project state, but
expect rough edges. This is improving with each release.
ANTHROPIC_API_KEY is a plaintext API key. Keep it out of .envrc files that
might be committed to version control — use direnv's
dotenv or
source_env with a
gitignored file instead.
Docs: Claude Code settings, direnv wiki — Claude Code
| Variable | Sub-pattern | What it controls |
|---|---|---|
NPM_CONFIG_USERCONFIG |
Config directory redirection | Path to the user-level .npmrc file (default ~/.npmrc) |
To use a different .npmrc (containing different registry URLs, auth tokens,
or scope configurations):
export NPM_CONFIG_USERCONFIG=$HOME/.npmrc-work
Any npm configuration option can also be set directly via environment
variables using the npm_config_<option> naming convention (e.g.
npm_config_registry, npm_config_//registry.npmjs.org/:_authToken).
npm also supports per-project .npmrc files in the project root, which are
loaded automatically without direnv. For registry auth tokens that differ by
project, a per-project .npmrc may be simpler than direnv — but keep it
gitignored if it contains tokens.
.npmrc files can contain registry authentication tokens in plaintext. npm
requires these files to have mode 0600; files with broader permissions are
silently ignored.
This is a mature pattern.
Docs: npm config, npmrc
SSH does not follow either sub-pattern — there is no SSH_CONFIG_DIR
environment variable. SSH handles multiple identities natively through
~/.ssh/config using Host aliases, IdentityFile, and IdentitiesOnly
directives. If you use SSH remotes instead of HTTPS, GH_CONFIG_DIR has no
effect on push/pull authentication — key selection is handled entirely by
SSH's own config, independent of direnv. See
HTTPS vs SSH remotes above for details.
Docs: man ssh_config
Because direnv manages all environment variables for a directory tree, you can
combine any of the above in a single .envrc. A realistic work directory
might look like:
# ~/work/.envrc
export GH_CONFIG_DIR=$HOME/.config/gh-work
export AWS_PROFILE=work
export CLOUDSDK_ACTIVE_CONFIG_NAME=work
export KUBECONFIG=$HOME/.kube/config-work
export DOCKER_CONFIG=$HOME/.docker-work
export CLAUDE_CONFIG_DIR=$HOME/.claude-work
export TF_CLI_CONFIG_FILE=$HOME/.terraformrc-work
All of these activate when you cd into ~/work/ and deactivate when you
leave. Combined with git's includeIf for commit identity, this gives you a
single, declarative file that controls your entire toolchain identity.
If a subdirectory needs to override or extend any of these — for example, a
project that deploys to a different AWS account — add a .envrc in that
subdirectory with source_up_if_exists at the top to inherit the parent's
variables before overriding the ones that differ. See
Adding new .envrc files in subdirectories
above.