What This Is, in One Breath
I help run two organisations, a Work tenant and a Volunteer tenant, each with its own Claude account and its own repos. Instead of two laptops or one risky shared login, I stood up one small always-on Azure VM that runs Claude Code for both, each sealed in its own lane, reachable from my desktop and my phone.
😖 Before
Two accounts, a shared login or a laptop each, easy to send something as the wrong identity, or save a file in the wrong place.
🎯 After
One VM, one sealed config directory per tenant, one word to switch (claude-work / claude-vol). Nothing bleeds across; a phone push when either needs me.
- One 24/7 Ubuntu 24.04 VM (Standard_B2als_v2) in Southeast Asia (Singapore), an Anthropic-supported region
- Two tenants isolated by
CLAUDE_CONFIG_DIR(~/.claude-work,~/.claude-vol), each signed in with its own interactive login - Reached over key-only SSH locked to my one IP, no Bastion, no VPN, no open ports (access layer ≈ $0)
- Phone push per tenant via ntfy; desktop via VS Code Remote-SSH; live sessions kept in
tmux - Runs at ≈ US$48/month (VM + disk + public IP) on a capped sponsorship subscription, live-priced; see the Cost section
One Person, Two Separate Worlds
Plenty of people help run more than one organisation, and each has its own Claude account and its own code and data.
A laptop each is a hassle; one shared account is a mistake waiting to happen, a message sent as the wrong identity, a file saved in the wrong place. This playbook is the middle path: one always-on machine that keeps every tenant in its own lane, so you switch between them with a single word and nothing leaks across.
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.
The machine holds a self-contained config directory per tenant. Each remembers which Claude account it belongs to, and a one-word command opens it, sealed off from the rest.
new config dir + login
Four Things, in Plain Terms
1The 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 me a rebuild).
2A Config Directory per Tenant
Each tenant gets its own folder (like a browser profile), storing that account's Claude login apart from every other.
3One Sign-in per Tenant
Each directory signs into its Claude account once, interactively. After that a one-word command (claude-work) opens it.
4A Locked Front Door
Reach the machine over key-only SSH, allowed from your one IP address, no open ports, no Bastion, no VPN. Cheapest reliable way in.
Icons Used in This Guide
Same little signposts on every step, so you can skim for exactly what you need.
Remember, Two Shells, Know Which Is Which
PowerShell runs on your Windows machine to drive Azure, but it must be a registered / compliant device, or a Cloud Shell, because Conditional Access blocks az writes elsewhere (Step 1). Bash runs on the VM once you're in. Every command below is a single line (no \ or backtick line-breaks, so paste never mangles), with the values you edit gathered into a “Set your values” block on top.
Provision the VM
Create the VM, then push in swap, Node, Claude Code and the two tenant folders, all in one shot.
- Sign
azin on a device Conditional Access trusts - Create the VM in an Anthropic-supported region
- Auto-run the in-VM setup (swap, Node, Claude Code,
~/.claude-work+~/.claude-vol)
Money, What Step 1 Costs
A Standard_B2als_v2 (2 vCPU / 4 GiB) running 24/7 is ≈ US$34.5/mo (live retail price, Southeast Asia, Linux PAYG); with a 64 GB Premium SSD (~US$10/mo) and the static public IP from Step 2 (~US$3-4/mo) that's ≈ US$48/mo. I run it on a capped sponsorship subscription, so I watch the credit balance closely. A Reserved-Instance / Savings Plan trims compute ~30-40%.
- Run the build from a device Conditional Access trusts. A fresh, interactive
az login(with MFA) refreshes the token cache; automation/unregistered machines get blocked on writes. - Choose an Anthropic-supported region (Southeast Asia, India, etc.). Not East Asia / Hong Kong, see the gotcha below.
$Subscription = "<SUBSCRIPTION_ID>" $Tenant = "<TENANT_ID>" $RG = "<RESOURCE_GROUP>" # e.g. claude $VM = "<VM_NAME>" # e.g. vm-claude-host $Location = "southeastasia" # MUST be Anthropic-supported (not eastasia) $Size = "Standard_B2als_v2" # 4 GiB; B2ats_v2 (1 GiB) is too small $Admin = "azureadmin" $PubKey = "$env:USERPROFILE\.ssh\vm-claude-host_ed25519.pub"
az login --tenant $Tenant az account set --subscription $Subscription
az vm create --resource-group $RG --name $VM --location $Location --image Ubuntu2404 --size $Size --os-disk-size-gb 64 --storage-sku Premium_LRS --public-ip-address "" --nsg-rule NONE --admin-username $Admin --ssh-key-values $PubKey
az vm run-command invoke --resource-group $RG --name $VM --command-id RunShellScript --scripts "@vm-setup.sh"⤓ vm-setup.sh (the in-VM script this pushes)
# Phase 2, 4 GiB swap so a small VM survives load fallocate -l 4G /swapfile && chmod 600 /swapfile && mkswap /swapfile && swapon /swapfile grep -q '^/swapfile' /etc/fstab || echo '/swapfile none swap sw 0 0' >> /etc/fstab sysctl vm.swappiness=60 # Phase 4, Node 20 LTS + Claude Code CLI curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && apt-get install -y nodejs git tmux npm install -g @anthropic-ai/claude-code # Phase 5, one config directory per tenant install -d -o azureadmin -g azureadmin ~azureadmin/.claude-work ~azureadmin/.claude-vol
What Bit Me, the Region 403 That Forced a Rebuild
I first built in East Asia (Hong Kong). Claude Code simply wouldn't connect, api.anthropic.com returns HTTP 403 from unsupported regions (HK/China). Fix: rebuild in Southeast Asia (Singapore). Verify from the VM before trusting it: curl -s -o /dev/null -w '%{http_code}' https://api.anthropic.com/, you want anything but 403.
What Bit Me, az Blocked by Conditional Access
My automation workstation's az couldn't write until an interactive MFA az login refreshed the shared token cache (the Azure MCP could read but not create). Fix: run the build from a registered/compliant device, or a portal Cloud Shell.
What Bit Me, 1 GiB RAM Was Too Small
The directed B2ats_v2 (1 GiB) OOM-killed builds and wouldn't hold a VS Code Remote-SSH server. Fix: the 4 GiB swap file is mandatory, and I settled on B2als_v2 (4 GiB). If it still OOMs, resize to 8 GiB (Operate).
Tip, No Public IP Yet, on Purpose
--public-ip-address "" --nsg-rule NONE creates the VM with nothing exposed. You open exactly one narrow door in Step 2, not before.
Open One Locked Front Door
A cheap static public IP, with SSH allowed only from your current IP. No Bastion, no VPN.
- Give the VM a small static public IP
- Allow inbound SSH (port 22) from your one IP only
- (Optional) point a friendly hostname at it with a CNAME
Technical Stuff, Why a Public IP and Not Bastion
A VM with no way in is stranded, you need Bastion, a VPN, or a public IP to get a shell. Bastion is ~$140+/mo; a VPN is fiddly. The cheapest reliable option is a ~$3-4/mo static public IP locked by the network firewall (NSG) to a single source address, with key-only SSH. That's the whole access layer, otherwise ≈ $0.
- Find your current public IP, open api.ipify.org. You'll paste it below.
- (Optional) add a CNAME, e.g.
claude.<your-domain>→ the VM's Azure DNS name, so a changing IP never changes how you connect.
$RG = "<RESOURCE_GROUP>" # e.g. claude $VM = "<VM_NAME>" # e.g. vm-claude-host $NSG = "<NSG_NAME>" # e.g. vm-claude-hostNSG $MyIp = "<YOUR.PUBLIC.IP>" # from api.ipify.org, no /32 $PubIp = "tmp-claude-ssh" # name for the public-IP resource
az network public-ip create --resource-group $RG --name $PubIp --sku Standard --allocation-method Static
az network nic ip-config update --resource-group $RG --nic-name "${VM}VMNic" --name ipconfig1 --public-ip-address $PubIp
az network nsg rule create --resource-group $RG --nsg-name $NSG --name allow-ssh-tmp --priority 300 --access Allow --protocol Tcp --direction Inbound --destination-port-ranges 22 --source-address-prefixes "$MyIp/32"
ssh -i $env:USERPROFILE\.ssh\vm-claude-host_ed25519 azureadmin@<VM_PUBLIC_IP_or_hostname>
What Bit Me, the Empty Passphrase That Wasn't Empty
Generating the key in PowerShell with ssh-keygen -N '""' set the literal passphrase "", not an empty one, every connection then prompted. Fix: generate cleanly in bash (ssh-keygen -t ed25519 -N "") so it's truly passphrase-free.
What Bit Me, No Public IP + No Bastion = Stranded
The original plan was “no public IP, use az ssh/Bastion.” Without a Bastion or VPN actually in place, there was no path in at all. Fix: the single-IP-locked public IP above, it's what makes the box reachable at all.
Remember, Your Home IP Will Change
Most ISPs hand out dynamic IPs. When SSH suddenly times out, your address moved, re-point the rule with the same script (Operate → IP changed). The CNAME hostname stays put; only the allow-rule needs the new address.
Sign Each Tenant in, and Run Them
One interactive login per config directory; then one-word wrappers, tmux, and a login picker.
- Sign into each tenant once (interactive OAuth, can't be automated)
- Install one-word wrappers:
claude-work,claude-vol - Keep live sessions in
tmux; optionally auto-show a picker on login
- Start each tenant once and complete its device-code login, sign into that tenant's Claude account, and pick the right organisation.
- This is a one-time, human step per tenant: OAuth can't be scripted. After it, the login persists in the config directory.
tmux new -s work export CLAUDE_CONFIG_DIR=~/.claude-work && claude # complete the login, then Ctrl-b d to detach tmux new -s vol export CLAUDE_CONFIG_DIR=~/.claude-vol && claude # complete the login, then Ctrl-b d to detach
printf '#!/usr/bin/env bash\nCLAUDE_CONFIG_DIR=~/.claude-work claude "$@"\n' > ~/.local/bin/claude-work printf '#!/usr/bin/env bash\nCLAUDE_CONFIG_DIR=~/.claude-vol claude "$@"\n' > ~/.local/bin/claude-vol chmod +x ~/.local/bin/claude-work ~/.local/bin/claude-vol
tmux new -s work ; claude-work # or: tmux attach -t work tmux new -s vol ; claude-vol # run ONE active session at a time on a small VM
What Bit Me, Sessions Vanished After a Reboot
tmux sessions do not survive a VM reboot, recreate them afterwards. The good news: the logins persist in the config directories, so you never re-authenticate unless credentials are revoked.
Warning, One Active Session at a Time
On a small VM, running both tenants at once invites the OOM-killer. Detach one (Ctrl-b then d) before attaching the other. It's also the cleanest guarantee the two never blur together.
Tip, a Picker on Login
tenant-wrappers.sh can drop a small menu into ~/.bashrc (fenced between markers) so an interactive ssh greets you with 1) Work · 2) Volunteer · s) shell and opens your choice in tmux. It's guarded to fire only on real interactive logins, never in VS Code terminals or scp.
Reach It from Your Editor and Your Phone
VS Code Remote-SSH with per-folder auto-routing, plus a phone push when a tenant needs you.
- Connect VS Code over Remote-SSH; use Profiles for window/theme separation
- Auto-route each workspace folder to the right tenant
- Wire ntfy so each tenant pushes “needs input” / “task done” to your phone
- Add a host alias in
~/.ssh/configand Connect to Host → claude in VS Code Remote-SSH. - Create two VS Code Profiles (“Claude Work”, “Claude Volunteer”) with distinct colours, for window separation only.
- Subscribe to your ntfy topics in the ntfy phone app (one per tenant).
<folder>/.vscode/settings.json{ "terminal.integrated.env.linux": { "CLAUDE_CONFIG_DIR": "/home/azureadmin/.claude-work" } }
~/.claude-vol/settings.json{ "hooks": {
"Notification": [{ "hooks": [{ "type": "command",
"command": "curl -s -d 'VOL: needs input' https://ntfy.sh/<your-vol-topic>" }] }],
"Stop": [{ "hooks": [{ "type": "command",
"command": "curl -s -d 'VOL: task done' https://ntfy.sh/<your-vol-topic>" }] }]
} }
What Bit Me, VS Code Wouldn't Honour the Account Switch
The VS Code extension didn't reliably respect CLAUDE_CONFIG_DIR over Remote-SSH, and Profiles can't route Claude accounts on their own. Fix: keep account isolation in the CLI, open the integrated terminal and run claude-work/claude-vol (or rely on the per-folder settings.json above). The terminal command is the real account, not the extension.
Warning, Public Ntfy Topics Are Readable
On public ntfy.sh, anyone who learns a topic name can read and post to it. Keep topic names long and unguessable, keep payloads trivial, and for real privacy, self-host ntfy and repoint the hook URLs. Restart a tenant's claude session after editing its settings.json to load hook changes.
<PLACEHOLDERS> or gathered into the “Set your values” block. The logic, flags, SKUs, region and file paths are the real, as-built ones.Operating It
The handful of commands you actually reach for after it's live.
az network nsg rule update -g $RG --nsg-name $NSG -n allow-ssh-tmp --source-address-prefixes "<NEW.IP>/32"
az vm deallocate -g $RG -n $VM # stop billing compute (disk still billed)
az vm start -g $RG -n $VM
az vm get-instance-view -g $RG -n $VM --query "instanceView.statuses[].code" -o tsv
az vm deallocate -g $RG -n $VM ; az vm resize -g $RG -n $VM --size Standard_B2as_v2 ; az vm start -g $RG -n $VM
az role assignment delete --assignee <BUILD_SP_OBJECT_ID> --scope /subscriptions/<SUBSCRIPTION_ID>/resourceGroups/$RG
Tip, Watch Memory the First Time Under Real Load
From an SSH session, keep watch -n2 free -h open while you run a heavy task. If swap saves it, you're fine; if the OOM-killer fires, that's your signal to resize.
Cheat Sheet, the Errors That Cost Me the Most Time
Every gotcha above, in one scannable place. Tape it above your desk.
Claude Code can't connect · 403 from api.anthropic.com | Unsupported region, rebuild in Southeast Asia / India, never East Asia (HK/China). |
az create/write fails on your workstation | Conditional Access, run from a registered device or Cloud Shell after an interactive MFA az login. |
| builds/npm get killed · Remote-SSH won't attach | Too little RAM, the 4 GiB swap is mandatory; resize to 8 GiB (B2as_v2). |
| can't SSH in at all after build | No public IP and no Bastion/VPN strands the box, add the single-IP-locked public IP (Step 2). |
| SSH keeps prompting for a passphrase | PowerShell -N '""' set a literal "", regenerate with bash ssh-keygen -t ed25519 -N "". |
| SSH suddenly times out | Your dynamic ISP IP changed, update rule allow-ssh-tmp to the new /32. |
| tmux sessions gone | They don't survive a reboot, recreate them (auth persists, so no re-login). |
| VS Code opens the wrong Claude account | Extension won't route accounts, run claude-work/claude-vol in the integrated terminal, or use per-folder settings.json. |
| phone alerts feel exposed | Public ntfy topics are readable by anyone with the name, self-host ntfy for privacy. |
| web app shows no live session | claude.ai is history only, drive live sessions over SSH + tmux (or the mobile app). |
Azure Consumption & Cost
Indicative monthly figures for the as-built host in Southeast Asia, taken from the Azure Retail Prices API (pay-as-you-go, Linux). Confirm for your region and currency on the Azure pricing calculator.
| Component | As-built spec | ≈ USD / month |
|---|---|---|
| Virtual machine (Linux, 24/7) | Standard_B2als_v2 · 2 vCPU / 4 GiB | $34.50 |
| Managed disk | 64 GB Premium SSD (P6) | $10 |
| Static public IP (Standard) | the entire access layer | $3-4 |
| SSH access (NSG allow-rule) | no Bastion, no VPN | $0 |
| Indicative total | as I run it | ≈ $48 |
az vm deallocate when I genuinely don't need it. A Reserved-Instance / Savings Plan cuts compute ~30-40% if you commit.Third-Party Apps & Recommendations
Ntfy / Pushover
A push to your phone when a session needs input or finishes, wired per tenant via Claude's Notification/Stop hooks. ntfy is free; self-host it for privacy.
Tmux
Detachable sessions that survive disconnects (though not reboots); reattach from any device over SSH. The lightest way to keep a session live on a small VM.
VS Code Remote-SSH + Profiles
Full editing on the VM from your desktop, with a coloured Profile per tenant. RAM-hungry, on a tiny VM, prefer plain SSH + tmux if it thrashes.
Claude Mobile App
Monitor a running session from your phone when you're away from the desk, pairs naturally with the ntfy pings.
az containerapp deploy, behind a manual approval gate). That's separate from the VM above, the host and the site have independent bills.How It Stays Safe and Separate
Sealed by Config Dir
Each tenant sees only its own login and data, no shared drawer for something to slip into the wrong account. Rule of the house: never paste data across tenants.
One Narrow Door
Key-only SSH, allowed from a single IP, on an otherwise closed box. No public services, no passwords.
One at a Time
One active session keeps everything unambiguous, and light on a small VM.
Least Privilege
Data residency pinned to Singapore; the build-time service principal revoked once the box is up.