How to Use Work & Personal GitHub Accounts on Ubuntu (SSH + gh CLI)
Prerequisites
You'll need these on your Ubuntu machine before starting:
- Git —
sudo apt install -y git - OpenSSH client —
sudo apt install -y openssh-client - GitHub CLI (
gh) — Install via the official guide: GitHub CLI → Installation
Step 1 — Create one SSH key per account
Generate a separate key for each account. Keeping them separate is what lets SSH pick the right identity per host.
# Work key
ssh-keygen -t ed25519 -C "work@example.com" -f ~/.ssh/id_work
# Personal key
ssh-keygen -t ed25519 -C "personal@example.com" -f ~/.ssh/id_personal
When prompted you can set a passphrase (recommended) or press Enter to skip. This creates four files:
~/.ssh/id_work+~/.ssh/id_work.pub(work)~/.ssh/id_personal+~/.ssh/id_personal.pub(personal)
Step 2 — Add the public keys to GitHub
Copy each public key and add it to the matching account at GitHub → Settings → SSH and GPG keys → New SSH key.
# Work — paste this into the work-username account
cat ~/.ssh/id_work.pub
# Personal — paste this into the personal-username account
cat ~/.ssh/id_personal.pub
Step 3 — The ~/.ssh/config (the heart of it)
This is the file that gives you the github-work and github-personal experience. Create ~/.ssh/config:
nano ~/.ssh/config
Paste:
Host github-personal
HostName github.com
IdentityFile ~/.ssh/id_personal
IdentitiesOnly yes
Host github-work
HostName github.com
IdentityFile ~/.ssh/id_work
IdentitiesOnly yes
Lock down the permissions (SSH is picky about this):
chmod 600 ~/.ssh/config
chmod 600 ~/.ssh/id_work ~/.ssh/id_personal
chmod 644 ~/.ssh/id_work.pub ~/.ssh/id_personal.pub
How it works: both hosts really connect to github.com, but each presents a different key. IdentitiesOnly yes forces SSH to use only the listed key (otherwise it may offer the wrong one and get rejected). You now address the two accounts by alias instead of github.com:
git@github-work:ORG/REPO.git→ authenticates as workgit@github-personal:USER/REPO.git→ authenticates as personal
Test both:
ssh -T git@github-work # Hi work-username! You've successfully authenticated...
ssh -T git@github-personal # Hi personal-username! You've successfully authenticated...
yes. Seeing the right username in each greeting confirms the key→account mapping is correct.Using the aliases when cloning
Always clone with the alias that matches the account that owns the repo:
# Work repo
git clone git@github-work:your-org/work-repo.git
# Personal repo
git clone git@github-personal:personal-username/personal-repo.git
For an existing repo, point its remote at the right alias:
git remote set-url origin git@github-work:your-org/work-repo.git
Step 4 — Per-directory git identity
SSH now picks the right key, but your commits still need the right name/email. Instead of setting this per repo by hand, let the directory decide using Git's includeIf.
This needs a folder layout where the path tells you who owns a repo. Group clones by their GitHub owner, the same way GitHub's URLs do:
~/Projects/github/
├── personal-username/ # your personal repos
├── your-org/ # work
└── another-client-org/ # work
~/Projects/github/<owner>/<repo> matches github.com/<owner>/<repo>, so you always know where a clone goes. Only your personal owner needs a rule; every work owner shares the default.
Set your default (work) identity in ~/.gitconfig:
[user]
name = work-username
email = work@example.com
[init]
defaultBranch = main
Now add a conditional include at the bottom of ~/.gitconfig that overrides the identity for anything under your personal owner's folder:
[includeIf "gitdir:~/Projects/github/personal-username/"]
path = ~/.gitconfig-personal
And create ~/.gitconfig-personal:
[user]
name = personal-username
email = personal@example.com
gitdir:~/Projects/github/personal-username/ matters: it means "any repo inside this directory". Every repo under it uses your personal name and email, and everything else falls back to work. Don't set user.name in a repo's own .git/config instead. A repo you forget to set falls back to work without telling you.Verify from inside each tree:
cd ~/Projects/github/your-org/work-repo && git config user.name # work-username
cd ~/Projects/github/personal-username/personal-repo && git config user.name # personal-username
The wrapper in Step 6 picks the gh account from this user.name, so it must equal your GitHub login exactly.
Step 5 — Authenticate gh for both accounts
Log in to each account once. gh stores them side by side and lets you switch between them.
gh auth login # run this once per account
Choose: GitHub.com → your preferred protocol → Login with a web browser, and complete it for your work account. Then run gh auth login again and complete it for your personal account.
Confirm both are present:
gh auth status
github.com
✓ Logged in to github.com account personal-username (keyring)
- Active account: true
✓ Logged in to github.com account work-username (keyring)
- Active account: false
You could switch by hand with gh auth switch -u <username>, but you'd have to remember every time you change projects.
Step 6 — Make gh auto-select the right account per repo
gh has no built-in "use account X in directory Y" feature (cli/cli#326). The workaround is a small wrapper script placed ahead of the real gh on your PATH. Before every gh call it reads the repo's git config user.name, looks up that account's token, and runs the real gh with it in GH_TOKEN.
Because it's a script on PATH (not a shell function), it works everywhere: interactive shells, scripts, and CI alike.
Create ~/.local/bin/gh:
mkdir -p ~/.local/bin
nano ~/.local/bin/gh
Paste:
#!/usr/bin/env bash
# gh wrapper: run gh as the account matching the current repo's
# `git config user.name`, by handing the real gh that account's token.
#
# Sits ahead of /usr/bin/gh on PATH so it applies to interactive shells,
# scripts, and CI alike. The real binary is always called by absolute path
# so this wrapper can never recurse into itself.
#
# See https://github.com/cli/cli/issues/326 (idea from @uncenter).
REAL_GH="/usr/bin/gh"
# Robustness: if the hard-coded path ever moves, fall back to the next gh on
# PATH that isn't this very script.
if [ ! -x "$REAL_GH" ]; then
self="$(readlink -f "$0" 2>/dev/null || echo "$0")"
REAL_GH=""
while IFS= read -r cand; do
[ -x "$cand" ] || continue
[ "$(readlink -f "$cand" 2>/dev/null || echo "$cand")" = "$self" ] && continue
REAL_GH="$cand"
break
done < <(type -aP gh 2>/dev/null)
if [ -z "$REAL_GH" ]; then
echo "gh-wrapper: could not locate the real gh binary" >&2
exit 127
fi
fi
# GH_TOKEN for this one process, not `gh auth switch`: the switch changes the
# active account for every gh on the machine, so parallel calls race.
# A GH_TOKEN already set wins, and `gh auth` commands other than
# git-credential refuse to run under GH_TOKEN, so they keep the keyring.
if [ -z "${GH_TOKEN:-}" ] && { [ "${1:-}" != auth ] || [ "${2:-}" = git-credential ]; }; then
want="$(git config user.name 2>/dev/null)"
if [ -n "$want" ] && token="$("$REAL_GH" auth token -u "$want" 2>/dev/null)"; then
export GH_TOKEN="$token"
fi
fi
exec "$REAL_GH" "$@"
Make it executable:
chmod +x ~/.local/bin/gh
Make sure the wrapper wins on PATH
The wrapper only works if ~/.local/bin comes before /usr/bin on your PATH. On Ubuntu, ~/.local/bin is usually added automatically, but make it explicit in ~/.bashrc:
# ~/.bashrc
export PATH="$HOME/.local/bin:$PATH"
Reload and confirm gh now resolves to the wrapper:
source ~/.bashrc
hash -r # clear any cached path to the old gh
command -v gh # -> /home/<you>/.local/bin/gh
How the wrapper works
- It reads
git config user.namefor the current repo, which yourincludeIfrule sets. gh auth token -u <name>prints that account's token from the keyring. If no login matches, it fails andghfalls back to the active account.- The token goes into
GH_TOKENfor this oneghprocess only.ghreadsGH_TOKENbefore the active account, and nothing else on the machine sees it. gh auth login,switchandstatusare skipped, because they refuse to run whileGH_TOKENis set.gh auth git-credentialstill gets the token, which Step 6b relies on.exec "$REAL_GH" "$@"replaces the process, so the exit code, stdin/stdout/stderr and signals pass straight through.- The real binary is always called by its absolute path, so the wrapper never calls itself in a loop.
gh auth switch instead. That works until two gh commands for different accounts run at once, from scripts or coding agents. The switch is one setting shared by the whole machine, so the second command flips the account under the first. The first then fails with an error such as GraphQL: Could not resolve to a Repository with the name 'owner/repo'. GH_TOKEN gives each process its own account, so the commands can't affect each other.Step 6b — Route HTTPS pushes through the wrapper
If you picked HTTPS in gh auth login, it ran gh auth setup-git, which points git's credential helper at the real binary by absolute path. Pushes then skip the wrapper and always go out as the active account. Check:
git config --global --get-all credential.https://github.com.helper
# !/usr/bin/gh auth git-credential <- skips the wrapper
Point it at the wrapper instead:
for host in github.com gist.github.com; do
git config --global --replace-all "credential.https://$host.helper" \
"!$HOME/.local/bin/gh auth git-credential" '^!'
done
Running gh auth setup-git again puts the absolute path back, so repeat this after it. If you clone over SSH with the aliases from Step 3, you can skip this step.
Step 7 — Verify the whole thing
# Work repo -> should report the work account
cd ~/Projects/github/your-org/work-repo
gh api user --jq '.login' # work-username
# Personal repo -> should report the personal account
cd ~/Projects/github/personal-username/personal-repo
gh api user --jq '.login' # personal-username
# A private work repo authenticates over HTTPS
cd ~/Projects/github/your-org/work-repo
git ls-remote origin HEAD
If each command prints what its comment says without you switching anything, you're done. SSH, commit identity, gh and HTTPS pushes all follow the directory now. gh auth status still shows the same active account as before, because the wrapper never changes it.
Troubleshooting
- Confirm the public key (
~/.ssh/id_work.pub) is added to the correct GitHub account. - Check
~/.ssh/confighasIdentitiesOnly yesso SSH doesn't offer the wrong key. - Run
ssh -vT git@github-workand look at theOffering public key:line to see which key is being tried.
command -v ghmust print~/.local/bin/gh. If it prints/usr/bin/gh, yourPATHorder is wrong or your shell cached the old path — runhash -rand open a new terminal.- Make sure the repo's
git config user.nameexactly matches aghlogin (case-sensitive).gh auth statuslists the valid logins. - A
GH_TOKENalready in your environment wins over the wrapper.echo ${GH_TOKEN:+set}printssetif one is there.
GitHub gives this answer, not "forbidden", when the account making the call can't see a private repo. So gh ran as the other account.
The wrapper picks the account from the directory gh runs in, not from the repo you name with -R owner/repo or a URL. A script or server that runs gh pr edit https://github.com/your-org/work-repo/pull/1 from its own folder in a personal repo acts as your personal account.
Run gh from inside the target repo's clone. In a script, cd there first or set the child process's working directory.
git config user.emailinside the repo tells you what's active.git config --show-origin user.nameshows which file set it. A.git/configentry means the repo overrides yourincludeIfrule.- Remember
includeIfmatches on the trailing-slash directory:gitdir:~/Projects/github/personal-username/(with the slash) covers everything inside it. - Already committed with the wrong identity? Fix the config, then
git commit --amend --reset-authorfor the latest commit.
This is exactly why we used a PATH wrapper instead of a shell function — as long as ~/.local/bin is on the PATH that the script inherits, the wrapper applies. If a cron/CI shell has a minimal PATH, add ~/.local/bin to it explicitly.
Wrapping up
The whole setup comes down to letting the directory decide who you are:
~/.ssh/configmapsgithub-work/github-personalaliases to separate keys.includeIfon~/Projects/github/<owner>/swaps your commit identity based on who owns the repo.- A small
ghwrapper hands eachghcall the token of that same identity, and git's credential helper goes through it too.
Set it up once on each new machine.