What This Is, in One Breath
Most people who read this help run more than one thing: a day job, something they volunteer for, and something they are building themselves. Each comes with its own Claude account, its own cloud subscription, its own repos. This is how to run all of them at once on one small always-on VM, each a separate working life sealed in its own lane.
😖 Before
One login juggled across three roles, or a laptop each. Easy to commit as the wrong identity, deploy to the wrong subscription, or leave a session running that quietly changes what another session sees.
🎯 After
One VM. One sealed lane per identity: its own Claude account and its own cloud account. One word to switch. Several sessions running at once without any of them treading on the others.
- The guide now covers two axes of identity (Claude account and cloud account), not just the first.
- Three lanes instead of two, and the pattern generalises to as many as you hold.
- Parallel is the point: earlier editions recommended one session at a time. Isolation done properly removes that limit.
- Every default profile is now treated as a hazard and explicitly retired.
- Phone access is resolved rather than caveated: a pre-connect rule refresh, built and verified, in Step 5.
One Person, Several Parallel Lives
The trap is not that you have several accounts. It is that the tools default to whichever one you touched last, and they do it silently.
An assistant that can read your mail, deploy infrastructure and push code is only safe if it is unambiguous about who it is being at the moment it acts. Get that wrong and the failure is not an error message: it is a resource created in the wrong company's subscription, under the wrong billing account, by the wrong identity.
Two Axes of Identity, Not One
Nearly every guide on this subject stops at the first axis. The second is where the expensive mistakes live.
Who You Are to Claude
Which account is signed in, whose usage is billed, which organisation's settings and history apply. Controlled by one environment variable pointing at a config directory.
CLAUDE_CONFIG_DIRWho You Are to the Cloud
Which subscription gets deployed to, which tenant is authenticated, whose money is spent. Same shape of mechanism, entirely separate variable, and the one people forget.
AZURE_CONFIG_DIRTechnical Stuff: Why a Directory and Not a Flag
Both tools keep their whole authenticated state (tokens, account list, the currently selected default) inside a single directory. Point the variable at a different directory and you get a completely independent identity: separate login, separate default, separate everything. It is the same trick browser profiles use, and it is why the isolation is real rather than cosmetic.
The important consequence: a command like az account set is not global: it is global to whichever profile is active. Miss the variable and you have just changed the default for every other lane that shares the fallback.
Think of It Like Browser Profiles
Your browser keeps separate profiles (work, home) each with its own logins and history, none seeing the others. This does the same for Claude Code and for your cloud CLI, in matched pairs.
Remember: a Claude Account and a Cloud Account Are Not the Same Count
Two Claude accounts can serve three lanes; three cloud subscriptions can sit behind a single login. The lanes are what you name, not the accounts. On the host this guide is built from, three lanes run on two Claude accounts and three cloud profiles, and that is fine, provided every lane names both of its directories explicitly.
Six Things, in Plain Terms
The Machine
One always-on Linux VM (small and cheap) so your laptop or phone just connects to it. It must sit in an Anthropic-supported region (that lesson cost a rebuild).
A Config Directory per Identity
Two per lane: one for Claude, one for the cloud CLI. Each stores that account's login apart from every other.
A Folder That Declares Its Lane
Each project folder states which cloud profile it belongs to, so the binding travels with the code instead of living in your memory.
Retired Defaults
Every fallback profile is emptied on purpose, so anything unrouted fails loudly instead of silently succeeding as the wrong identity.
A Locked Front Door
Key-only SSH from a known address, or an overlay network if you want it from a phone. No open ports, no passwords.
An Audit You Can Re-Run
One command that prints every lane and what it currently resolves to. If you cannot check it in ten seconds, you will stop checking it.
Icons Used in This Guide
Same little signposts on every step, so you can skim for exactly what you need.
Remember: Know Which Shell You Are In
Two kinds of shell matter here, and they behave differently. An interactive shell reads your startup files, so wrappers and PATH entries exist. A non-interactive one (anything scripted, a cron job, ssh host "…") does not. Half the traps in Part III are this distinction wearing a different hat.
Provision the Machine
A small always-on VM, in a supported region, with swap and a current Claude Code install.
Warning: Region First, Everything Else Second
Claude Code refuses to run from unsupported regions. Choosing the region by latency or price alone and discovering this afterwards means rebuilding the box. Confirm the region is supported before you create anything.
# 2 GiB swapfile, persisted across reboots
sudo fallocate -l 2G /swapfile && sudo chmod 600 /swapfile
sudo mkswap /swapfile && sudo swapon /swapfile
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab
# the native install keeps itself current and lands in ~/.local/bin curl -fsSL https://claude.ai/install.sh | bash # if you previously installed via npm, remove it once the native one works npm uninstall -g @anthropic-ai/claude-code command -v claude && claude --version # confirm ONE current binary
What Bit Me: Two Installs, and PATH Picks the Winner
A host carrying both the npm and the native install resolves claude differently depending on how the shell started. Interactive shells find ~/.local/bin first and get the current native build; non-interactive ones do not have that directory on PATH at all and fall through to the older npm binary at /usr/bin/claude. Combined with the identity traps below, a scripted run was getting both the wrong account and an out-of-date binary.
Give Each Lane Its Claude Identity
One config directory per Claude account, one wrapper command each.
mkdir -p ~/.local/bin
for lane in work vol ent; do
printf '#!/usr/bin/env bash\nexec env CLAUDE_CONFIG_DIR="$HOME/.claude-%s" claude "$@"\n' \
"$lane" > ~/.local/bin/claude-$lane
chmod +x ~/.local/bin/claude-$lane
done
claude-work # sign in as the work account, then exit claude-vol # sign in as the volunteer account, then exit claude-ent # sign in as the venture account, then exit
Technical Stuff: Why exec env And $HOME
Two small choices, both deliberate. exec replaces the shell rather than leaving a parent process idle for the whole session, worth having on a small box. And "$HOME" survives being quoted into a remote command string, where a bare ~ would not expand. Earlier editions of this guide published the tilde form; it works, but this is the one actually running.
Give Each Lane Its Cloud Identity
The axis most guides skip. Bind the folder to the cloud profile, declaratively.
A wrapper handles Claude because you always start a session by typing a command. The cloud CLI is different: it gets invoked from inside a session, by you or by the assistant, long after the wrapper ran. So the binding has to live with the folder rather than with the command.
for lane in work vol ent; do mkdir -p ~/.azure-$lane && chmod 700 ~/.azure-$lane done
# ~/venture/.claude/settings.local.json { "env": { "AZURE_CONFIG_DIR": "/home/<you>/.azure-ent" } }
# start the session from the lane's folder, then check before you log in echo "profile: $AZURE_CONFIG_DIR" # MUST print ~/.azure-ent az login --use-device-code az account set --subscription <subscription-id>
What Bit Me: the Login That Landed in the Wrong Profile
The device-code login was run from a plain terminal rather than from inside the lane, so AZURE_CONFIG_DIR was unset. The credentials went into the default profile, which quietly merged a second organisation's subscriptions into it and changed the default subscription for every other lane that falls back there. Two sessions were running at the time; both silently re-pointed at the wrong company.
Nothing errored. The only symptom was a subscription list that had grown. The fix is the one-line check above: print the profile path before authenticating, every time.
Tip: Put the Binding in settings.local.json, Not settings.json
The path is machine-specific and the file is personal, so the .local variant keeps it out of version control. Note also that project roots resolve per repository: a parent folder holding several repos will need the declaration in each one, not just at the top.
Retire the Defaults
Make the fallback fail loudly instead of succeeding as the wrong identity.
Both tools fall back to a default directory when the variable is unset: ~/.claude and ~/.azure. If you ever signed in before setting the lanes up, those logins are still there and still valid. That is an unlabelled extra lane that anything unrouted lands in, and it is what makes the failures silent.
Warning: Order of Operations Matters
Wire every lane explicitly before you empty the default. Retire it first and any folder still relying on the fallback loses its access immediately. Check for folders you forgot: a single grep across your project settings is enough.
grep -rn AZURE_CONFIG_DIR ~/*/.claude/settings*.json ~/*/*/.claude/settings*.json
AZURE_CONFIG_DIR=~/.azure az account clear
AZURE_CONFIG_DIR=~/.azure az account show # now: "Please run 'az login'" ✔
# check for live sessions FIRST: this directory may be in use
pgrep -af claude
mv ~/.claude ~/.claude.retired
Remember: the Default Directory Is a Lane Too
Until it is retired, the mental model “one folder, one account, one command” has an unlabelled extra box in it, and everything you forgot to route ends up there. A retired default converts a whole class of silent wrong-identity bugs into an obvious error message.
Reach It from a Phone
A pre-connect rule refresh, so a changing mobile address stops being a lockout.
Step 4 leaves one thing unresolved. Inbound SSH is pinned to a single address and a phone does not have one: mobile data reassigns on reconnection and usually sits behind carrier-grade NAT, so a phone and a laptop tethered to it cannot both be allowed at once. What follows was built and verified on the as-built host, not sketched.
The Ordering Is the Whole Trick
The obvious implementation is to refresh the rule once you are on the host, from a hook in the wrapper. It cannot work, and it is worth stating plainly because it is the first thing most people try: reaching the host is exactly what the rule gates, so a refresh that runs after you arrive only ever prepares the next connection. Change networks twice and you are locked out.
The update therefore has to run on the phone, before the connection, over HTTPS. Port 443 is open on every network that blocks 22.
phone: get public IP -> get ARM token -> update rule if changed -> ssh
^
the door is already open when we knock
One Rule per Device, Not One Rule Shared
With a laptop task and a phone both writing the same rule, whichever ran last wins. A laptop polling every five minutes means the phone keeps access for at most five minutes. Give each device its own slot.
allow-ssh-tmp (prio 300) <- laptop, scheduled task allow-ssh-phone (prio 100) <- phone, script below
Remember: a Dynamic Allow-List Needs One Slot per Device
Share a rule between devices and they will fight over it, silently, and the symptom looks like intermittent access rather than a configuration mistake.
The Script, as Built
Termux, with bash, curl and jq. Credentials live in a mode-600 file rather than in argv or the environment, so they stay out of ps and shell history. It reads before it writes, so an unchanged address costs one GET and no write at all.
# ~/.config/nsg-allow.conf (chmod 600) TENANT_ID="<TENANT_OF_THE_SUBSCRIPTION>" CLIENT_ID="<APP_ID>" CLIENT_SECRET="<SECRET>" SUBSCRIPTION="<SUBSCRIPTION_ID>" RESOURCE_GROUP="<RESOURCE_GROUP>" NSG="<NSG_NAME>" RULE="allow-ssh-phone"
. ~/.config/nsg-allow.conf ip=$(curl -fsS -m 8 https://api.ipify.org) token=$(curl -fsS -m 8 \ -d grant_type=client_credentials \ -d "client_id=$CLIENT_ID" \ --data-urlencode "client_secret=$CLIENT_SECRET" \ --data-urlencode "scope=https://management.azure.com/.default" \ "https://login.microsoftonline.com/$TENANT_ID/oauth2/v2.0/token" \ | jq -r .access_token) url="https://management.azure.com/subscriptions/$SUBSCRIPTION/resourceGroups/$RESOURCE_GROUP\ /providers/Microsoft.Network/networkSecurityGroups/$NSG/securityRules/$RULE?api-version=2023-09-01" rule=$(curl -fsS -m 8 -H "Authorization: Bearer $token" "$url") [ "$(jq -r .properties.sourceAddressPrefix <<<"$rule")" = "$ip/32" ] && exit 0 # Modify the fetched rule rather than hand-building one, so priority, ports # and direction survive. The two source fields are mutually exclusive in ARM. body=$(jq --arg src "$ip/32" \ '{properties: (.properties | .sourceAddressPrefix = $src | del(.sourceAddressPrefixes))}' \ <<<"$rule") curl -fsS -m 8 -X PUT -H "Authorization: Bearer $token" \ -H 'Content-Type: application/json' -d "$body" "$url" >/dev/null
Tip: Wire It in So It Can Never Block the Hop
Call it immediately before the ssh, on a timeout, and carry on regardless. A stale rule still beats refusing to try.
timeout 25 "$HOME/bin/nsg-allow" \ || printf 'NSG refresh failed - trying anyway\n' >&2 ssh -t -i "$KEY" "$HOST" "cd \"$dir\" && claude --resume"
Technical Stuff: the Identity It Needs
A service principal scoped to the single NSG, so the credential cannot touch the VM, its disk, or any other network resource.
az login --tenant <TENANT_OF_THE_SUBSCRIPTION> # see 5.4, this line matters
az ad sp create-for-rbac --name "nsg-phone-updater" --years 1 \
--role "Network Contributor" \
--scopes "/subscriptions/<SUB>/resourceGroups/<RG>\
/providers/Microsoft.Network/networkSecurityGroups/<NSG>"
What Bit Me Building This
Three of these cost real time, and all three produce misleading errors: the message names one problem while the cause is another.
What Bit Me: the Service Principal Landed in the Wrong Directory
If your az session is signed in to a different tenant than the one that owns the subscription, az ad sp create-for-rbac cheerfully creates the app in the signed-in directory, reports the subscription's tenant in its output, and the role assignment appears to succeed. The token then fails with:
AADSTS700016: Application with identifier '...' was not found in the directory
RBAC only authorises principals from the subscription's own tenant, so that assignment could never have worked. Sign in to the owning tenant first, and confirm which one that is:
az rest --method get \ --url "https://management.azure.com/subscriptions/<SUB>?api-version=2022-12-01" \ --query tenantId
What Bit Me: a Hand-Typed Secret Lost a Character
Transcribing the secret produced a 39-character value where it should have been 40, which surfaces as an error that sounds like the wrong secret rather than a truncated one:
AADSTS7000215: Invalid client secret provided
Never retype it. Pipe it straight to the device, so it is never displayed, never typed, and never enters shell history.
az ad sp credential reset --id <APP_ID> --query password -o tsv \ | ssh phone "umask 077; cat > ~/.config/.secret"
What Bit Me: Termux Is Not a Normal Linux
/tmp is not writable, so use $TMPDIR or ~/.cache. The base install ships without jq, python3 and openssl; pkg install jq is the one dependency this script adds.
What It Costs in Trust
This guide is honest about money and should be equally honest here. This puts a credential on a phone that can widen the SSH allow-list. Scoping it to one NSG means a stolen secret adds an address and nothing more, and the attacker still needs the SSH key: one layer, not the whole door. That is a reasonable trade for a personal jump host and an unreasonable one for shared infrastructure.
Prove It: the Ten-Second Audit
One command that prints every lane and what it actually resolves to.
Isolation you cannot verify is isolation you will stop trusting. Keep this to hand and run it after any login, any new lane, and any time something feels off.
#!/usr/bin/env bash
# Print what each lane currently resolves to, on both axes.
for lane in work vol ent; do
printf '\n=== %s ===\n' "$lane"
cdir="$HOME/.claude-$lane"
printf ' claude : %s\n' \
"$(jq -r '.oauthAccount.emailAddress // "(not signed in)"' \
"$cdir/.claude.json" 2>/dev/null || echo '(no config)')"
adir="$HOME/.azure-$lane"
printf ' cloud : %s\n' \
"$(AZURE_CONFIG_DIR="$adir" az account show \
--query '[name,user.name]' -o tsv 2>/dev/null | tr '\t' ' ' \
|| echo '(not signed in)')"
done
printf '\n=== defaults (should BOTH be empty) ===\n'
AZURE_CONFIG_DIR="$HOME/.azure" az account show -o tsv 2>&1 | head -1
[ -d "$HOME/.claude" ] && echo ' ~/.claude still present, not yet retired'
Why Isolation Is What Buys You Parallelism
Earlier editions of this guide recommended running one session at a time, on the grounds that it keeps things unambiguous. That advice was a workaround for missing isolation, not a virtue.
Once every lane pins both axes, sessions stop competing. A long build in one lane and an interactive session in another do not see each other's credentials, defaults, or working directory. The rule that replaces “one at a time” is narrower and more useful: never change global state while another session is running. With the defaults retired, there is barely any global state left to change.
Tmux, One Session per Lane
Detachable sessions that survive disconnects. Name them after the lane (work, vol, ent) so reattaching is unambiguous from any device.
A Launcher Menu
A short script that lists the lanes and opens the right pairing of folder, Claude account and tmux session. Typing the bare command is how you end up in the default.
A Status Line per Lane
Show the active lane, folder, branch and cloud subscription in the prompt. With three lanes open, the cheapest safeguard is simply being able to see which one you are looking at.
Push Alerts, Outbound Only
A push to your phone when a session needs input or finishes. Outbound, so it works regardless of how the inbound firewall is configured.
What Bit Me: Configuration Written to the Wrong Profile
A status line was configured, verified, and had no effect whatsoever. The reason was the same shape as every other trap here: it had been written into the default Claude directory, while the running sessions used their per-lane ones. The file was correct, the command was correct, and it applied to a profile nobody was using.
Once you run lanes, every configuration write needs to name its profile, the same discipline as every login. If a setting appears to do nothing, check which directory it landed in before you debug the setting itself.
What Bit Me: Scripted SSH Skips the Wrapper Entirely
The wrappers and PATH entries live in interactive-shell territory. A non-interactive command never reads them, so CLAUDE_CONFIG_DIR is unset, ~/.local/bin is not on PATH, and both the account and the binary fall back to the defaults. A phone shortcut of the shape ssh host "cd ~/volunteer && claude --resume" lands in a volunteer folder running as the work account, silently, if the default is still populated.
# wrong: wrapper not found, falls back to the default identity ssh host 'cd ~/volunteer && claude --resume' # right: call the wrapper by absolute path ssh host 'cd ~/volunteer && $HOME/.local/bin/claude-vol --resume'
Retiring the defaults (Step 4) turns this from a silent wrong-identity run into an immediate, obvious failure, which is the entire point of doing it.
Warning: Reaching It from a Phone Contradicts a Single-IP Firewall
Locking inbound SSH to one source address is cheap and effective from a fixed desk. From a phone it is not: mobile data hands out an address that changes on reconnection and usually sits behind carrier-grade NAT, so a laptop tethered to that phone and the phone itself cannot both be allowed at once.
There are three honest answers. Refresh the rule from the device before connecting, which is built out in full as Step 5 and is the as-built path here. An overlay network (Tailscale, WireGuard) gives a stable private address with no firewall edits and no credential on the phone, at the cost of an agent per machine; on locked-down corporate Wi-Fi it is the only option, since that network blocks port 22 regardless of the allow-list. Outbound only means accepting that the phone gets alerts and the mobile app, but not a shell.
Cheat Sheet: the Errors That Cost the Most Time
Nearly all of these have the same root cause: something ran without naming its profile.
| Symptom | Cause and fix |
|---|---|
| Your subscription list suddenly got longer | A login ran without AZURE_CONFIG_DIR and merged another tenant into the default profile, possibly changing the default subscription for live sessions. Re-run the audit, restore the intended default, then re-login inside the lane. |
| A deployment went to the wrong subscription | The folder never declared its profile. Add the env block to that project's settings.local.json and re-check with echo $AZURE_CONFIG_DIR. |
| Wrong Claude account when driven from a script or phone shortcut | Non-interactive SSH skips your startup files. Call the wrapper by absolute path: $HOME/.local/bin/claude-vol. |
claude-vol: command not found over SSH | Same root cause, different symptom: ~/.local/bin is only on the interactive PATH. Use the absolute path. |
| A setting you just configured does nothing | It was written to a different config directory than the one your session uses. Check which profile the file lives in before debugging the setting. |
claude --version disagrees between shells | Two installations. Remove the npm one once the native install is verified. |
AADSTS50076: multi-factor authentication required | That tenant demands MFA and the login skipped it. Sign in to it explicitly with az login --tenant <tenant-id>. Unrelated to lane isolation. |
| Another session's default changed under you | Something global was altered mid-flight. Retire the defaults (Step 4) so there is nothing global left to change. |
| Port 22 unreachable from a tethered connection | The allow-rule still names a previous address. Refresh it from the device before connecting (Step 5), or move to an overlay network. |
AADSTS700016 when the phone requests a token | The service principal was created in the signed-in directory, not the one owning the subscription, so RBAC could never authorise it. Recreate it after az login --tenant <subscription owner>. |
AADSTS7000215: invalid client secret | The secret was retyped and lost a character. Never transcribe it: pipe it to the device so it is never displayed or typed. |
| The phone connects, then loses access next time | Two devices are writing one allow-rule and overwriting each other. Give each device its own rule at its own priority. |
| The rule updates but you still cannot get in | The refresh is running after connect instead of before. It has to run on the device, pre-connect, over HTTPS. |
/tmp not writable, or jq missing, on the phone | Termux is not a normal Linux. Use $TMPDIR or ~/.cache, and pkg install jq. |
| An SSH key you generated in PowerShell keeps prompting | Generating with -N '""' sets a literal two-character passphrase. Regenerate in bash; verify with ssh-keygen -y -f <key>, which returns instantly on a truly passphrase-free key. |
How It Stays Safe and Separate
Sealed on Both Axes
Each lane sees only its own Claude login and its own cloud credentials. The isolation is real, but it is conditional on the profile being named, which is why the defaults are retired and the audit exists.
Failures Are Loud
With no populated fallback, an unrouted command errors instead of quietly succeeding as the wrong identity. Loud beats silent every time.
Parallel by Design
Sessions run side by side because nothing they touch is shared. The remaining rule is simply to avoid changing global state while others are running.
One Narrow Door
Key-only SSH on an otherwise closed box, from a known address or an overlay network. No public services, no passwords.
Least Privilege
Data residency pinned to a chosen region; any build-time service principal revoked once the box is up.
Verifiable in Ten Seconds
The audit prints every lane on both axes. A check nobody runs protects nothing, so it has to be quick enough to actually use.