Skip to content
bartovsky_
Theme
martin@bartovsky.com

Notes / · 16 min

Claude Code and Codex on a locked-down Azure VM: set-up and lessons learned

A private server with no public inbound port, agents without admin rights, a Telegram gateway and a permission model that looked tighter than it was. The build, the commands and the mistakes worth avoiding.

Martin BartovskyAIHow-toshare on LinkedIn

This is the technical companion to I run AI agents on my own server - and client data never goes near them. Built over three days in July 2026; versions and prices are from then. Names, IPs and IDs are replaced with placeholders.

Architecture in six layers

Layer What Technology
1. Infrastructure A small Linux VM, outbound traffic through a dedicated gateway Azure VM (Standard_B2s, 2 vCPU / 4 GiB, Ubuntu 24.04), Azure NAT Gateway
2. Access Private encrypted network between my devices; no public port Tailscale (WireGuard), Tailscale SSH
3. Isolation Agents run under an account without sudo Linux user agents
4. Agent runtime The AI tools themselves Claude Code, Codex CLI, uv, tmux
5. Interface How I talk to it, and how it survives reboots Telegram bots, python-telegram-bot, systemd
6. Orchestration Draft by one agent, critique by the other A small Python gateway in “router” mode

The design principle: every layer assumes the one above it can fail. The permission model can leak - the OS account still has no admin rights. Tailscale can break - there are two more ways in.

Step 1: A VM with zero inbound

From Azure Cloud Shell (Bash):

az vm create \
  --resource-group <rg> \
  --name agentlab \
  --image Canonical:ubuntu-24_04-lts:server:latest \
  --size Standard_B2s \
  --location westeurope \
  --admin-username <admin> \
  --generate-ssh-keys \
  --public-ip-sku Standard \
  --storage-sku StandardSSD_LRS \
  --os-disk-size-gb 64 \
  --nsg-rule NONE \
  --output table

--nsg-rule NONE is the key: the network security group is created without a single inbound allow rule. Azure’s default DenyAllInBound does the rest. Don’t add any inbound rule later - access goes through Tailscale, which only needs outbound traffic.

az network nsg rule list -g <rg> --nsg-name agentlabNSG -o table
# must not contain any inbound rule from Internet/Any

The public IP created here is temporary - it’s removed in step 4.

Step 2: Two accounts, one without sudo

Account sudo Password Role
admin passwordless locked (break-glass only, see recovery) management
agents none locked runs the agents
sudo adduser --disabled-password --gecos "" agents
# deliberately NOT added to the sudo group

sudo -u agents -i          # work as agents

This account is the load-bearing wall of the whole design. Everything below it - permission lists, hooks, prompts - is softer.

Step 3: Tailscale without ever opening port 22

Generate an auth key in the Tailscale admin console, then install from Cloud Shell through the Azure VM agent - no SSH needed:

az vm run-command invoke -g <rg> -n agentlab \
  --command-id RunShellScript \
  --scripts "curl -fsSL https://tailscale.com/install.sh | sh && \
             tailscale up --auth-key=<tskey> --ssh"

--ssh turns on Tailscale SSH: log-in by tailnet identity, no password, no open port. From then on: ssh <admin>@agentlab.

Two things I’d set straight away:

  • Disable key expiry for the server in the Tailscale admin console. Otherwise it eventually drops out of the tailnet - your primary access path quietly expiring.
  • Mind which account owns the tailnet. Mine sits under a company e-mail domain, which makes it an organisation account and started an enterprise trial. For a lab it doesn’t matter, but know which bag it’s in before the trial ends.

Step 4: Explicit egress, then remove the public IP

Azure is retiring “default outbound access” for new subnets. I replaced it with a NAT Gateway and removed the public IP from the VM, so there is literally nothing to connect to.

Order matters. Attach the NAT Gateway to the subnet before switching default outbound off - otherwise outbound traffic drops for a moment and Tailscale goes with it.

RG=<rg>; LOC=westeurope; VNET=agentlabVNET; SUBNET=agentlabSubnet

# 1 - dissociate the public IP from the NIC (Portal: Networking → IP configuration),
#     then delete the orphaned public IP resource so you don't pay for it

# 2 - public IP for the NAT gateway
az network public-ip create -g $RG -n agentlab-nat-pip \
  --sku Standard --allocation-method Static --location $LOC

# 3 - NAT gateway (10 min idle timeout for long-lived Tailscale connections)
az network nat gateway create -g $RG -n agentlab-natgw \
  --public-ip-addresses agentlab-nat-pip --idle-timeout 10 --location $LOC

# 4 - attach to the subnet - MUST happen before step 5
az network vnet subnet update -g $RG --vnet-name $VNET -n $SUBNET --nat-gateway agentlab-natgw

# 5 - make the subnet private (switches off default outbound)
az network vnet subnet update -g $RG --vnet-name $VNET -n $SUBNET --default-outbound-access false

Check the data plane, not just “Succeeded”:

curl -s https://ifconfig.me; echo    # from the VM: must print the NAT gateway's IP
tailscale status                      # peers still connected

Cost. The NAT Gateway is about EUR 30 a month - as much as the VM. Cheaper alternatives exist: a static public IP on the NIC with the NSG lock (about EUR 3, but then “no inbound” is a configuration, not a structural fact), or relying on default outbound while an older subnet still has it. I kept the NAT; it’s a budget decision, not a security one.

Step 5: The agents

Python: use uv, not the system Python. Ubuntu 24.04 ships without ensurepip, so python3 -m venv fails, and fixing it needs sudo - which the agent account doesn’t have. uv installs into user space and handles environments and packages itself:

curl -LsSf https://astral.sh/uv/install.sh | sh

Claude Code installs with the official installer under agents (check the current method in Anthropic’s docs) and lands in ~/.local/bin/claude.

Codex CLI and device log-in on a headless server cost me the most time - three problems stacked on top of each other that looked like one:

  1. The ChatGPT account has a setting “Enable device code authorization for Codex”. Off by default. Check it first.
  2. A headless VM has no browser for the OAuth callback. Tunnel the callback port from your desktop: ssh -L 1455:localhost:1455 <admin>@<tailscale-host>.
  3. Two-factor codes kept being “incorrect” - the phone’s clock was off. TOTP is purely time-based; half a minute of drift is enough. Sync time in the authenticator app.
codex login status    # must say: logged in via ChatGPT

Step 6: The permission model - and why it leaks

Claude Code reads its permissions from ~/.claude/settings.json: allow (no questions), ask, deny (hard no). Abbreviated:

{
  "permissions": {
    "allow": [
      "Read(~/**)", "Edit(~/**)", "Write(~/**)",
      "Bash(ls:*)", "Bash(cat:*)", "Bash(grep:*)", "Bash(git:*)",
      "Bash(python3:*)", "Bash(pip:*)", "Bash(node:*)", "Bash(npm:*)"
    ],
    "ask": [
      "Bash(rm:*)", "Bash(curl:*)", "Bash(wget:*)", "Bash(ssh:*)", "Bash(nc:*)", "Bash(git push:*)"
    ],
    "deny": [
      "Bash(sudo:*)", "Write(/etc/**)", "Write(/usr/**)", "Write(/var/**)", "Write(/root/**)"
    ]
  }
}

Two things that aren’t obvious:

In a headless set-up, ask means deny. Nobody is there to answer the prompt, so the request is silently refused.

It looks like outbound traffic is blocked. It isn’t. curl and wget are in ask, but python3 and node are in allow - and python3 -c "import urllib.request; ..." goes out just as well. Edit(~/**) also lets the agent edit its own settings.json. The realistic attack: the agent fetches a web page with an injected instruction, reads a token from the home directory, and sends it out through Python. Missing sudo doesn’t stop that - exfiltration needs no admin rights.

What I take from it:

  • Egress control belongs on the network layer (NSG, NAT, a separate sandbox), not in a permission list, as long as an interpreter is allowed.
  • If the agents don’t need to run code - a research-and-critique pipeline doesn’t - move python3, node, pip, npm to ask. Give free code execution its own sandbox machine.
  • Keep only tokens on the box that you can revoke and rotate quickly.

Step 7: Letting agents search the web without asking

Because ask means deny headless, web search would never be approved. A PreToolUse hook approves the two read-only web tools:

mkdir -p ~/.claude/hooks
cat > ~/.claude/hooks/allow-web.sh <<'EOF'
#!/usr/bin/env bash
echo '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow","permissionDecisionReason":"auto-approved read-only web tool"}}'
EOF
chmod +x ~/.claude/hooks/allow-web.sh
"hooks": {
  "PreToolUse": [
    { "matcher": "WebSearch|WebFetch",
      "hooks": [ { "type": "command", "command": "/home/agents/.claude/hooks/allow-web.sh" } ] }
  ]
}
~/.local/bin/claude -p "Use WebFetch on https://example.com and return the <title>."
# returns "Example Domain" without asking = the hook works

What went wrong first: a hook printing {"decision":"allow"} fired but was ignored - that’s not the schema Claude Code 2.1 expects for PreToolUse. The schema has changed between versions, so test it, don’t trust it. The matcher is case-sensitive (WebSearch, not websearch). An allow-list entry for web tools proved unreliable, and a PermissionRequest hook doesn’t fire in non-interactive mode at all. Reassuringly, a hook can only tighten: it can’t override a deny.

Step 8: Telegram gateway and systemd

One Python script serves all three bots. An environment variable picks the mode - claude, codex or router. The parts that matter:

  • Authorisation: a hard-coded set of allowed Telegram user IDs. Everyone else is rejected and logged.
  • Timeout: 600 seconds per agent call (see lesson 3 below).
  • Chunking: replies split into 4,000-character messages, Telegram’s limit.
  • Router mode: agent A’s full output is passed to agent B as a variable inside the process, never through the chat.

Bot tokens from BotFather go into files readable only by the agent account:

echo '<TOKEN>' | sudo tee /home/agents/telegram-gateway/token_router >/dev/null
sudo chown agents:agents /home/agents/telegram-gateway/token_router
sudo chmod 600 /home/agents/telegram-gateway/token_router

One systemd unit per bot (/etc/systemd/system/tg-pipeline.service):

[Unit]
Description=Telegram gateway - router
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=agents
WorkingDirectory=/home/agents/telegram-gateway
Environment=GATEWAY_AGENT=router
Environment=GATEWAY_TOKEN_FILE=/home/agents/telegram-gateway/token_router
Environment=PATH=/home/agents/.local/bin:/usr/local/bin:/usr/bin:/bin
ExecStart=/home/agents/.local/bin/uv run python gateway.py
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now tg-pipeline.service
sudo journalctl -u tg-pipeline.service -f

Restart=always makes it self-healing and it starts at boot. Note: User=agents + multi-user.target is a system unit running as agents, not a user unit - manage it with sudo systemctl, not systemctl --user (that fails with “No medium found”).

Step 9: Three ways back in

# Path When How
1 Tailscale SSH normally ssh <admin>@agentlab
2 Azure VMAccess reset Tailscale is down Portal → VM → Reset password / Reset SSH key. Works even on locked accounts.
3 Serial Console the Azure agent is down too Portal → VM → Serial console, admin + break-glass password from a password manager

The break-glass password exists only for the serial console. Set it, store it, don’t use it unless something is on fire. Test all three paths before you need them.

Lessons worth more than the commands

  1. “The service runs, but I can’t run it by hand” is almost always PATH. The binaries are in ~/.local/bin; systemd has an explicit PATH, an ad-hoc shell doesn’t. Call by absolute path.
  2. A bot name is not a unit name. systemctl restart <bot_name> → “Unit not found”. Check with systemctl list-unit-files | grep tg-.
  3. A timeout cancels the waiting, not the process. With a 180-second limit, long research tasks “timed out” - and the orphaned agent process delivered a truncated answer later on its own. Raising the limit to 600 s fixed it, and status messages between pipeline steps stop long silences from looking like a hang.
  4. Truncated output: handler or token limit? If it arrives complete after fixing the timeout, it was the handler. If it cuts off at the same place regardless, then look at token limits.
  5. A config file that won’t parse and you can’t see why - look for duplicate sections. A TOML file with the same [projects."..."] table twice. Rewriting the file cleanly beat hunting for the duplicate.
  6. Editing the agent’s files as root changes their owner. Next time the agent can’t read them. Deploy with the right owner in one step: sudo install -o agents -g agents -m 644 /tmp/file /home/agents/....
  7. 2>/dev/null hides the truth. While debugging, let the errors print.
  8. Configuration is not behaviour. “Succeeded” in Azure, a correct-looking settings.json - they say how it should work. Verify the data plane: curl for egress, claude -p for the hook, a test message for the bot.
  9. One unknown at a time. Test the mechanics with a small task first, then the load. When a combined test fails, you don’t know which layer broke.

Daily operation, in five lines

sudo systemctl is-active tg-claude tg-codex tg-pipeline     # all running?
sudo journalctl -u tg-pipeline -n 50 --no-pager             # last 50 log lines
ps -u agents -o pid,etime,cmd                             # what runs as agents (orphans!)
curl -s https://ifconfig.me; echo                         # egress through NAT?
az vm stop -g <rg> -n agentlab                            # deallocate = stop paying for compute

[->] next step

Where do your numbers stop adding up?

Start with a short call. Bring one report nobody trusts - I'll trace it back to where the data gets captured.