FIELD GUIDE · mcps.work/scripts/multiples

One machine, two Claude tenants — the as‑built playbook

How I run one always‑on Azure VM hosting two isolated Claude Code tenants — every step split into what you click and what you paste, single‑line commands with the values on top, and every mistake I actually hit noted right where it bit me.

Field‑tested buildClick vs. pasteSingle‑line commandsReal gotchas
The short version

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.

What I actually built
  • 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
PART I

Get the idea first

What problem this solves and how the pieces fit — no jargon needed.

The situation

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.

The big idea

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.

The moving parts

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.

PART II

Build it, step by step

Four steps, each bundled with its script. Do the 🖱️ browser bits, paste the ⌨️ commands, dodge the 🩹 gotchas.

How to read the steps

Icons used in this guide

Same little signposts on every step, so you can skim for exactly what you need.

💡
TipA shortcut or a better way.
⚠️
WarningGet this wrong and it bites.
📌
RememberKeep this in mind throughout.
🔧
Technical stuffThe nerdy “why” — skippable.
🩹
What bit meA real error from the live run + the fix.
📌

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.

⤓ Prefer to run them as files?Every step has a Download button in its top‑right corner. Grab the script, open it in any text editor, edit only the “Set your values” block at the top, then run it in the shell shown on that step. The download buttons are live — they serve the real, generalised scripts straight from this page.
1

Provision the VM

Create the VM, then push in swap, Node, Claude Code and the two tenant folders — all in one shot.

⤓ build-claude-vm.ps1
In this step
  • Sign az in 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%.

🖱️ INTERACTIVE — do this firsta registered / compliant device
  1. 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.
  2. Choose an Anthropic‑supported region (Southeast Asia, India, etc.). Not East Asia / Hong Kong — see the gotcha below.
⌨️ POWERSHELL — copy & pastebuild-claude-vm.ps1
1Set your values
$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"
2Sign in & select the subscription (one line each)
az login --tenant $Tenant
az account set --subscription $Subscription
3Create the VM (one line)
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
4Push the in‑VM setup — no SSH needed yet (one line)
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)
🐧 BASH — what vm-setup.sh does inside the VMruns automatically via run-command
Swap · Node · Claude Code · tenant folders (idempotent)
# 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.

2

Open one locked front door

A cheap static public IP, with SSH allowed only from your current IP. No Bastion, no VPN.

⤓ nsg-ip-update.ps1
In this step
  • 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.

🖱️ INTERACTIVE — do this in the browserportal.azure.com (optional DNS)
  1. Find your current public IP — open api.ipify.org. You'll paste it below.
  2. (Optional) add a CNAME — e.g. claude.<your-domain> → the VM's Azure DNS name — so a changing IP never changes how you connect.
⌨️ POWERSHELL — copy & pasteyour compliant device or Cloud Shell
1Set your values
$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
2Attach a static public IP to the VM (one line each)
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
3Allow SSH from your IP only (one line)
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"
4Connect (from your machine)
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.

3

Sign each tenant in, and run them

One interactive login per config directory; then one‑word wrappers, tmux, and a login picker.

⤓ tenant-wrappers.sh
In this step
  • 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
🖱️ INTERACTIVE — do this in the browserclaude.ai device login
  1. Start each tenant once and complete its device‑code login — sign into that tenant's Claude account, and pick the right organisation.
  2. This is a one‑time, human step per tenant: OAuth can't be scripted. After it, the login persists in the config directory.
🐧 BASH — run on the VMconnected via SSH
1First sign‑in, once per tenant
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
2Install the one‑word wrappers (from tenant-wrappers.sh)
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
3From then on, one word per tenant
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.

4

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.

⤓ vscode-settings.json
In this step
  • 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
🖱️ INTERACTIVE — do this in the browser / editorVS Code · ntfy app
  1. Add a host alias in ~/.ssh/config and Connect to Host → claude in VS Code Remote‑SSH.
  2. Create two VS Code Profiles (“Claude Work”, “Claude Volunteer”) with distinct colours — for window separation only.
  3. Subscribe to your ntfy topics in the ntfy phone app (one per tenant).
🐧 CONFIG — on the VMper-folder routing + ntfy hooks
1Auto‑route a workspace folder to a tenant — <folder>/.vscode/settings.json
{ "terminal.integrated.env.linux": { "CLAUDE_CONFIG_DIR": "/home/azureadmin/.claude-work" } }
2Phone push per tenant — add to ~/.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.

All generalisedEvery command here has had real subscription IDs, tenant IDs, public/ISP IP addresses, hostnames, service‑principal IDs and ntfy topics swapped for <PLACEHOLDERS> or gathered into the “Set your values” block. The logic, flags, SKUs, region and file paths are the real, as‑built ones.
PART III

Run it, and keep it cheap

Day‑to‑day operations, the tear‑out cheat sheet, and what it costs.

Day to day

Operating it

The handful of commands you actually reach for after it's live.

⌨️ POWERSHELL — the ops you'll useyour compliant device
Your IP changed → SSH times out → re‑point the rule (one line)
az network nsg rule update -g $RG --nsg-name $NSG -n allow-ssh-tmp --source-address-prefixes "<NEW.IP>/32"
Power & billing
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
OOM under load → resize to 8 GiB, then start
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
Tidy up: revoke the build‑time service principal (least privilege)
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.

The part of tens

Cheat sheet — the errors that cost me the most time

Every gotcha above, in one scannable place. Tape it above your desk.

🩹 If you see this…  →  do this
Claude Code can't connect · 403 from api.anthropic.comUnsupported region — rebuild in Southeast Asia / India, never East Asia (HK/China).
az create/write fails on your workstationConditional Access — run from a registered device or Cloud Shell after an interactive MFA az login.
builds/npm get killed · Remote‑SSH won't attachToo little RAM — the 4 GiB swap is mandatory; resize to 8 GiB (B2as_v2).
can't SSH in at all after buildNo public IP and no Bastion/VPN strands the box — add the single‑IP‑locked public IP (Step 2).
SSH keeps prompting for a passphrasePowerShell -N '""' set a literal "" — regenerate with bash ssh-keygen -t ed25519 -N "".
SSH suddenly times outYour dynamic ISP IP changed — update rule allow-ssh-tmp to the new /32.
tmux sessions goneThey don't survive a reboot — recreate them (auth persists, so no re‑login).
VS Code opens the wrong Claude accountExtension won't route accounts — run claude-work/claude-vol in the integrated terminal, or use per‑folder settings.json.
phone alerts feel exposedPublic ntfy topics are readable by anyone with the name — self‑host ntfy for privacy.
web app shows no live sessionclaude.ai is history only — drive live sessions over SSH + tmux (or the mobile app).
What it runs you

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.

ComponentAs‑built spec≈ USD / month
Virtual machine (Linux, 24/7)Standard_B2als_v2 · 2 vCPU / 4 GiB$34.50
Managed disk64 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 totalas I run it≈ $48
Skipping Bastion is the savingAn Azure Bastion would add ~$140+/mo — more than the whole rest of the bill. The single‑IP‑locked public IP delivers the same “just me, over SSH” access for a few dollars. I only pay for the VM, its disk, and that tiny IP.
Capped subscription — watch the meterThis runs on an Azure sponsorship subscription with a hard credit cap, so a 24/7 VM is a real line item — I keep an eye on the balance and az vm deallocate when I genuinely don't need it. A Reserved‑Instance / Savings Plan cuts compute ~30–40% if you commit.
Worth adding

Third‑party apps & recommendations

Phone alerts

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.

Remote terminal

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.

Editor over SSH

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.

On the go

Claude mobile app

Monitor a running session from your phone when you're away from the desk — pairs naturally with the ntfy pings.

How this site is built & deployedThe page you're reading is served by a Starlette MCP server on Azure Container Apps, built and shipped by a GitLab CI pipeline (test → build image → push to the GitLab Container Registry → az containerapp deploy, behind a manual approval gate). That's separate from the VM above — the host and the site have independent bills.
Peace of mind

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.

Switch tenants in one wordNo accidental cross‑postingOne tidy machinePhone push per tenant≈ $0 access layerSafe by default