Claude Agent VM — Setup Guide
A dedicated Ubuntu Desktop VM on Proxmox that gives Claude Code its own full development environment: clone the repo, write code, run tests, run the apps with a real GUI/Electron window, push branches, open PRs. Replaces the prior remote-routine model so every step happens locally on hardware Claude controls.
This guide sets up the environment only. Scheduling/triggering of
autonomous fires is handled by the budget-paced agent-daemon systemd
service — see Autonomous Loop for the
daemon, /afk-pickup, PR CI babysitting (/pr-watch), manual
verification, and the PR reconcile flow (/pr-reconcile).
1. Decisions locked (reference)
| # | Topic | Choice |
|---|---|---|
| 1 | Claude binary | Both Claude Code CLI and Claude Agent SDK |
| 2 | Run mode | Headless one-shot and long-lived tmux |
| 3 | Permission mode (autonomous) | bypassPermissions |
| 4 | Working dir | Persistent clone at ~/Smart-Smoker-V2, branch-per-fire |
| 5 | Anthropic auth | Pro/Max OAuth via claude /login |
| 6 | GitHub auth | Classic PAT, scopes repo, project, workflow |
| 7 | Git author identity | Bot — claude-agent <claude-agent@benjr70.local> |
| 8 | Node | 24.7.0 exact via nvm (matches CI) |
| 9 | Docker | Engine + Buildx + Compose v2, claude user in docker group |
| 10 | Mongo | New dev.docker-compose.yml with Mongo only — local-dev DB |
| 11 | Browsers | Playwright Chromium + system Google Chrome |
| 12 | Electron | Real window via host GUI (libraries pre-installed) |
| 13 | Remote access | Tailscale SSH + Proxmox web console only |
| 14 | Skills scope | Project skills plus mirror user-level ~/.claude/skills/ |
| 15 | MCP servers | Project .mcp.json MCPs + add github MCP |
| 16 | Dotfiles | Manual scp from dev box |
| 17 | Git push transport | HTTPS via gh-managed PAT credential helper |
| 18 | Linux user | claude (single user) |
| 19 | Sudo | None for claude (admin sudo manually when needed) |
| 20 | Repo clone | Full gh repo clone, all branches, full history |
| 21 | App .env |
Fresh dev values pointing at local Mongo |
| 22 | Firewall | ufw: deny LAN inbound, allow tailscale0 |
| 23 | Hostname | claude-agent-1 |
| 24 | Tailscale auth | Interactive tailscale up (browser flow) |
| 25 | Disk layout | Single ext4 root + 4 GB swapfile |
| 26 | Logs | Defer; just create ~/claude-agent/logs/ |
| 27 | Apt baseline | Full set (core + dev ergonomics + repo specifics) |
2. VM provisioning (Proxmox)
Create the VM through the Proxmox UI. Suggested params:
| Setting | Value |
|---|---|
| Name | claude-agent-1 |
| OS ISO | Ubuntu Desktop 24.04 LTS (ubuntu-24.04-desktop-amd64.iso) |
| BIOS | OVMF (UEFI) |
| Machine type | q35 |
| CPU | host, 6 cores |
| Memory | 12288 MB (12 GB) |
| Disk | 50 GB, format qcow2 (or raw on ZFS), discard on |
| Network NIC 1 | LAN bridge (vmbr0), virtio |
| Network NIC 2 | (optional) — second NIC not needed; Tailscale runs over the LAN NIC |
| Boot order | DVD → Disk |
The "two interfaces" plan (Q5) is logical, not physical: one NIC carries
LAN traffic, Tailscale creates the tailscale0 virtual interface on top.
Install Ubuntu Desktop normally:
- Hostname:
claude-agent-1 - Username:
claude - Strong password (no passwordless sudo — see §10)
- Auto-login: off
- Disk: erase entire disk, single ext4 root, swapfile (default Ubuntu installer behavior matches Q25)
Reboot, log in as claude, run sudo apt update && sudo apt full-upgrade -y,
reboot once more.
3. Apt baseline
sudo apt update
sudo apt install -y \
git curl wget jq build-essential ca-certificates xclip \
tmux htop ripgrep fd-find bat tree neovim \
python3-pip pkg-config libssl-dev mesa-utils \
ufw apt-transport-https gnupg lsb-release software-properties-common
fd-find ships the binary as fdfind on Ubuntu — alias if desired:
mkdir -p ~/.local/bin
ln -s "$(which fdfind)" ~/.local/bin/fd
echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc
Electron runtime libraries (most ship with Desktop edition; install explicitly to be sure):
sudo apt install -y \
libgtk-3-0 libnss3 libasound2t64 libxss1 libxtst6 libgbm1 \
libnotify4 libxshmfence1
4. Networking
4a. Tailscale
curl -fsSL https://tailscale.com/install.sh | sh
sudo tailscale up --hostname=claude-agent-1
A browser link appears — open it on a logged-in machine, approve the node. Verify:
tailscale status | head -3
tailscale ip -4
The node should appear in the tailnet as
claude-agent-1.tail74646.ts.net.
4b. UFW — LAN deny, Tailscale allow
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow in on tailscale0
sudo ufw enable
sudo ufw status verbose
SSH is reachable only over Tailscale. App ports (3000/3001/3003/8080) listen on all interfaces but the firewall blocks LAN inbound, so only tailnet peers can connect.
4c. Tailscale SSH (optional but recommended)
sudo tailscale up --hostname=claude-agent-1 --ssh
Lets you ssh claude@claude-agent-1 from any other tailnet device with
no key management.
5. Linux user claude
Already created during install. Verify and harden:
# Confirm user `claude` is NOT in the sudo group (Q19).
groups claude
# expect: claude (no sudo / adm)
# If sudo was added during install, remove it:
sudo deluser claude sudo
Admin operations are performed by logging into the Proxmox console as
root or any other admin account — claude stays unprivileged.
6. Node.js via nvm
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash
source ~/.bashrc
nvm install 24.7.0
nvm alias default 24.7.0
node -v # → v24.7.0
npm -v
Global tools that the workflow needs:
npm install -g @anthropic-ai/claude-code @anthropic-ai/sdk
claude-code is the CLI. The SDK is installed because Q1 = both — even
if you never use it from a script, having it in the global path makes
ad-hoc Node experiments work.
7. Docker
Install Docker Engine + Buildx + Compose v2 from Docker's apt repo:
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo tee /etc/apt/keyrings/docker.asc >/dev/null
sudo chmod a+r /etc/apt/keyrings/docker.asc
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] \
https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo $VERSION_CODENAME) stable" \
| sudo tee /etc/apt/sources.list.d/docker.list >/dev/null
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
sudo usermod -aG docker claude
# log out / back in for group change to apply
Verify (after re-login):
docker --version
docker compose version
docker buildx version
docker run --rm hello-world
8. Browsers
System Chrome (for human visual testing and any Claude-driven manual checks):
wget -O /tmp/chrome.deb https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb
sudo apt install -y /tmp/chrome.deb
rm /tmp/chrome.deb
google-chrome --version
Playwright Chromium gets installed per-repo after cloning the repo (§11).
9. GitHub CLI + auth
curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \
| sudo dd of=/etc/apt/keyrings/githubcli-archive-keyring.gpg
sudo chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] \
https://cli.github.com/packages stable main" \
| sudo tee /etc/apt/sources.list.d/github-cli.list >/dev/null
sudo apt update
sudo apt install -y gh
Generate a classic PAT at https://github.com/settings/tokens:
- Note:
claude-agent-vm - Expiration: 90 days (rotate on calendar)
- Scopes:
repo,project,workflow
Save the token to ~/.config/claude-agent/gh-pat (mode 600), then:
mkdir -p ~/.config/claude-agent
chmod 700 ~/.config/claude-agent
# paste token into the file via the editor of your choice
chmod 600 ~/.config/claude-agent/gh-pat
gh auth login --with-token < ~/.config/claude-agent/gh-pat
gh auth setup-git # configures HTTPS credential helper for `git push`
gh auth status
Verify the project scope is present in the output. Without it,
afk-pickup falls back to the GitHub MCP path — also fine.
10. Git config (bot identity)
git config --global user.name "claude-agent"
git config --global user.email "claude-agent@benjr70.local"
git config --global init.defaultBranch master
git config --global pull.rebase false
git config --global push.default current
The claude-agent@benjr70.local address is intentionally
non-deliverable — commits clearly attribute to the bot but never spam a
real inbox. Adjust the domain if you prefer a real email you control.
11. Clone the repo
cd ~
gh repo clone benjr70/Smart-Smoker-V2
cd Smart-Smoker-V2
git status
Bootstrap the workspace (the repo's npm install requires
--legacy-peer-deps, wrapped by npm run bootstrap):
npm run bootstrap
Install Playwright Chromium and its system dependencies:
npx playwright install --with-deps chromium
12. Local Mongo via docker compose
The repo's existing cloud.docker-compose.yml carries a full prod-like
stack. For dev/test on this VM we only need Mongo. Create
dev.docker-compose.yml at the repo root:
# dev.docker-compose.yml — local Mongo for the claude-agent VM
services:
mongo:
image: mongo:7.0
container_name: dev-mongo
restart: unless-stopped
ports:
- 127.0.0.1:27017:27017
volumes:
- dev_mongo_data:/data/db
volumes:
dev_mongo_data:
Start it:
docker compose -f dev.docker-compose.yml up -d
docker compose -f dev.docker-compose.yml ps
Mongo URI for app .env files: mongodb://127.0.0.1:27017/smartsmoker.
13. App .env files
Repo apps read environment variables at runtime. Author fresh dev values on the VM — never copy production secrets to a bot host.
apps/backend/.env:
DB_URL=mongodb://127.0.0.1:27017/smartsmoker
PORT=3001
JWT_SECRET=dev-only-not-a-real-secret-rotate-me
VAPID_PUBLIC_KEY=
VAPID_PRIVATE_KEY=
VAPID_SUBJECT=mailto:dev@benjr70.local
Generate VAPID keys if web-push tests need them:
npx web-push generate-vapid-keys
apps/device-service/.env:
PORT=3003
BACKEND_URL=http://127.0.0.1:3001
apps/frontend/.env:
REACT_APP_BACKEND_URL=http://127.0.0.1:3001
apps/smoker/.env (Electron renderer, if applicable):
REACT_APP_BACKEND_URL=http://127.0.0.1:3001
The actual variable names each app expects should be confirmed against
the consuming code; the values above are placeholders that match the
ports table in CLAUDE.md.
14. Claude Code configuration
14a. Anthropic OAuth login
claude /login
A browser link is printed — log in with your Pro/Max account, grant
access. The credential lands at ~/.claude/.credentials.json and
auto-refreshes.
14b. Mirror user-level skills from your dev box
From your dev box:
ssh claude@claude-agent-1 "mkdir -p ~/.claude/skills"
scp -r ~/.claude/skills/* claude@claude-agent-1:~/.claude/skills/
scp ~/.gitconfig claude@claude-agent-1:~/.gitconfig.devbox # for reference only
The repo's .claude/skills/ and .claude/settings.json already travel
with the clone in §11 — no extra step needed for project skills.
14c. Add github MCP to the project
Edit .mcp.json at the repo root and add a github server. Example:
{
"mcpServers": {
"context7": { "type": "stdio", "command": "npx", "args": ["-y", "@upstash/context7-mcp"], "env": {} },
"playwright": { "type": "stdio", "command": "npx", "args": ["-y", "@playwright/mcp@latest"], "env": {} },
"terraform": { "type": "stdio", "command": "npx", "args": ["-y", "@hashicorp/terraform-mcp-server"], "env": {} },
"docker": { "type": "stdio", "command": "npx", "args": ["-y", "@quantgeekdev/docker-mcp"], "env": {} },
"mongodb": { "type": "stdio", "command": "npx", "args": ["-y", "mongodb-mcp-server"], "env": {} },
"github": {
"type": "stdio",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-github"],
"env": {
"GITHUB_PERSONAL_ACCESS_TOKEN": "${GITHUB_TOKEN}"
}
}
}
}
Export GITHUB_TOKEN in ~/.bashrc from the same PAT used for gh:
echo 'export GITHUB_TOKEN="$(cat ~/.config/claude-agent/gh-pat)"' >> ~/.bashrc
Commit the .mcp.json change in a normal PR — it benefits every clone,
not just this VM.
The same file also carries the /verify-pr harness servers
(playwright-chrome, playwright-electron) as committed entries (issue #388).
Do not let a provisioning run author them by hand:
scripts/verify-pr/provision-box.sh only verifies them, and any uncommitted
edit to .mcp.json is destroyed by the agent-run cleanup's git reset --hard.
Both entries launch through bash -c and resolve the checkout root with
git rev-parse --show-toplevel before exec'ing their wrapper. That is
deliberate: stdio MCP servers inherit the session's cwd, so a committed
./scripts/… command would ENOENT for anyone who starts Claude in a
subdirectory, and an absolute path would only ever be right on one machine.
If a session on this VM is missing mcp__playwright-electron__* tools, run
claude mcp list and check for a stale local-scope duplicate of these two
servers (claude mcp remove <name> -s local) — a local entry shadows the
committed project entry, including with the old wrapper path.
14d. Working directory layout
/home/claude/
Smart-Smoker-V2/ ← persistent clone, branch per fire
claude-agent/
logs/ ← future fire logs (rotation set up later)
.claude/
.credentials.json
skills/ ← mirrored user skills
.config/claude-agent/
gh-pat ← 600 PAT file
Create the log directory and a basic logrotate config:
mkdir -p ~/claude-agent/logs
sudo tee /etc/logrotate.d/claude-agent >/dev/null <<'EOF'
/home/claude/claude-agent/logs/*.log {
weekly
rotate 8
compress
missingok
notifempty
su claude claude
}
EOF
15. Smoke test — VM is ready
Run from ~/Smart-Smoker-V2:
# 1. Toolchain
node -v # v24.7.0
npm -v
docker compose version
gh auth status
claude --version
# 2. Workspace install
npm run bootstrap
# 3. Lint + format check
npm run lint
npm run format:check
# 4. Tests, per app (must run from each app dir per CLAUDE.md)
( cd apps/backend && npm test )
( cd apps/device-service && npm test )
( cd apps/frontend && npm test )
( cd apps/smoker && npm test )
( cd packages/TemperatureChart && npm test )
# 5. Mongo reachable
docker compose -f dev.docker-compose.yml ps
mongosh --quiet --eval 'db.runCommand({ping:1})' mongodb://127.0.0.1:27017 || \
docker exec dev-mongo mongosh --quiet --eval 'db.runCommand({ping:1})'
# 6. Headed Electron (smoker) at least starts
npm run smoker:start &
SMOKER_PID=$!
sleep 8
ps -p $SMOKER_PID > /dev/null && echo "smoker UP" || echo "smoker FAILED"
kill $SMOKER_PID 2>/dev/null
# 7. Claude Code can clone its own context
claude --print --permission-mode bypassPermissions \
"Read CLAUDE.md and reply with the four app names in this monorepo, comma-separated. No other text."
If all of the above succeed, the VM is ready.
16. What's not in this guide
- Scheduling — how/when autonomous fires are triggered. Deferred per user direction. Future doc will cover systemd timer vs cron vs remote-routine, log shipping, alerting.
- PR-check watcher — auto-fix-on-red flow. Same reason.
- Backups / snapshots — Proxmox-native concern; configure under your existing backup policy.
- Multi-fire concurrency — single VM, single fire at a time per
AFK:in-progressdistributed lock. Scaling out is a future-VM topic.
17. Operations cheatsheet
# SSH in (from any tailnet device)
ssh claude@claude-agent-1
# Manual Claude session (Q2c — long-lived tmux)
tmux new -s claude
cd ~/Smart-Smoker-V2
claude
# One-shot autonomous-style invocation
cd ~/Smart-Smoker-V2
claude --print --permission-mode bypassPermissions "<prompt>"
# Bring Mongo up/down
cd ~/Smart-Smoker-V2
docker compose -f dev.docker-compose.yml up -d
docker compose -f dev.docker-compose.yml down
# Refresh repo before a manual run
cd ~/Smart-Smoker-V2
git fetch origin master
git checkout master
git reset --hard origin/master
npm run bootstrap
# Rotate the GitHub PAT
echo "<new-token>" > ~/.config/claude-agent/gh-pat
chmod 600 ~/.config/claude-agent/gh-pat
gh auth login --with-token < ~/.config/claude-agent/gh-pat
gh auth setup-git