Skip to main content

VPS deployment

What runs on the VPS: brain serve — the proxy (/v1/*, client-key auth) and the board — under systemd, behind Caddy with automatic HTTPS. The tools' own logs stay on the laptop; everything that goes through the proxy is recorded on the server.

:::note Live since 2026-09-20 Contabo VPS (x86_64, 4 vCPU, 8 GB, Ubuntu 24.04), hostname brain.<ip-with-dashes>.sslip.io (no domain needed — hostname vs domain). IP, hostname and board credentials are in the git-ignored deploy/server.local.env. Alternative: on Hetzner (the original plan, CAX11 ARM) paste deploy/cloud-init.yaml as the server's cloud config and skip step 1; use the aarch64 binary. :::

1. Prepare the server (over SSH, as root)​

Contabo has no cloud-init in the order flow, so the steps of deploy/cloud-init.yaml are run by hand; order the server with your SSH public key.

apt-get update && apt-get -y upgrade
apt-get install -y ufw fail2ban unattended-upgrades git curl ca-certificates \
debian-keyring debian-archive-keyring apt-transport-https
useradd --system --home /opt/llm_brain --shell /usr/sbin/nologin brain
mkdir -p /opt/llm_brain/data /opt/llm_brain/config
# SSH keys only. Contabo's 50-cloud-init.conf sets PasswordAuthentication yes
# and the first file wins, so the hardening file must sort before it.
printf 'PasswordAuthentication no\nPermitRootLogin prohibit-password\nKbdInteractiveAuthentication no\n' \
> /etc/ssh/sshd_config.d/00-hardening.conf && systemctl restart ssh
# Caddy (official repo): the two curl lines from deploy/cloud-init.yaml, then
apt-get update && apt-get install -y caddy
ufw default deny incoming && ufw default allow outgoing
ufw allow OpenSSH && ufw allow 80/tcp && ufw allow 443/tcp && ufw --force enable
systemctl enable --now fail2ban

Plus the unattended-upgrades file from cloud-init.yaml (/etc/apt/apt.conf.d/20auto-upgrades).

2. Install brain​

Binaries are built by CI for every v* tag (.github/workflows/release.yml): brain-x86_64-unknown-linux-gnu and brain-aarch64-unknown-linux-gnu, each with a .sha256.

cd /opt/llm_brain
B=brain-x86_64-unknown-linux-gnu
curl -fsSLO https://github.com/pjcau/llm_brain/releases/latest/download/$B
curl -fsSLO https://github.com/pjcau/llm_brain/releases/latest/download/$B.sha256
sha256sum -c $B.sha256 && install -m 0755 $B brain
git clone --depth 1 https://github.com/pjcau/llm_brain /tmp/src && cp -r /tmp/src/config /opt/llm_brain/

Secrets: /opt/llm_brain/.env (mode 0600, owner brain) with the OPENROUTER_KEY_* lines, BRAIN_HOME=/opt/llm_brain, BRAIN_DB=/opt/llm_brain/data/brain.db. Copied with scp from the laptop; never through git or chat (secrets).

cp /tmp/src/deploy/brain.service /etc/systemd/system/ # brain serve --bind 127.0.0.1:8080 --refresh 600
cp /tmp/src/deploy/Caddyfile /etc/caddy/Caddyfile # then edit: hostname, bcrypt hash, API routes (below)
chown -R brain:brain /opt/llm_brain && chmod 600 /opt/llm_brain/.env
systemctl daemon-reload && systemctl enable --now brain && systemctl reload caddy
curl -s https://brain.<ip-with-dashes>.sslip.io/health # → ok

The Caddyfile on the server sends /v1/*, /api/hello and /health straight to brain (the proxy's own key auth applies, flush_interval -1 for SSE) and keeps basic auth on everything else (the board; caddy hash-password for the hash, credentials in server.local.env).

Client keys are issued on the server (Phase 1):

cd /opt/llm_brain && sudo -u brain env BRAIN_HOME=/opt/llm_brain BRAIN_DB=/opt/llm_brain/data/brain.db \
./brain keys create --profile dev --name laptop

Update​

New release, or changed config/*.yaml (read at start only):

# binary
cd /opt/llm_brain && B=brain-x86_64-unknown-linux-gnu
curl -fsSLO https://github.com/pjcau/llm_brain/releases/latest/download/$B
curl -fsSLO https://github.com/pjcau/llm_brain/releases/latest/download/$B.sha256
sha256sum -c $B.sha256 && systemctl stop brain && install -m 0755 $B brain && systemctl start brain
# config, from the laptop
scp config/profiles.yaml config/tiers.yaml root@<vps>:/opt/llm_brain/config/ && ssh root@<vps> systemctl restart brain

After changing a profile's daily_limit_usd, also run brain upstream sync (laptop, management key) so the OpenRouter key's own limit follows.

What is exposed​

RouteAuth
/v1/*client key, checked by brain
/api/hellonone (Claude Code's connectivity probe, 204)
/healthopen
/, /api/summary (board)Caddy basic auth

Caddy on 80/443, everything else closed by ufw; brain listens on 127.0.0.1:8080 only.

Backups: not set up​

Litestream is not installed: the .deb asset name changed upstream and the install was deferred. The SQLite file (data/brain.db: client keys, requests) is currently not replicated. The intended setup is in deploy/litestream.yml (S3-compatible bucket, credentials in /etc/default/litestream, restore with litestream restore -o /opt/llm_brain/data/brain.db s3://llm-brain-backup/brain.db).