Add Claude Code LXC deployer script for Proxmox

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-23 20:41:03 +02:00
co-authored by Claude Opus 5
commit 82a6c59bce
+982
View File
@@ -0,0 +1,982 @@
#!/usr/bin/env bash
# ============================================================================
# Claude Code LXC Deployer for Proxmox — angepasste Version
# Creates a fully provisioned Ubuntu 26.04 LXC container ready for Claude Code
#
# Änderungen gegenüber dem Original (serversathome):
# - FIX: Template-Erkennung nahm mit awk '{print $NF}' die letzte Spalte
# (Architektur) statt des Template-Namens -> jetzt $2
# - FIX: grep-Pipeline unter `set -e` abgesichert (|| true), sonst bricht
# das Script kommentarlos ab, wenn kein Template gefunden wird
# - Storage-Default: data (statt truenas-lvm)
# - Zeitzone: Europe/Berlin (statt America/New_York) — auch Cron & CLAUDE.md
# - Zusätzlich de_DE.UTF-8 Locale generiert
# - pnpm global installiert (zusätzlich zu npm)
# - Optionale Git-Identität (user.name/user.email) wird abgefragt und gesetzt
#
# Run on your Proxmox host: bash agentic.sh
# ============================================================================
set -euo pipefail
# ── Colors & Helpers ────────────────────────────────────────────────────────
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
BOLD='\033[1m'
NC='\033[0m'
info() { echo -e "${CYAN}[INFO]${NC} $*"; }
success() { echo -e "${GREEN}[OK]${NC} $*"; }
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
error() { echo -e "${RED}[ERROR]${NC} $*"; exit 1; }
header() {
echo ""
echo -e "${BOLD}╔══════════════════════════════════════════════════╗${NC}"
echo -e "${BOLD}║ Claude Code LXC Deployer (Proxmox) ║${NC}"
echo -e "${BOLD}╚══════════════════════════════════════════════════╝${NC}"
echo ""
}
# ── Pre-flight checks ──────────────────────────────────────────────────────
preflight() {
[[ $(id -u) -eq 0 ]] || error "This script must be run as root on the Proxmox host."
command -v pct &>/dev/null || error "pct not found. Are you running this on a Proxmox host?"
command -v pveam &>/dev/null || error "pveam not found. Are you running this on a Proxmox host?"
}
# ── Template Resolution ─────────────────────────────────────────────────────
resolve_template() {
info "Resolving latest Ubuntu 26.04 LXC template from catalog..."
pveam update >/dev/null 2>&1 || true
local found
# FIX: $2 statt $NF (pveam available liefert: Sektion, Name, Arch) und
# || true, damit ein leerer grep-Treffer unter `set -e` nicht das ganze
# Script beendet, bevor der Fallback greifen kann.
found=$(pveam available --section system 2>/dev/null \
| awk '{print $2}' \
| grep -E '^ubuntu-26\.04-standard' \
| sort -V | tail -n1 || true)
if [[ -n "$found" ]]; then
TEMPLATE="$found"
success "Using template: $TEMPLATE"
else
TEMPLATE="ubuntu-26.04-standard_26.04-1_amd64.tar.zst"
warn "No 26.04 template found in catalog; using fallback name: $TEMPLATE"
warn "Verify with: pveam available --section system | grep ubuntu-26.04"
fi
}
# ── Configuration ───────────────────────────────────────────────────────────
get_config() {
# Find next available CT ID
local next_id
next_id=$(pvesh get /cluster/nextid 2>/dev/null || echo "100")
# Resolve newest Ubuntu 26.04 template (sets $TEMPLATE)
resolve_template
echo -e "${BOLD}Container Configuration${NC}"
echo "─────────────────────────────────────────────────"
read -rp "Container ID [$next_id]: " CT_ID
CT_ID="${CT_ID:-$next_id}"
[[ "$CT_ID" =~ ^[0-9]+$ ]] || error "Container ID must be a number."
pct status "$CT_ID" &>/dev/null && error "Container ID $CT_ID already exists."
read -rp "Hostname [claude-code]: " CT_HOSTNAME
CT_HOSTNAME="${CT_HOSTNAME:-claude-code}"
read -rsp "Root password: " CT_PASSWORD
echo ""
[[ -n "$CT_PASSWORD" ]] || error "Password cannot be empty."
read -rp "CPU cores [4]: " CT_CORES
CT_CORES="${CT_CORES:-4}"
read -rp "RAM in MB [10240]: " CT_RAM
CT_RAM="${CT_RAM:-10240}"
read -rp "Swap in MB [2048]: " CT_SWAP
CT_SWAP="${CT_SWAP:-2048}"
read -rp "Disk size in GB [30]: " CT_DISK
CT_DISK="${CT_DISK:-30}"
read -rp "Storage [data]: " CT_STORAGE
CT_STORAGE="${CT_STORAGE:-data}"
# Network - default DHCP
read -rp "IP address (DHCP or x.x.x.x/xx) [dhcp]: " CT_IP
CT_IP="${CT_IP:-dhcp}"
if [[ "$CT_IP" != "dhcp" ]]; then
read -rp "Gateway: " CT_GW
[[ -n "$CT_GW" ]] || error "Gateway is required for static IP."
fi
read -rp "DNS server [1.1.1.1]: " CT_DNS
CT_DNS="${CT_DNS:-1.1.1.1}"
# SSH key (optional)
read -rp "Path to SSH public key (optional, press Enter to skip): " CT_SSH_KEY
# Git identity (optional) — wird im Container als git config --global gesetzt
read -rp "Git user.name (optional, press Enter to skip): " GIT_NAME
read -rp "Git user.email (optional, press Enter to skip): " GIT_EMAIL
echo ""
echo -e "${BOLD}Summary${NC}"
echo "─────────────────────────────────────────────────"
echo " CT ID: $CT_ID"
echo " Hostname: $CT_HOSTNAME"
echo " Template: $TEMPLATE"
echo " CPU: $CT_CORES cores"
echo " RAM: $CT_RAM MB ($(( CT_RAM / 1024 )) GB)"
echo " Swap: $CT_SWAP MB"
echo " Disk: ${CT_DISK}G on $CT_STORAGE"
echo " Network: $CT_IP"
echo " DNS: $CT_DNS"
[[ -n "${GIT_NAME:-}" ]] && echo " Git: $GIT_NAME <${GIT_EMAIL:-}>"
echo "─────────────────────────────────────────────────"
echo ""
read -rp "Proceed? (y/N): " confirm
[[ "$confirm" =~ ^[Yy]$ ]] || { echo "Aborted."; exit 0; }
}
# ── Download Ubuntu 26.04 Template ─────────────────────────────────────────
get_template() {
info "Checking for template: $TEMPLATE"
if ! pveam list local 2>/dev/null | grep -q "$TEMPLATE"; then
info "Downloading $TEMPLATE ..."
pveam download local "$TEMPLATE" || error "Failed to download template. Run 'pveam update' and try again."
else
success "Template already downloaded: $TEMPLATE"
fi
TEMPLATE_PATH="local:vztmpl/$TEMPLATE"
}
# ── Create Container ───────────────────────────────────────────────────────
create_container() {
info "Creating LXC container $CT_ID..."
# Build network string
local net_str="name=eth0,bridge=vmbr0"
if [[ "$CT_IP" == "dhcp" ]]; then
net_str+=",ip=dhcp"
else
net_str+=",ip=$CT_IP,gw=$CT_GW"
fi
# Build pct create command
local cmd=(
pct create "$CT_ID" "$TEMPLATE_PATH"
--hostname "$CT_HOSTNAME"
--password "$CT_PASSWORD"
--cores "$CT_CORES"
--memory "$CT_RAM"
--swap "$CT_SWAP"
--rootfs "$CT_STORAGE:$CT_DISK"
--net0 "$net_str"
--nameserver "$CT_DNS"
--ostype ubuntu
--unprivileged 0
--features nesting=1,keyctl=1
--onboot 1
--start 0
)
# Add SSH key if provided
if [[ -n "${CT_SSH_KEY:-}" && -f "$CT_SSH_KEY" ]]; then
cmd+=(--ssh-public-keys "$CT_SSH_KEY")
fi
"${cmd[@]}"
success "Container $CT_ID created."
# Disable AppArmor for Docker-in-LXC compatibility
info "Setting AppArmor profile to unconfined (required for Docker)..."
echo "lxc.apparmor.profile: unconfined" >> "/etc/pve/lxc/${CT_ID}.conf"
}
# ── Start & Wait for Network ──────────────────────────────────────────────
start_container() {
info "Starting container $CT_ID..."
pct start "$CT_ID"
sleep 3
info "Waiting for network..."
local attempts=0
while ! pct exec "$CT_ID" -- ping -c1 -W2 1.1.1.1 &>/dev/null; do
((attempts++))
[[ $attempts -lt 30 ]] || error "Container failed to get network after 60s."
sleep 2
done
success "Container is online."
}
# ── Provision Container ───────────────────────────────────────────────────
provision_container() {
info "Provisioning container (this takes a few minutes)..."
# Write provision script to host, then push into container
cat > /tmp/provision-${CT_ID}.sh << 'PROVISION_EOF'
#!/bin/bash
set -e
export DEBIAN_FRONTEND=noninteractive
echo ">>> Setting timezone to Europe/Berlin..."
ln -sf /usr/share/zoneinfo/Europe/Berlin /etc/localtime
echo "Europe/Berlin" > /etc/timezone
dpkg-reconfigure -f noninteractive tzdata
echo ">>> Generating locales..."
apt-get update -qq
apt-get install -y -qq locales
sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen
sed -i '/de_DE.UTF-8/s/^# //g' /etc/locale.gen
locale-gen en_US.UTF-8 de_DE.UTF-8 > /dev/null 2>&1
update-locale LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8
export LANG=en_US.UTF-8
export LC_ALL=en_US.UTF-8
echo ">>> Updating system..."
apt-get upgrade -y -qq
echo ">>> Installing core packages..."
apt-get install -y -qq \
git curl wget unzip zip \
ca-certificates gnupg lsb-release apt-transport-https software-properties-common \
bash-completion locales \
htop nano vim tmux screen \
jq yq tree \
net-tools iproute2 iputils-ping dnsutils \
openssh-server \
cron logrotate
echo ">>> Installing build tools & dev libraries..."
apt-get install -y -qq \
build-essential make cmake pkg-config autoconf automake libtool \
python3 python3-pip python3-venv python3-dev \
libssl-dev libffi-dev libsqlite3-dev zlib1g-dev \
libreadline-dev libbz2-dev libncurses-dev liblzma-dev libxml2-dev libxslt-dev
echo ">>> Installing search & productivity tools..."
apt-get install -y -qq \
ripgrep fd-find fzf bat \
rsync \
sqlite3
echo ">>> Installing database clients..."
apt-get install -y -qq \
postgresql-client redis-tools
echo ">>> Installing Node.js (latest LTS via NodeSource)..."
# setup_lts.x tracks the current LTS line, so fresh deploys get the newest
# *safe* Node automatically (Node 24 today; it rolls to the next LTS on its own
# when one lands — no script edit needed). We deliberately track LTS, not the
# odd/Current line (short-lived, not recommended here). apt upgrades keep it
# patched within the line; a major-version jump stays a deliberate rebuild
# rather than an unattended 4 a.m. surprise.
curl -fsSL https://deb.nodesource.com/setup_lts.x | bash -
apt-get install -y -qq nodejs
# Quiet flags reused for every provisioning npm install: drop the funding/audit
# lines and the deprecation warnings that come from these packages' upstream
# transitive deps (not fixable from here). Scoped to provisioning only — your
# own installs under /project behave normally.
NPM_QUIET=(--no-fund --no-audit --loglevel=error)
# Take npm to its own latest release — the bundled npm lags behind and otherwise
# prints an "update available" notice on every install.
npm install -g "${NPM_QUIET[@]}" npm@latest || echo " [WARN] npm self-update failed; using bundled npm"
echo " Node.js $(node --version) / npm $(npm --version)"
# npm 12+ blocks dependencies' install scripts behind an allowlist (separate from
# ignore-scripts), so native modules like CloudCLI's better-sqlite3/node-pty/
# bcrypt never compile and the service crash-loops with no bind on :3001. We just
# took npm to latest (>=12), so set the allowlist NOW, before any native install
# below (CloudCLI, Playwright, the language servers). It persists to /root/.npmrc
# under npm 12+; harmless if npm is still <12.
# HINWEIS: gilt damit global (auch für eigene Installs unter /project) —
# Install-Scripts fremder Pakete laufen dann ungefragt.
npm config set dangerously-allow-all-scripts true 2>/dev/null || true
echo ">>> Installing global npm packages..."
npm install -g "${NPM_QUIET[@]}" typescript ts-node eslint prettier
echo ">>> Installing pnpm..."
# Für Projekte mit pnpm-Workflow. Zusätzlich corepack aktivieren, damit
# Projekte mit "packageManager"-Feld in der package.json ihre gepinnte
# pnpm/yarn-Version automatisch bekommen.
npm install -g "${NPM_QUIET[@]}" pnpm || echo " [WARN] pnpm install failed"
export COREPACK_ENABLE_DOWNLOAD_PROMPT=0
corepack enable 2>/dev/null || true
echo " pnpm $(pnpm --version 2>/dev/null || echo 'n/a')"
echo ">>> Installing Go..."
GO_VERSION=$(curl -fsSL "https://go.dev/VERSION?m=text" | head -1)
curl -fsSL "https://go.dev/dl/${GO_VERSION}.linux-amd64.tar.gz" -o /tmp/go.tar.gz
rm -rf /usr/local/go
tar -C /usr/local -xzf /tmp/go.tar.gz
rm /tmp/go.tar.gz
echo 'export PATH=$PATH:/usr/local/go/bin' >> /etc/profile.d/go.sh
echo " Go $(/usr/local/go/bin/go version | awk '{print $3}')"
echo ">>> Installing Rust..."
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
source "$HOME/.cargo/env"
echo " Rust $(rustc --version | awk '{print $2}')"
echo ">>> Installing Docker..."
curl -fsSL https://get.docker.com | sh
systemctl enable docker
echo " Docker $(docker --version | awk '{print $3}' | tr -d ',')"
echo ">>> Installing Docker Compose plugin..."
apt-get install -y -qq docker-compose-plugin 2>/dev/null || true
echo " Compose $(docker compose version --short 2>/dev/null || echo 'included with Docker')"
echo ">>> Installing Claude Code (native installer)..."
curl -fsSL https://claude.ai/install.sh | bash
# Ensure claude is on PATH for all sessions
if [[ -f "$HOME/.local/bin/claude" ]]; then
ln -sf "$HOME/.local/bin/claude" /usr/local/bin/claude 2>/dev/null || true
elif [[ -f "$HOME/.claude/bin/claude" ]]; then
ln -sf "$HOME/.claude/bin/claude" /usr/local/bin/claude 2>/dev/null || true
fi
CLAUDE_BIN="$(command -v claude || echo /usr/local/bin/claude)"
echo " Claude Code installed: $("$CLAUDE_BIN" --version 2>/dev/null || echo 'version unknown')"
echo ">>> Configuring Claude Code settings (permissions + env)..."
# NOTE on enabledPlugins: we DO declare it here. Earlier belief was that
# enabledPlugins is ignored in non-interactive/container contexts — that's
# wrong. `claude plugin install X@mkt` installs the plugin but leaves it
# Status: disabled; the enabled-state lives in enabledPlugins in THIS file
# (user settings, ~/.claude/settings.json), which IS honored. Without this
# block every plugin below (including the LSP servers) stays disabled and
# CloudCLI's /project chat shows only the 1 local skill. Enabled skills show
# up live after the cloudcli restart — no fresh session needed.
#
# Verified keys (checked against code.claude.com/docs, 2026-07): the env vars
# below are all valid current settings. CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS
# must live inside "env" (it's an environment variable, not a top-level key).
# Removed: CLAUDE_CODE_ENABLE_AUTO_MODE (dead no-op since Claude Code v2.1.207 —
# auto mode works from permissions.defaultMode alone) and alwaysThinkingEnabled
# (current models use adaptive thinking; MAX_THINKING_TOKENS still caps the
# budget). The old "enableRemoteControl" key was never real — Remote Control is
# enabled per-session with /rc or for all sessions via /config (Pro/Max).
mkdir -p /root/.claude
# Permissions model: auto mode instead of a blanket allow-list.
# defaultMode "auto" auto-approves actions but routes them through a
# background safety classifier (still blocks rm -rf / , rm -rf ~ , etc.).
# Unlike bypassPermissions, auto mode is NOT refused when running as root,
# and defaultMode is honored from user settings (~/.claude/settings.json).
# The "deny" list applies in EVERY mode (including auto) — it's the hard
# floor protecting credentials/secrets and a few destructive commands.
# Env extras: DISABLE_AUTOUPDATER stops Claude's background auto-updater (we
# self-manage the version via agentic-update, so the background check just
# risks version drift/races); CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC is the
# umbrella that silences telemetry/error-reporting/feedback/surveys for a quiet
# headless box (trade-off: also disables error reporting).
cat > /root/.claude/settings.json << 'SETTINGS'
{
"$schema": "https://json.schemastore.org/claude-code-settings.json",
"permissions": {
"defaultMode": "auto",
"deny": [
"Read(**/.env)",
"Read(**/.env.*)",
"Read(**/.credentials.json)",
"Read(/root/.claude/.credentials.json)",
"Read(**/secrets/**)",
"Read(**/*.pem)",
"Read(**/id_rsa)",
"Read(**/id_ed25519)",
"Bash(dd:*)",
"Bash(mkfs:*)",
"Bash(rm -rf /:*)",
"Bash(rm -rf ~:*)"
]
},
"env": {
"CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS": "1",
"CLAUDE_CODE_MAX_OUTPUT_TOKENS": "64000",
"MAX_THINKING_TOKENS": "31999",
"DISABLE_AUTOUPDATER": "1",
"CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1"
},
"enabledPlugins": {
"code-review@claude-plugins-official": true,
"commit-commands@claude-plugins-official": true,
"context7@claude-plugins-official": true,
"frontend-design@claude-plugins-official": true,
"security-guidance@claude-plugins-official": true,
"typescript-lsp@claude-plugins-official": true,
"pyright-lsp@claude-plugins-official": true,
"gopls-lsp@claude-plugins-official": true,
"rust-analyzer-lsp@claude-plugins-official": true,
"superpowers@superpowers-marketplace": true
}
}
SETTINGS
echo ">>> Adding plugin marketplaces..."
# The official marketplace is auto-registered on first INTERACTIVE launch only,
# so a non-interactive provisioning run must add it explicitly. We use ONLY the
# first-party marketplace now — the old 'anthropics/claude-code' demo repo
# (registers as 'claude-code-plugins') ships same-named example copies that
# shadow the official ones, and boxes were silently landing on those.
"$CLAUDE_BIN" plugin marketplace add anthropics/claude-plugins-official \
|| echo " [WARN] could not add claude-plugins-official"
# Refresh the index BEFORE installing — a stale cache makes name@official
# silently fall through, which is how boxes ended up on demo copies.
"$CLAUDE_BIN" plugin marketplace update claude-plugins-official \
|| echo " [WARN] could not update claude-plugins-official index"
# Third-party marketplace for Superpowers.
"$CLAUDE_BIN" plugin marketplace add obra/superpowers-marketplace \
|| echo " [WARN] could not add superpowers-marketplace"
echo ">>> Installing Claude Code plugins (official CLI)..."
# Install pinned to the official marketplace only (no demo fallthrough).
# Non-fatal — a single failed plugin won't abort provisioning. Enabled-state is
# declared in settings.json above (enabledPlugins); install just stages files.
install_plugin() {
local name="$1"
if "$CLAUDE_BIN" plugin install "${name}@claude-plugins-official" 2>/dev/null; then
echo " installed ${name}@claude-plugins-official"
else
echo " [WARN] could not install plugin: ${name}@claude-plugins-official"
fi
return 0
}
install_plugin frontend-design
install_plugin code-review
install_plugin commit-commands
install_plugin security-guidance
install_plugin context7
# LSP plugins — real-time diagnostics + navigation. Thin wrappers around the
# language servers installed below; they need the server binary on PATH.
install_plugin typescript-lsp
install_plugin pyright-lsp
install_plugin gopls-lsp
install_plugin rust-analyzer-lsp
# Superpowers lives in its own marketplace.
"$CLAUDE_BIN" plugin install superpowers@superpowers-marketplace 2>/dev/null \
&& echo " installed superpowers@superpowers-marketplace" \
|| echo " [WARN] could not install superpowers"
# Sanity check — list what actually landed.
echo ">>> Installed plugins:"
"$CLAUDE_BIN" plugin list 2>/dev/null || echo " (plugin list unavailable)"
echo ">>> Installing language servers (backends for the *-lsp plugins)..."
# The *-lsp plugins are thin wrappers that shell out to a language server on
# PATH — they do NOT self-install the binary. Toolchains (Node/Go/Rust) are
# already installed above, so this just adds the servers. All non-fatal.
command -v typescript-language-server >/dev/null 2>&1 \
|| npm install -g "${NPM_QUIET[@]}" typescript-language-server typescript \
|| echo " [WARN] typescript-language-server install failed"
command -v pyright-langserver >/dev/null 2>&1 \
|| npm install -g "${NPM_QUIET[@]}" pyright \
|| echo " [WARN] pyright install failed"
if [ ! -x /usr/local/bin/gopls ] && [ -x /usr/local/go/bin/go ]; then
GOBIN=/usr/local/bin /usr/local/go/bin/go install golang.org/x/tools/gopls@latest \
|| echo " [WARN] gopls install failed"
fi
if ! command -v rust-analyzer >/dev/null 2>&1 && [ -x "$HOME/.cargo/bin/rustup" ]; then
"$HOME/.cargo/bin/rustup" component add rust-analyzer >/dev/null 2>&1 \
&& ln -sf "$("$HOME/.cargo/bin/rustup" which rust-analyzer 2>/dev/null)" /usr/local/bin/rust-analyzer \
|| echo " [WARN] rust-analyzer install failed"
fi
# ---------------------------------------------------------------------------
# FALLBACK if the above installs don't persist in your Claude Code version:
# Headless plugin install has been historically finicky. The container-intended
# mechanism is the (currently undocumented) CLAUDE_CODE_PLUGIN_SEED_DIR env var,
# which seeds pre-staged plugins on first launch. If `claude plugin list` above
# is empty after provisioning, stage the plugin repos into a seed dir and export
# CLAUDE_CODE_PLUGIN_SEED_DIR=<dir> in /root/.bashrc, or simply run the
# `claude plugin install` commands above once inside an interactive session.
# ---------------------------------------------------------------------------
echo ">>> Installing webapp-testing skill (from anthropics/skills)..."
# Installed as a plain user skill (NOT a marketplace plugin). A SKILL.md placed
# under ~/.claude/skills/ is picked up automatically with no plugin entry needed.
git clone --depth 1 --filter=blob:none --sparse https://github.com/anthropics/skills.git /tmp/anthropic-skills
cd /tmp/anthropic-skills && git sparse-checkout set skills/webapp-testing
mkdir -p /root/.claude/skills/
cp -r /tmp/anthropic-skills/skills/webapp-testing /root/.claude/skills/webapp-testing
rm -rf /tmp/anthropic-skills
cd /root
echo ">>> Installing Playwright (Python) for the webapp-testing skill..."
# The webapp-testing skill imports the PYTHON playwright package
# ("from playwright.sync_api import ..."), so the Node playwright we install for
# CloudCLI's Browser feature is NOT enough — the pip package must be present too,
# or the skill dies at import. Both share the same browser cache
# (~/.cache/ms-playwright), so Chromium is downloaded once for both.
# Playwright doesn't support Ubuntu 26.04 yet (microsoft/playwright#40117) and
# hard-errors instead of falling back, so force the ubuntu24.04 build, which runs
# fine on 26.04's newer glibc. This whole block is non-fatal: a browser-install
# failure must NEVER abort provisioning (it previously killed everything after
# it — Docker services, SSH, cron — because the script runs under `set -e`).
set +e
# The override MUST include the arch suffix — Playwright's build keys are
# "ubuntu24.04-x64", not "ubuntu24.04". Without "-x64" it can't find a build.
export PLAYWRIGHT_HOST_PLATFORM_OVERRIDE="ubuntu24.04-x64"
# Make the override durable for interactive sessions too (systemd services read
# their own Environment=, but 'claude' launched over SSH reads /etc/environment),
# so the skill can re-install browsers on 26.04 later without hitting the error.
grep -q PLAYWRIGHT_HOST_PLATFORM_OVERRIDE /etc/environment 2>/dev/null \
|| echo 'PLAYWRIGHT_HOST_PLATFORM_OVERRIDE=ubuntu24.04-x64' >> /etc/environment
pip install --break-system-packages -q playwright \
&& echo " python playwright package installed" \
|| echo " [WARN] pip install playwright failed (webapp-testing skill will error on import)"
if python3 -m playwright install --with-deps chromium; then
echo " Playwright chromium + OS deps installed (ubuntu24.04-x64 build, running on 26.04)"
elif python3 -m playwright install chromium; then
echo " Playwright chromium installed (browser only; some OS deps may be missing)"
else
echo " [WARN] Playwright browser install failed."
echo " Ubuntu 26.04 isn't supported by Playwright yet (microsoft/playwright#40117)."
echo " Re-run this once support lands, or to retry the 24.04-build workaround:"
echo " PLAYWRIGHT_HOST_PLATFORM_OVERRIDE=ubuntu24.04-x64 python3 -m playwright install --with-deps chromium"
fi
unset PLAYWRIGHT_HOST_PLATFORM_OVERRIDE
set -e
echo ">>> Setting up /project directory..."
mkdir -p /project
cat > /project/CLAUDE.md << 'CLAUDEMD'
# Claude Code Workspace
## Environment
- **OS**: Ubuntu 26.04 LXC container on Proxmox
- **Working directory**: /project
- **Timezone**: Europe/Berlin
- **User**: root
## Available Tools
- **Languages**: Node.js (latest LTS), Python 3 (system default), Go (latest), Rust (latest)
- **Package managers**: npm, pnpm (+ corepack), pip (use --break-system-packages), cargo, go install
- **Docker**: Docker Engine + Compose plugin, running and ready
- **Web UI**: CloudCLI UI (claudecodeui) on port 3001 — chat, file explorer/editor, git, shell
- **Search tools**: ripgrep (rg), fd-find (fdfind), fzf
- **Databases**: PostgreSQL client (psql), Redis client (redis-cli), SQLite3
## Permissions
Permission mode is "auto" (permissions.defaultMode). Actions are auto-approved
but pass through a background safety classifier that still blocks catastrophic
commands (rm -rf /, rm -rf ~). A deny floor in ~/.claude/settings.json applies in
every mode and blocks reading credentials/secrets (.env, *.pem, id_rsa,
.credentials.json) — do not try to work around it.
## Agent Teams
Agent teams are enabled. You can spawn parallel teammates for complex tasks:
- Use agent teams for work that benefits from parallel exploration
- Use subagents (Task tool) for quick focused work that reports back
- tmux is installed for split-pane team visualization
## Remote Control
Remote Control lets you steer a live local session from the Claude mobile app or
web. It is NOT enabled via settings.json — turn it on per session with `/rc`
(or `claude remote-control`), or for all sessions via `/config` →
"Enable Remote Control for all sessions". Requires a Pro/Max login (research
preview), so it only applies once someone signs in interactively.
## Docker Usage
Docker compose files should go in /docker/<service-name>/docker-compose.yml.
There is no always-on Watchtower daemon. Container images are refreshed one-shot
by the weekly `agentic-update` run (or on demand: `agentic-update`), so give
containers you want updated the usual `restart: unless-stopped`.
All Docker containers in this LXC need `security_opt: [apparmor=unconfined]`.
## Conventions
- Prefer creating files over printing long code blocks
- Use git for version control on all projects in /project/src/
- When installing Python packages, use: pip install --break-system-packages <package>
- Thinking is adaptive — the model decides when to think; lean into it for complex work
## Installed Plugins / Skills
Plugins are staged via the `claude plugin` CLI at provision time and enabled via
the `enabledPlugins` block in ~/.claude/settings.json. Run `claude plugin list`
to confirm what's active.
- **frontend-design**: Production-grade UI with distinctive aesthetics (auto-activates on frontend tasks)
- **code-review**: Multi-agent PR review with confidence scoring
- **commit-commands**: Git commit, push, and PR workflows (/commit, /push, /pr)
- **security-guidance**: Security warnings when editing sensitive files
- **context7**: Live, version-specific library docs lookup (reduces API hallucinations)
- **typescript-lsp / pyright-lsp / gopls-lsp / rust-analyzer-lsp**: real-time
diagnostics + navigation (backed by the language servers on PATH)
- **superpowers**: Development workflow framework — brainstorm → plan → implement with TDD
- /superpowers:brainstorm — Refine ideas before coding
- /superpowers:write-plan — Create implementation plans
- /superpowers:execute-plan — Execute plans in batches via subagents
- Auto-activating skills: test-driven-development, systematic-debugging, verification-before-completion
- **webapp-testing** (local skill, not a marketplace plugin): Playwright-based
browser testing for UI verification and debugging
CLAUDEMD
echo ">>> Configuring SSH..."
sed -i "s/^#*PermitRootLogin.*/PermitRootLogin yes/" /etc/ssh/sshd_config
sed -i "s/^#*PasswordAuthentication.*/PasswordAuthentication yes/" /etc/ssh/sshd_config
systemctl enable ssh
systemctl restart ssh
echo ">>> Setting up shell environment..."
cat >> /root/.bashrc << 'BASHRC'
# ── Claude Code Container ──────────────────────────────────
export EDITOR=nano
export LANG=en_US.UTF-8
export TZ=Europe/Berlin
export PATH="$HOME/.local/bin:$HOME/.claude/bin:$HOME/.cargo/bin:/usr/local/go/bin:$PATH"
# Aliases
alias ll="ls -lah --color=auto"
alias cls="clear"
alias ..="cd .."
alias ...="cd ../.."
alias gs="git status"
alias gl="git log --oneline -20"
alias dc="docker compose"
alias dps="docker ps --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}'"
# Always start in /project
cd /project 2>/dev/null || true
BASHRC
echo ">>> Setting up Git defaults..."
git config --global init.defaultBranch main
git config --global core.editor nano
git config --global pull.rebase false
echo ">>> Docker ready (no always-on service containers)..."
# code-server was removed — CloudCLI UI (below) already provides a file
# explorer/editor, so it was redundant (and dropped its :8443 + hardcoded
# 'admin' password surface). Watchtower is no longer a scheduled daemon; image
# updates for any containers YOU create now run one-shot from the coordinated
# 'agentic-update' script (see below).
echo ">>> Installing CloudCLI UI (claudecodeui web front end)..."
# Web front end for Claude Code: chat UI, file explorer/editor, git panel, and a
# built-in shell, served on :3001. It drives the same `claude` CLI and reads
# /root/.claude, so it inherits this container's login and auto-mode settings.
# It ships with its own login/auth (first-run account setup on the login page).
# Installed from the published npm package (latest, unpinned); the package
# includes a prebuilt server, so there is no client/server build step here.
npm install -g "${NPM_QUIET[@]}" @cloudcli-ai/cloudcli \
|| echo " [WARN] CloudCLI UI install failed (needs Node 22+ and build tools for node-pty)"
# Self-heal in case the native build was still skipped (better-sqlite3 binding
# missing) — rebuild in place so the service can bind :3001. Gated by the
# allowlist set right after the npm self-update above.
CCUI_PKG="$(npm root -g 2>/dev/null)/@cloudcli-ai/cloudcli"
if [ -d "$CCUI_PKG/node_modules/better-sqlite3" ] && ! ls "$CCUI_PKG/node_modules/better-sqlite3/build/Release/"*.node >/dev/null 2>&1; then
echo " rebuilding CloudCLI native modules (better-sqlite3/node-pty/bcrypt)"
( cd "$CCUI_PKG" && npm rebuild >/dev/null 2>&1 ) || echo " [WARN] CloudCLI native rebuild failed"
fi
CCUI_BIN="$(command -v cloudcli || echo /usr/bin/cloudcli)"
mkdir -p /root/.cloudcli
echo " CloudCLI UI installed: $("$CCUI_BIN" version 2>/dev/null || echo 'version unknown')"
# Browser feature runtime. CloudCLI detects Playwright via require('playwright')
# from its GLOBAL package dir, but its in-app "Install Runtime" button installs
# into the server's cwd (/project) — which require() can't resolve, so the button
# silently never takes effect. Fix: install Playwright GLOBALLY (resolvable) plus
# its Chromium using the ubuntu24.04-x64 fallback build (26.04 isn't officially
# supported by Playwright yet), so the Browser tab is ready with no button click.
echo ">>> Installing Playwright runtime for CloudCLI Browser feature..."
export PLAYWRIGHT_HOST_PLATFORM_OVERRIDE=ubuntu24.04-x64
npm install -g "${NPM_QUIET[@]}" playwright \
|| echo " [WARN] global Playwright install failed (CloudCLI Browser feature won't work)"
playwright install chromium \
|| echo " [WARN] Chromium download failed; retry later with: PLAYWRIGHT_HOST_PLATFORM_OVERRIDE=ubuntu24.04-x64 playwright install chromium"
unset PLAYWRIGHT_HOST_PLATFORM_OVERRIDE
# systemd unit so the UI survives reboots and restarts on failure.
cat > /etc/systemd/system/cloudcli.service << EOF
[Unit]
Description=CloudCLI UI (claudecodeui) - web front end for Claude Code
After=network-online.target docker.service
Wants=network-online.target
[Service]
Type=simple
User=root
Environment=NODE_ENV=production
# NODE_ENV=production makes child 'npm install' omit devDependencies, which
# breaks CloudCLI's in-app plugin installs (their 'npm run build' is 'tsc' and
# needs typescript/@types/node). Force npm to include dev deps so plugin builds
# succeed.
Environment=NPM_CONFIG_INCLUDE=dev
# CloudCLI's "Install Runtime" (Playwright/Chromium for the Browser feature)
# hard-fails on Ubuntu 26.04, which Playwright doesn't officially support yet
# (microsoft/playwright#40117). Same override we use for the webapp-testing
# skill: force the ubuntu24.04-x64 fallback build, which runs on 26.04's glibc.
Environment=PLAYWRIGHT_HOST_PLATFORM_OVERRIDE=ubuntu24.04-x64
Environment=HOME=/root
Environment=HOST=0.0.0.0
Environment=SERVER_PORT=3001
Environment=CLAUDE_CLI_PATH=/usr/local/bin/claude
Environment=DATABASE_PATH=/root/.cloudcli/auth.db
Environment=PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/root/.local/bin
WorkingDirectory=/project
ExecStart=${CCUI_BIN} start
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable cloudcli >/dev/null 2>&1 || true
systemctl start cloudcli \
|| echo " [WARN] cloudcli service failed to start; check 'journalctl -u cloudcli'"
echo ">>> Installing coordinated update tooling (agentic-update / agentic-doctor)..."
# Single coordinated update pass, modeled on homelabhero's hh-update + hh doctor:
# OS packages -> Claude Code -> CloudCLI UI -> one-shot Watchtower for any Docker
# containers -> restart UI -> health check -> log. Re-runnable on demand
# ('agentic-update'), no container recreation required. Everything tracks latest
# (unpinned); the post-update health check is the safety net, and results land
# in /var/log/agentic-update.log.
cat > /usr/local/bin/agentic-doctor << 'DOCTOR'
#!/usr/bin/env bash
# Health check for the Claude Code container. Exit 0 = healthy, non-zero = problems.
LOG_TAG="[doctor]"
rc=0
check() { if eval "$2" >/dev/null 2>&1; then echo " OK $1"; else echo " FAIL $1"; rc=1; fi; }
# note() is informational only — it never fails the health check. Used for
# user-actionable states (like "not logged in yet") that aren't container faults.
note() { if eval "$2" >/dev/null 2>&1; then echo " OK $1"; else echo " WARN $1"; fi; }
echo "$LOG_TAG $(date '+%Y-%m-%d %H:%M:%S %Z')"
check "claude binary present" "command -v claude"
check "claude reports a version" "claude --version"
note "claude logged in (creds on disk; run 'claude' /login if WARN)" "test -s /root/.claude/.credentials.json"
check "cloudcli service active" "systemctl is-active --quiet cloudcli"
check "UI listening on :3001" "ss -ltn | grep -q ':3001'"
check "docker daemon running" "systemctl is-active --quiet docker"
check "disk under 90% on /" "test $(df --output=pcent / | tail -1 | tr -dc 0-9) -lt 90"
if [ $rc -eq 0 ]; then echo "$LOG_TAG all checks passed"; else echo "$LOG_TAG one or more checks FAILED"; fi
exit $rc
DOCTOR
chmod +x /usr/local/bin/agentic-doctor
cat > /usr/local/bin/agentic-update << 'UPDATE'
#!/usr/bin/env bash
# Coordinated update pass for the Claude Code container.
# Run on demand: agentic-update
# Runs weekly via /etc/cron.d/agentic-update. Log: /var/log/agentic-update.log
# Deliberately NOT 'set -e': one component failing must not abort the rest.
set -uo pipefail
export DEBIAN_FRONTEND=noninteractive
LOG=/var/log/agentic-update.log
exec >>"$LOG" 2>&1
echo "==================================================================="
echo ">>> agentic-update starting: $(date '+%Y-%m-%d %H:%M:%S %Z')"
echo ">>> [1/6] OS packages (includes Node.js patches within its LTS line)..."
apt-get update -qq && apt-get upgrade -y -qq && apt-get autoremove -y -qq && apt-get clean -qq
echo ">>> [2/6] npm (latest)..."
npm install -g --no-fund --no-audit --loglevel=error npm@latest || echo " [WARN] npm self-update failed"
# npm 12+ blocks dependencies' install scripts behind an allowlist (separate
# from ignore-scripts), so CloudCLI's native deps (better-sqlite3/node-pty/
# bcrypt) never compile -> cloudcli crash-loops and nothing binds :3001. Boxes
# deploy on npm 10 (builds fine) but this weekly run upgrades npm above, so the
# config MUST be set here -- AFTER npm@latest lands (it only persists to
# /root/.npmrc under npm 12+) and BEFORE the cloudcli reinstall/rebuild below.
npm config set dangerously-allow-all-scripts true 2>/dev/null || true
echo ">>> [3/6] Claude Code..."
curl -fsSL https://claude.ai/install.sh | bash || echo " [WARN] Claude Code update failed"
echo ">>> [4/6] CloudCLI UI (claudecodeui)..."
npm install -g --no-fund --no-audit --loglevel=error @cloudcli-ai/cloudcli@latest || echo " [WARN] CloudCLI UI update failed"
# Self-heal: if the native build was skipped (better-sqlite3 binding missing),
# rebuild in place before restart -- else cloudcli loops on "Could not locate
# the bindings file". npm rebuild is itself gated by the config set above.
CCUI_PKG="$(npm root -g 2>/dev/null)/@cloudcli-ai/cloudcli"
if [ -d "$CCUI_PKG/node_modules/better-sqlite3" ] && ! ls "$CCUI_PKG/node_modules/better-sqlite3/build/Release/"*.node >/dev/null 2>&1; then
echo " rebuilding CloudCLI native modules (better-sqlite3/node-pty/bcrypt)"
( cd "$CCUI_PKG" && npm rebuild >/dev/null 2>&1 ) || echo " [WARN] CloudCLI native rebuild failed"
fi
systemctl restart cloudcli || echo " [WARN] cloudcli restart failed"
echo ">>> [5/6] Docker images (one-shot Watchtower)..."
if command -v docker >/dev/null 2>&1 && [ -n "$(docker ps -q 2>/dev/null)" ]; then
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
containrrr/watchtower --run-once --cleanup \
|| echo " [WARN] Watchtower one-shot run failed"
else
echo " (no running containers; skipped)"
fi
echo ">>> [6/6] Health check..."
if agentic-doctor; then
echo ">>> agentic-update finished OK: $(date '+%Y-%m-%d %H:%M:%S %Z')"
else
echo ">>> agentic-update finished WITH HEALTH-CHECK FAILURES: $(date '+%Y-%m-%d %H:%M:%S %Z')"
fi
echo ""
UPDATE
chmod +x /usr/local/bin/agentic-update
cat > /etc/cron.d/agentic-update << 'CRON'
# Weekly coordinated update - Sunday 4:00 AM (Europe/Berlin, container local time)
# (OS + Claude Code + CloudCLI UI + container images, then a health check)
SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin
0 4 * * 0 root /usr/local/bin/agentic-update
CRON
chmod 0644 /etc/cron.d/agentic-update
cat > /etc/logrotate.d/agentic-update << 'LOGROTATE'
/var/log/agentic-update.log {
monthly
rotate 3
compress
missingok
notifempty
}
LOGROTATE
echo ">>> Cleaning up..."
apt-get autoremove -y -qq
apt-get clean -qq
rm -rf /var/lib/apt/lists/*
echo ""
echo "╔══════════════════════════════════════════════════╗"
echo "║ Provisioning Complete! ║"
echo "╚══════════════════════════════════════════════════╝"
PROVISION_EOF
chmod +x /tmp/provision-${CT_ID}.sh
pct push "$CT_ID" /tmp/provision-${CT_ID}.sh /tmp/provision.sh
pct exec "$CT_ID" -- chmod +x /tmp/provision.sh
pct exec "$CT_ID" -- /tmp/provision.sh
rm -f /tmp/provision-${CT_ID}.sh
}
# ── Configure Git Identity (optional) ──────────────────────────────────────
configure_git_identity() {
if [[ -n "${GIT_NAME:-}" ]]; then
info "Setting git user.name..."
pct exec "$CT_ID" -- git config --global user.name "$GIT_NAME" \
|| warn "Could not set git user.name"
fi
if [[ -n "${GIT_EMAIL:-}" ]]; then
info "Setting git user.email..."
pct exec "$CT_ID" -- git config --global user.email "$GIT_EMAIL" \
|| warn "Could not set git user.email"
fi
}
# ── Write Proxmox Notes ─────────────────────────────────────────────────────
# Drops a Markdown "card" into the container's Notes panel in the Proxmox UI
# (rendered as Markdown on PVE 7+). Uses placeholders so the heredoc can stay
# single-quoted (no accidental expansion of backticks/$ in the Markdown).
write_notes() {
local ct_ip
ct_ip=$(pct exec "$CT_ID" -- hostname -I 2>/dev/null | awk '{print $1}')
ct_ip="${ct_ip:-<container-ip>}"
local notes
notes=$(cat <<'EOF'
# 🤖 Claude Code Container
**Web UI (CloudCLI UI):** http://__IP__:3001 — _create a login on first visit_
**SSH:** `ssh root@__IP__` | **Console:** `pct enter __CTID__`
## Start Claude Code
Log in, then run `claude` (the shell auto-cd's to `/project`).
## Update & health
- `agentic-update` — one coordinated pass: OS → Claude Code → Web UI → container images → health check
- `agentic-doctor` — run the health check on its own
- Auto-runs weekly (Sunday 4 AM, Europe/Berlin). Log: `/var/log/agentic-update.log`
## Service & config
- `systemctl status cloudcli` — the Web UI service (`journalctl -u cloudcli` for logs)
- Permissions: **auto mode** + secret deny-floor — `/root/.claude/settings.json`
---
_IP above is the address at deploy time; on DHCP it may change (check with `pct exec __CTID__ -- hostname -I`)._
EOF
)
notes=${notes//__IP__/$ct_ip}
notes=${notes//__CTID__/$CT_ID}
if pct set "$CT_ID" --description "$notes" >/dev/null 2>&1; then
success "Wrote container notes to the Proxmox UI."
else
warn "Could not set container notes (non-fatal)."
fi
}
# ── Print Summary ─────────────────────────────────────────────────────────
print_summary() {
local ct_ip
ct_ip=$(pct exec "$CT_ID" -- hostname -I 2>/dev/null | awk '{print $1}')
echo ""
echo -e "${GREEN}${BOLD}╔══════════════════════════════════════════════════╗${NC}"
echo -e "${GREEN}${BOLD}║ Claude Code LXC Ready! ║${NC}"
echo -e "${GREEN}${BOLD}╚══════════════════════════════════════════════════╝${NC}"
echo ""
echo -e " ${BOLD}Container:${NC} $CT_ID ($CT_HOSTNAME)"
echo -e " ${BOLD}IP:${NC} ${ct_ip:-pending (DHCP)}"
echo -e " ${BOLD}Resources:${NC} ${CT_CORES} CPU / $(( CT_RAM / 1024 )) GB RAM / ${CT_DISK} GB disk"
echo -e " ${BOLD}Storage:${NC} $CT_STORAGE"
echo -e " ${BOLD}Timezone:${NC} Europe/Berlin"
echo ""
echo -e " ${BOLD}Connect:${NC}"
echo -e " Console: ${CYAN}pct enter $CT_ID${NC}"
[[ -n "${ct_ip:-}" ]] && echo -e " SSH: ${CYAN}ssh root@${ct_ip}${NC}"
[[ -n "${ct_ip:-}" ]] && echo -e " Web UI: ${CYAN}http://${ct_ip}:3001${NC} (CloudCLI UI — create a login on first visit)"
echo ""
echo -e " ${BOLD}Start Claude Code:${NC}"
echo -e " ${CYAN}claude${NC} (shell auto-cd's to /project on login)"
echo ""
echo -e " ${BOLD}Verify plugins:${NC} ${CYAN}claude plugin list${NC}"
echo ""
echo -e " ${BOLD}Installed:${NC}"
echo " • Claude Code (native) • Node.js (latest LTS) + npm/pnpm"
echo " • Python 3 + pip + venv • Go (latest)"
echo " • Rust (via rustup) • Docker + Compose"
echo " • Git, ripgrep, fzf, fd • Build essentials"
echo " • PostgreSQL & Redis CLI • CloudCLI UI web front end (port 3001)"
echo ""
echo -e " ${BOLD}Permissions:${NC} Auto mode (classifier-guarded) + deny floor for secrets"
echo -e " ${BOLD}Config:${NC} ~/.claude/settings.json"
echo -e " ${BOLD}Features:${NC} Agent teams (experimental), adaptive thinking, 64k output tokens"
echo -e " ${BOLD}Remote Control:${NC} enable per-session with ${CYAN}/rc${NC} or all sessions via ${CYAN}/config${NC} (Pro/Max)"
echo -e " ${BOLD}Plugins:${NC} frontend-design, code-review, commit-commands, security-guidance,"
echo -e " context7, superpowers, + LSP (typescript/pyright/gopls/rust-analyzer)"
echo -e " ${BOLD}Skills:${NC} webapp-testing (local, Playwright)"
echo -e " ${BOLD}Updates:${NC} Sonntags 4 Uhr (Europe/Berlin) — coordinated (OS + Claude + UI + containers)"
echo -e " then a health check. Run anytime: ${CYAN}agentic-update${NC} / ${CYAN}agentic-doctor${NC}"
echo ""
}
# ── Main ──────────────────────────────────────────────────────────────────
main() {
header
preflight
get_config
get_template
create_container
start_container
provision_container
configure_git_identity
write_notes
print_summary
}
main "$@"