How to Use Work & Personal GitHub Accounts on Ubuntu (SSH + gh CLI)

A complete, ordered guide to running multiple GitHub accounts on a fresh Ubuntu machine — SSH keys, per-directory git identity, and automatic gh CLI account switching.

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
The SSH keys page is at github.com/settings/keys — make sure you're logged into the correct account before adding each key.

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 work
  • git@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...
The first connection asks you to trust GitHub's host key — type 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
The trailing slash in 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.name for the current repo, which your includeIf rule sets.
  • gh auth token -u <name> prints that account's token from the keyring. If no login matches, it fails and gh falls back to the active account.
  • The token goes into GH_TOKEN for this one gh process only. gh reads GH_TOKEN before the active account, and nothing else on the machine sees it.
  • gh auth login, switch and status are skipped, because they refuse to run while GH_TOKEN is set. gh auth git-credential still 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.
An earlier version of this wrapper ran 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

Wrapping up

The whole setup comes down to letting the directory decide who you are:

  • ~/.ssh/config maps github-work/github-personal aliases to separate keys.
  • includeIf on ~/Projects/github/<owner>/ swaps your commit identity based on who owns the repo.
  • A small gh wrapper hands each gh call the token of that same identity, and git's credential helper goes through it too.

Set it up once on each new machine.