Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 33 additions & 27 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# Dev Boxer

Set up an Ubuntu 24.04 VPS as a remote Claude Code development environment — Matron chat stack, desktop GUI, security hardening, and your choice of Cloudflare Tunnel or plain IP + self-signed TLS — in one command.
Set up your first Matron on a fresh Ubuntu 24.04 VPS: browser app, journal, bridge, file viewer and your choice of Claude Code or Codex. The guided starter path uses trusted HTTPS for a public IPv4 address, without a domain.

**Start with the [four-step guide](https://matron.chat/getting-started/).** Development desktops, Cloudflare and other advanced settings remain available.

## Part of the Matron ecosystem

Expand All @@ -16,25 +18,18 @@ Set up an Ubuntu 24.04 VPS as a remote Claude Code development environment — M

## What you get

- **Claude Code** on a persistent remote server — run long-lived agents without keeping your laptop open
- **Matron chat** — talk to Claude Code from the Matron apps (iOS/desktop/web); this box runs [matron-bridge](https://github.com/Matronhq/matron-bridge) and, in bundled mode, its own [matron-journal](https://github.com/Matronhq/matron-journal) sync server
- **File viewer** — `viewer.<domain>` (or `https://<ip>:8444` in IP mode) serves files and artifacts the agent shares, via `matron-viewer.service`
- **Two exposure modes** — your own domain via Cloudflare Tunnel, or no domain at all via IP + self-signed TLS (see [docs/exposure-modes.md](docs/exposure-modes.md))
- **Desktop GUI (optional)** — XFCE4 + XRDP + VS Code, installed on demand with `~/setup-desktop`; reachable only through an SSH tunnel, no open ports
- **Security hardening** — SSH key-only auth, custom port, UFW, fail2ban, unattended-upgrades
- **Cloudflare Tunnel** — zero-trust HTTPS access with optional SSO
- **Dev tools** — Docker, Node.js 22, Git, Python, uv, GitHub CLI, lazydocker, Chrome, Firefox
The starter profile installs one AI CLI, the browser app, journal, bridge and file viewer. It sets up SSH keys on port 22, a firewall, automatic OS updates and automatic HTTPS renewal. It omits desktops, Docker, browsers, coding plugins and local voice transcription. Email and other service integrations are not included.

Advanced developer setup retains those optional tools and the Cloudflare/self-signed exposure modes. See [exposure modes](docs/exposure-modes.md).

## Prerequisites

- A fresh **Ubuntu 24.04** machine with sudo access. Anywhere works:
- **Cheap VPS** (Hetzner / DigitalOcean / etc., ~$5/mo) — fastest path
- **Spare desktop or laptop** at home (no monthly fee — see [No VPS?](#no-vps-use-spare-hardware))
- **VM** (multipass, OrbStack, Hyper-V) on top of macOS or Windows
- **amd64 or arm64** (on arm64, Chrome is skipped — no arm64 build)
- **Cloudflare mode only:** a Cloudflare account with a domain, a zone DNS API token, and optionally a one-time account setup token
- **IP mode:** nothing extra — no domain needed
- SSH key pair
- A **fresh Ubuntu 24.04 x86-64 VPS** with root or sudo access, initially 2 CPUs / 4 GB RAM / 40 GB disk. This size has been exercised in a resource-limited container; a public VPS acceptance run is still required before release.
- A stable public **IPv4** address and inbound TCP ports **22, 80, 443, 8444**. Port 80 must remain reachable for certificate renewal.
- Your SSH **public** key, with its private key kept on your computer.
- A Claude Code or Codex account with CLI access. AI usage and the VPS are separate costs. The [buying guide](https://matron.chat/getting-started/#vps) compares budget providers, contract terms and setup fees.

For spare hardware, ARM64 or Cloudflare, choose Advanced and read the relevant guide below.

## Quick start

Expand All @@ -48,16 +43,27 @@ curl -fsSL https://raw.githubusercontent.com/matronhq/dev-boxer/main/install.sh

### What happens next

The installer clones Dev Boxer to `/opt/dev-boxer`, prompts for the minimum required settings, writes `config.yml` and gitignored `secrets.yml`, then runs the full setup. Later `sudo ./setup.rb ...` reruns from that checkout, which will automatically fast-forward before continuing; set `DEV_BOXER_SKIP_AUTO_UPDATE=1` to disable that.
The installer clones Dev Boxer to `/opt/dev-boxer`, saves your settings and private `secrets.yml`, then provisions the server. Choose:

1. **Set up my first Matron** — choose a sign-in name, an AI provider, the public IP and an SSH public key. Accept the certificate terms, then follow the AI sign-in link. Open the printed Matron URL and use the password or phone QR shown only in your terminal.
2. **Add a box to my Matron** — enter the existing HTTPS address, approve the pairing code in Matron, and sign into the AI provider on this box.
3. **Advanced developer setup** — the original wizard, including journal, domain/Cloudflare, desktop and developer configuration. It is also available with `sudo ./setup.rb --advanced` on a new checkout.

Start a chat in the prepared `work` folder and give it a small design task:

> Create three visual directions for a simple fitness tracker: calm, bold and playful. Show colors, typography and a sample screen for each in one self-contained HTML page. Save it as design-directions.html, share a preview link, and put a notice in For you linking to the finished page.

Open the result from its **For you** notice and check all three directions. This checks the agent's work, file preview and result notification together. Setup's service checks do not substitute for this test.

Later SSH connections use `ssh dev@YOUR_VPS_IP` on the starter path. Test that login before closing the initial terminal. The agent's `dev` account has passwordless sudo and can administer the server.

Repeating the install command uses the saved settings and installed component revisions. Use `sudo matron status`, `sudo matron login`, or `sudo matron link` for recovery. Make a backup before the explicit `sudo matron update --backup PATH` action. See [backup, restore and recovery](docs/starter-recovery.md).

It asks for, in order:
The starter's component revisions and AI CLI versions are pinned in `components.json`. Ubuntu security updates remain automatic. The original advanced profile keeps its existing component update behavior; installer auto-pull is now opt-in with `DEV_BOXER_AUTO_UPDATE=1`.

1. Linux username, SSH public key, and SSH port
2. Journal location: bundled on this box (plus a journal username), or external (plus its `wss://` URL and an optional agent-token file)
3. Exposure mode: `cloudflare` or `ip`. Cloudflare mode asks for the base domain, whether Dev Boxer manages DNS (plus a zone DNS token), and whether it creates the tunnel and Access app (plus allowed emails/domains and a one-time account setup token). IP mode asks for the public IP — leave blank to auto-detect.
4. Claude experience level: beginner, intermediate, or advanced — tunes the generated CLAUDE.md
### Advanced developer settings

It derives `dev.<domain>`, `chat.<domain>`, `viewer.<domain>`, and `hello.<domain>` automatically in Cloudflare mode (`chat.` only in bundled-journal mode).
The advanced wizard asks for a Linux username and SSH port, journal mode, exposure mode and Claude experience level. In Cloudflare mode it derives `dev`, `chat`, `viewer` and `hello` hostnames. The rest of this README describes those advanced options.

**Private repos:** the wizard doesn't ask for a GitHub token. To let the dev user clone private repos without an interactive login, add a fine-grained PAT to `secrets.yml` before running setup:

Expand Down Expand Up @@ -129,7 +135,7 @@ See [docs/cloudflare-access.md](docs/cloudflare-access.md) for a manual walkthro

## Modules

Setup runs 10 modules by default, in order; the optional desktop module (03) brings the total to 11.
The starter runs only security, users, prerequisites, the chosen AI CLI, Matron, exposure and onboarding. Advanced setup uses the developer modules below.

| # | Module | What it does |
|---|--------|--------------|
Expand All @@ -141,7 +147,7 @@ Setup runs 10 modules by default, in order; the optional desktop module (03) bri
| 06 | `browsers` | Chrome, Firefox, Xvfb |
| 07 | `claude` | Claude Code CLI + plugins + Chrome DevTools MCP + partial-clone git guard hook |
| 08 | `matron` | matron-journal (bundled mode) + matron-bridge, agent enrollment, optional local voice-note transcription (whisper.cpp; set `bridge.voice_notes.enabled: false` when your journal transcribes voice notes, and setup removes it; see below) |
| 09 | `exposure` | Cloudflare Tunnel or IP + self-signed TLS, per `exposure.mode` |
| 09 | `exposure` | Trusted IP HTTPS for the starter; Cloudflare Tunnel or self-signed IP TLS in Advanced |
| 10 | `desktop-apps` | lazydocker, CLAUDE.md, MOTD, optional desktop helper |
| 11 | `hello-world` | `localhost:9820` tunnel smoke-test service |

Expand All @@ -167,7 +173,7 @@ To add more MCP servers to the box's Claude Code, see [docs/adding-mcp-servers.m

## Multiple boxes, one journal

Point more than one Dev Boxer install at the same matron-journal by setting `journal.mode: external` and `journal.url` (its `wss://` address) on every box after the first. Provide a pre-minted `journal.token_file` (mint one on the journal host: `matron-admin agent add <user> <name>`), or leave it unset to pair from the Matron app during setup instead. See [docs/exposure-modes.md](docs/exposure-modes.md) for how to reach that journal from the outside.
For the starter path, choose **Add a box to my Matron** on the next VPS. In advanced configuration, point more than one install at the same matron-journal by setting `journal.mode: external` and `journal.url` (its `wss://` address) on every box after the first. Provide a pre-minted `journal.token_file` (mint one on the journal host: `matron-admin agent add <user> <name>`), or leave it unset to pair from the Matron app during setup instead. See [docs/exposure-modes.md](docs/exposure-modes.md) for how to reach that journal from the outside.

## Re-enrolling an agent

Expand Down
163 changes: 163 additions & 0 deletions bin/matron
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
#!/usr/bin/env ruby
# Administrative recovery commands. Credentials are only ever shown on the
# operator's controlling terminal by the explicit `link` action.
require "optparse"
require "shellwords"
require "json"
require "fileutils"
require "tmpdir"
require "time"
require "open3"

ROOT = File.expand_path("..", __dir__)
$LOAD_PATH.unshift(File.join(ROOT, "lib"))
require "dev_boxer"
require "dev_boxer/ai_reply"

config_path = File.join(ROOT, "config.yml")
OptionParser.new { |o| o.on("--config PATH") { |v| config_path = File.expand_path(v) } }.order!
action = ARGV.shift || "status"
abort "Run this command with sudo." unless Process.uid.zero?
config = DevBoxer::Config.load(config_path)
username = config.user.name
provider = config.agent&.provider || "claude"
abort "Invalid AI provider in config." unless %w[claude codex].include?(provider)
shell = DevBoxer::Shell.new
log = DevBoxer::Log.new
exposure = DevBoxer::Exposure.for(config: config, shell: shell, log: log)
bundled = (config.journal&.mode || "bundled") == "bundled"
origin = DevBoxer::JournalEnrollment.https_base(exposure.journal_public_url)
services = %w[matron-bridge matron-viewer nginx]
services << "matron-journal" if bundled
secrets_path = DevBoxer::Config.secrets_path_for(config_path)
state_paths = [config_path, secrets_path, "/etc/matron", "/home/#{username}/matron-bridge/.env", "/home/#{username}/matron-bridge/.journal-token", "/home/#{username}/matron-bridge/mcp-config.local.json"]
state_paths << "/opt/matron-journal/data" if bundled
restore_args = [username, bundled ? "bundled" : "external", *state_paths]
restore_tool = File.join(ROOT, "bin/restore-state.py")

begin
case action
when "status"
failed = []
services.each do |service|
ready = shell.service_active?(service)
puts "#{ready ? 'OK' : 'CHECK'} #{service}"
failed << service unless ready
end
if bundled
ready = shell.sh("curl --fail --silent --show-error --max-time 15 #{Shellwords.escape(origin + '/app/')} >/dev/null")
puts "#{ready ? 'OK' : 'CHECK'} browser: #{origin}/app/"
failed << "HTTPS browser" unless ready
end
viewer_ready = shell.sh("curl --fail --silent --show-error --max-time 15 #{Shellwords.escape(exposure.viewer_base_url + '/health')} >/dev/null")
puts "#{viewer_ready ? 'OK' : 'CHECK'} HTTPS file viewer"
failed << "HTTPS file viewer" unless viewer_ready
if config.exposure.mode == "public-ip"
ready = shell.sh("openssl x509 -in /etc/letsencrypt/live/matron-ip/cert.pem -noout -checkend 172800 >/dev/null 2>&1")
timer = shell.service_active?("matron-certificate.timer")
renewed = shell.sh("systemctl is-failed --quiet matron-certificate.service")
puts "#{ready && timer && !renewed ? 'OK' : 'CHECK'} certificate and automatic renewal"
failed << "certificate renewal (sudo journalctl -u matron-certificate.service)" unless ready && timer && !renewed
end
abort "Needs attention: #{failed.join(', ')}. Re-run setup or see https://matron.chat/getting-started/#help" unless failed.empty?
puts "Services respond. Finish sign-in and check your first chat in Matron."
when "login"
status_cmd = provider == "codex" ? "codex login status" : "claude auth status"
unless shell.sh("su - #{Shellwords.escape(username)} -c #{Shellwords.escape(status_cmd)} >/dev/null 2>&1")
puts "Open the provider's sign-in link on your own computer. Enter any code only in its sign-in flow."
login_cmd = provider == "codex" ? "codex login --device-auth" : "claude auth login"
shell.run_as_user_interactive(username, login_cmd)
end
puts "Checking a short AI reply (uses a small amount of your account allowance)..."
prompt = "Reply with exactly: Matron is ready"
cmd = provider == "codex" ? "codex exec --skip-git-repo-check --sandbox read-only --json #{Shellwords.escape(prompt)}" : "claude -p --tools '' --output-format json #{Shellwords.escape(prompt)}"
result = shell.run_as_user(username, "cd ~/work && #{cmd}")
abort "Sign-in completed, but no successful reply arrived. Try sudo matron login again." unless DevBoxer::AiReply.ready?(provider, result)
puts "AI reply received. Open Matron and try your first chat."
when "link"
unless bundled
puts "Sign into your existing Matron at #{origin}/app/; this box uses that account."
exit
end
# Do not send passwords or sign-in QR codes into piped logs.
File.open("/dev/tty", "w") do |tty|
saved = YAML.safe_load_file(secrets_path)
journal_user = config.journal&.username || username
tty.puts "Open #{origin}/app/"
tty.puts "Username: #{journal_user}"
tty.puts "Password: #{saved.dig('journal', 'user_password')}"
tty.puts "For a phone, scan this short-lived sign-in QR in Matron:"
cmd = DevBoxer::JournalEnrollment.matron_admin_command("link-code #{Shellwords.escape(journal_user)} --server-url #{Shellwords.escape(origin)}")
tty.puts shell.sh!(cmd)
tty.puts "Save the password in your password manager. Never paste it into chat."
end
when "enroll"
exec "ruby", File.join(ROOT, "bin/enroll"), "--config", config_path
when "resume"
exec "ruby", File.join(ROOT, "setup.rb"), "--config", config_path, *ARGV
when "backup"
destination = File.expand_path(ARGV.shift || "/var/backups/matron-#{Time.now.utc.strftime('%Y%m%d-%H%M%S')}.tar.gz")
abort "Destination already exists" if File.exist?(destination)
FileUtils.mkdir_p(File.dirname(destination), mode: 0o700)
old_umask = File.umask(0o077)
stopped = []
begin
%w[matron-bridge matron-viewer matron-journal].each do |unit|
next if unit == "matron-journal" && !bundled
next unless shell.service_active?(unit)
shell.systemctl(:stop, unit)
stopped << unit
end
Dir.mktmpdir("matron-backup") do |dir|
files = state_paths.select { |p| File.exist?(p) }
revision = shell.sh!("git -C #{Shellwords.escape(ROOT)} rev-parse HEAD").strip
manifest = {"format" => 1, "user" => username, "bundled" => bundled, "files" => files, "installer_revision" => revision, "components" => JSON.parse(File.read(File.join(ROOT, "components.json")))}
File.write(File.join(dir, "manifest.json"), JSON.pretty_generate(manifest))
shell.sh!("tar -czf #{Shellwords.escape(destination)} -C #{Shellwords.escape(dir)} manifest.json -C / #{files.map { |p| Shellwords.escape(p.delete_prefix('/')) }.join(' ')}")
end
puts "Backup saved to #{destination} (owner-only). It contains credentials: store it privately off this VPS."
puts "Work folders and AI provider sessions are excluded. Back up your work separately; sign in again after restoring."
ensure
stopped.reverse_each { |unit| shell.systemctl(:start, unit) }
File.umask(old_umask)
end
when "restore"
archive = File.expand_path(ARGV.shift.to_s)
abort "Use sudo matron restore BACKUP --replace-state. This replaces this installation's Matron data." unless ARGV.shift == "--replace-state" && File.file?(archive)
abort "Backup validation failed; nothing restored." unless system("python3", restore_tool, "--check", archive, *restore_args)
# On any failure below, leave services stopped until a good restore.
%w[matron-bridge matron-viewer matron-journal].each do |unit|
next if unit == "matron-journal" && !bundled
next unless shell.service_active?(unit)
shell.systemctl(:stop, unit)
end
args = ["python3", restore_tool, archive, *restore_args]
abort "Restore refused; state may be incomplete. Keep services stopped and restore a known-good backup." unless system(*args)
shell.sh!("chown -R matron:matron /opt/matron-journal/data") if bundled
%w[.env .journal-token mcp-config.local.json].each do |name|
path = "/home/#{username}/matron-bridge/#{name}"
shell.sh!("chown #{Shellwords.escape(username)}:#{Shellwords.escape(username)} #{Shellwords.escape(path)}") if File.exist?(path)
end
shell.sh!("chown #{username}:#{username} /etc/matron/agent-token") if File.exist?("/etc/matron/agent-token")
shell.sh!("chown root:matron /etc/matron/journal.env") if bundled && File.exist?("/etc/matron/journal.env")
# Keep the new server's reachability settings if restoring onto a replacement.
DevBoxer::Config.merge_into_file(config_path, {"exposure" => config.to_h.fetch("exposure"), "ssh" => config.to_h.fetch("ssh"), "user" => {"ssh_public_key" => config.user.ssh_public_key}})
puts "State restored. Re-applying this installation's HTTPS settings and services."
exec "ruby", File.join(ROOT, "setup.rb"), "--config", config_path
when "update"
abort "Run sudo matron backup first. Then use: sudo matron update --backup /path/to/backup.tar.gz" unless ARGV.shift == "--backup"
backup = File.expand_path(ARGV.shift.to_s)
abort "Backup file not found" unless File.file?(backup) && File.size(backup).positive?
abort "Backup validation failed; update cancelled." unless system("python3", restore_tool, "--check", backup, *restore_args)
shell.sh!("git -C #{Shellwords.escape(ROOT)} diff --quiet && git -C #{Shellwords.escape(ROOT)} diff --cached --quiet")
shell.sh!("git -C #{Shellwords.escape(ROOT)} pull --ff-only")
exec "ruby", File.join(ROOT, "setup.rb"), "--config", config_path
else
abort "Commands: status, login, link, enroll, resume, backup [path], restore PATH --replace-state, update --backup PATH."
end
rescue DevBoxer::Shell::Error => e
warn e.message
exit 1
rescue Errno::ENXIO
abort "Open an interactive SSH terminal to show the sign-in details."
end
Loading
Loading