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:
| Role | Account | Auth | |
|---|---|---|---|
| Private development | alex-dev | dev@example.com | HTTPS + macOS keychain (existing setup, unchanged) |
| Public releases | alexcarter | alex@example.com | SSH (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.comThat'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:
- 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.
- Auth confusion. You push with the wrong account's credentials, or you simply can't tell which credentials a given
git pushis 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/configdecides which key authenticates the connection~/.gitconfigdecides 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_publicand add two lines to the
Host public-githubblock in~/.ssh/config:UseKeychain yes AddKeysToAgent yesYou'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-publicincludeIf "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.gitand 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 ;;
esacchmod +x ~/dev/git_public/.githooks/pre-pushNow 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,insteadOfrewrites the URL and you'll be reading it with the public account's key — which has no access, so the clone fails withERROR: 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 mainTwo 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_modulesIf 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-editAfter 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
insteadOfapplies 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
| Layer | Mechanism | Accident it kills |
|---|---|---|
| SSH host alias | Credentials baked into the URL | Not knowing which key a repo uses |
includeIf | Directory-scoped identity | Committing with the wrong name/email |
insteadOf | URL rewrite at connection time | Having to hand-write special URLs |
| Asymmetric permissions | Public key can't reach private repos | Wrong-repo operations (they fail loudly) |
| pre-push hook | Pattern check on push targets | Pushing 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.