Back to articles

Software Development

Two GitHub Accounts, Zero Context Switching

How I run separate GitHub accounts for private development and public releases. Repos under one directory automatically commit and authenticate as the public identity — using includeIf, an SSH host alias, and insteadOf. Wrong operations fail loudly instead of succeeding quietly.

Two GitHub Accounts, Zero Context Switching: Directory-Based Identity for Private and Public Repos

I keep my private development work and my public releases on separate GitHub accounts. The setup below makes the switch fully automatic: any repo under ~/dev/git_public/ commits and authenticates as my public identity, everything else stays untouched. There is no switch command, no "which account am I on right now," and pushing to the wrong place fails loudly instead of succeeding quietly.

Names below are fictional:

RoleAccountEmailAuth
Private developmentalex-devdev@example.comHTTPS + macOS keychain (existing setup, unchanged)
Public releasesalexcarteralex@example.comSSH (new)

Written for macOS; everything except the keychain bits applies to Linux as-is.

Background

Until now I ran everything on a single development account. Auth is HTTPS: the first push opens a browser to log in to GitHub, and after that the token stored in the macOS keychain handles it silently. Every push target was my own private repo, so this never needed improving.

Now I've added a second, public-facing account, and anything meant for publication lives there. The reason for splitting at the account level is manageability: a public repo sitting in a sea of private ones stands out immediately, and vice versa. Unless, of course, you misfire.

So the goal became: repos under a designated directory should push as the public account, automatically. What follows is the setup I landed on.

TL;DR

Generate a key for the public account and register the .pub with GitHub:

ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519_public -C "alexcarter"
cat ~/.ssh/id_ed25519_public.pub   # → Settings → SSH and GPG keys on the public account

~/.ssh/config:

Host public-github
    HostName github.com
    IdentityFile ~/.ssh/id_ed25519_public
    IdentitiesOnly yes

~/.gitconfig — your existing config plus two lines at the end:

[credential]
    helper = osxkeychain
[user]
    name = alex-dev
    email = dev@example.com
 
[includeIf "gitdir:~/dev/git_public/"]
    path = ~/.gitconfig-public

~/.gitconfig-public (new file):

[user]
    name = alexcarter
    email = alex@example.com
 
[url "git@public-github:"]
    insteadOf = git@github.com:
    insteadOf = https://github.com/

Verify:

ssh -T git@public-github                      # → "Hi alexcarter!"
cd ~/dev/git_public/some-repo
git config user.email                         # → alex@example.com

That's the whole thing. The rest of this post explains why it's built this way and what each piece buys you.

The problem

Running two accounts naively invites two kinds of accidents:

  1. Identity leaks. You push to a public repo and the commits carry your private account's name and email. This one is silent — nothing errors, and by the time you notice, it's in the published history.
  2. Auth confusion. You push with the wrong account's credentials, or you simply can't tell which credentials a given git push is about to use.

The first accident is the dangerous one, precisely because it succeeds.

Design principle: no global state

The obvious tool for multiple accounts is gh auth switch, which flips a global "active account" flag. It works, but it has the classic problem of all stateful switches: you will forget to flip it. Not often. Just often enough.

So this setup keeps no state at all. Everything is keyed to location — the directory a repo lives in, and the URL a remote points to:

  • Repos under ~/dev/git_public/ → public identity and public credentials, automatically
  • Everything else → the existing private setup, untouched

There is no "current account" to check, because the question doesn't exist.

Two config files, two different jobs

Easy to conflate, and you need both:

  • ~/.ssh/config decides which key authenticates the connection
  • ~/.gitconfig decides which name and email get baked into commits

A perfect SSH setup does nothing for commit identity, and vice versa. Each accident from "The problem" above lives on its own side of this split.

Piece 1: an SSH host alias

Host public-github
    HostName github.com
    IdentityFile ~/.ssh/id_ed25519_public
    IdentitiesOnly yes

public-github isn't a real hostname — it's an alias that only SSH understands. Any git operation against git@public-github:... actually talks to github.com, but with the public account's key. The credential choice is baked into the URL itself, which means git remote -v always tells you the truth about which identity a repo uses.

One nice property of my particular split: the private account keeps using HTTPS + keychain, so there's no Host github.com entry to write at all. HTTPS means private, SSH means public — the two identities don't even share a transport.

(If you need the public account from a second machine, don't copy the private key around. Generate a fresh key on each machine and register each .pub with the same account. GitHub happily holds multiple keys, and decommissioning a machine becomes "delete its key.")

About the passphrase prompt during ssh-keygen — this is a judgment call:

  • Skip it (empty Enter): anyone who can read the key file can use the key. But this key only unlocks the public account, so the blast radius of a leak is small. Fine if you value convenience.

  • Set one: macOS makes it nearly free. After generating the key with a passphrase, run:

    ssh-add --apple-use-keychain ~/.ssh/id_ed25519_public

    and add two lines to the Host public-github block in ~/.ssh/config:

        UseKeychain yes
        AddKeysToAgent yes
    

    You'll type the passphrase roughly once per machine; the keychain handles the rest.

Piece 2: conditional gitconfig with includeIf

[includeIf "gitdir:~/dev/git_public/"]
    path = ~/.gitconfig-public

includeIf "gitdir:..." pulls in an extra config file for any repo under that directory. The trailing slash matters — it's what makes the match recursive.

The included file sets the public name and email, so every repo under ~/dev/git_public/ commits as alexcarter from the moment it's initialized. No per-repo git config user.email ritual, and therefore no way to forget the ritual.

(This works at clone time too, on Git 2.36+: the config is resolved against the clone destination, so even the initial clone runs with the public settings.)

Piece 3: URL rewriting with insteadOf

[url "git@public-github:"]
    insteadOf = git@github.com:
    insteadOf = https://github.com/

This rewrites remote URLs at connection time. Inside ~/dev/git_public/, you can paste a bog-standard URL straight from GitHub's clone button:

git clone https://github.com/alexcarter/my-tool.git

and the actual connection goes over git@public-github: — the public key — anyway. You never have to remember the alias exists.

Why wrong operations fail instead of succeeding

This is the part I like most. Inside ~/dev/git_public/, all GitHub traffic authenticates as the public account. The public account has no access to the private account's repos. So if you absent-mindedly point a remote at a private repo and try to clone or push:

ERROR: Repository not found.

It just fails. Every time. The identity leak — the silent, dangerous accident — has been converted into a loud, harmless one. That's the right direction to move accidents in: don't rely on being careful, arrange things so that carelessness hits a wall.

Optional: a pre-push hook as a second wall

If a permissions error feels like a thin guarantee, add an explicit check. In ~/.gitconfig-public:

[core]
    hooksPath = ~/dev/git_public/.githooks

~/dev/git_public/.githooks/pre-push:

#!/bin/bash
# Refuse pushes to anything but the public account
url="$2"
case "$url" in
  git@public-github:alexcarter/*) exit 0 ;;
  *)
    echo "🚫 BLOCKED: push target is not the public account: $url"
    exit 1 ;;
esac
chmod +x ~/dev/git_public/.githooks/pre-push

Now every repo under the public directory validates its push destination against a pattern, independently of whether auth would have succeeded.

The actual workflow: releasing private code publicly

With the machinery in place, publishing something I built privately looks like this:

⚠️ Clone outside ~/dev/git_public/. If you clone a private repo from inside the public directory, insteadOf rewrites the URL and you'll be reading it with the public account's key — which has no access, so the clone fails with ERROR: Repository not found. That's the guard rail doing its job, but the error message is confusing the first time you hit it.

# 1. Clone the private repo (outside the public dir, private credentials)
cd ~/dev
git clone https://github.com/alex-dev/my-tool.git my-tool-release
cd my-tool-release
 
# 2. Drop the history (private-era commits don't come along)
rm -rf .git
 
# 3. Move into the public directory, then init
cd .. && mv my-tool-release ~/dev/git_public/my-tool
cd ~/dev/git_public/my-tool
git init          # identity is already public at this point
git add .
git commit -m "Initial commit"
 
# 4. Create an empty repo on the public account (no README, fully empty)
 
# 5. Add the remote and push — plain URL is fine, insteadOf handles it
git remote add origin https://github.com/alexcarter/my-tool.git
git branch -M main
git push -u origin main

Two things to check before that push:

Deleting .git removes the history, not the secrets in your files. .env files, cloud credentials, hardcoded API keys — grep for them:

grep -rn -iE "(api[_-]?key|secret|password|token|AKIA)" . --exclude-dir=node_modules

If you did commit with the wrong identity, it's one command to fix — as long as you haven't pushed:

git commit --amend --reset-author --no-edit

After a push it becomes a history rewrite, which is why git log -1 --format='%an %ae' before pushing is a habit worth having.

Footnotes and sharp edges

  • insteadOf applies to everything. Inside the public directory, even cloning someone else's public repo over HTTPS goes through the public account's SSH. Public repos are readable by anyone, so nothing breaks — but be aware that all GitHub traffic from that directory carries the public identity. Which is, of course, the point.

Summary

LayerMechanismAccident it kills
SSH host aliasCredentials baked into the URLNot knowing which key a repo uses
includeIfDirectory-scoped identityCommitting with the wrong name/email
insteadOfURL rewrite at connection timeHaving to hand-write special URLs
Asymmetric permissionsPublic key can't reach private reposWrong-repo operations (they fail loudly)
pre-push hookPattern check on push targetsPushing anywhere unexpected

Day to day, the entire operating manual is one sentence: things that will be published live in ~/dev/git_public/. No switch commands, no state to remember.