Six fixes addressing operational correctness on a real Debian host:
1. Ownership after git operations: chown -R zopu:zopu on the checkout
after clone/update/rollback before running bun install/build as the
service user. .env kept at 0600 with explicit chmod after each
operation.
2. sudo replaced with runuser: minimal Debian does not include sudo.
runuser is part of util-linux (essential) and always available.
All three scripts (bootstrap, update, rollback) now use runuser -u.
3. cron.d entry fixed: /etc/cron.d format requires a username field.
Added ${SERVICE_USER} between the time fields and the command path.
4. Firewall: added explicit `ufw allow in on tailscale0` rule so the
deny-incoming default does not block Tailscale private-overlay
reachability. Removed inaccurate claim that direct private IP works
by default; documented that an explicit per-interface rule is needed.
5. SupplementaryGroups=docker added to zopu-agent.service: the Orb
sandbox runtime lives in the agent process, not just the daemon.
6. zopu-health.service: added Environment=ENV_FILE=__INSTALL_DIR__/.env
so health-check.sh sources the correct .env at custom install paths.
Verified Flue build output: bun run build:agents produces
packages/agents/dist/server.mjs (confirmed by running the build).
Agent unit ExecStart path is correct.
Validated: bash -n on all 6 shell scripts; mocked health-check smoke
against live RivetKit engine on port 6420; systemd unit structural
checks. No JS/TS/agent files changed.
332 lines
12 KiB
Markdown
332 lines
12 KiB
Markdown
# Zopu Single-Node Runtime Deployment
|
|
|
|
Deployment artifacts for the Zopu execution plane on a single Debian dedicated
|
|
server. Convex is already deployed as the control plane; this lane deploys only
|
|
the execution plane: the daemon (Bun/Effect + AgentOS/RivetKit), the Flue agent
|
|
service, Docker Engine for future Orb sandboxes, and supporting infrastructure.
|
|
|
|
## Architecture
|
|
|
|
```
|
|
┌─────────────────────────────────────────────────────────────┐
|
|
│ Debian Dedicated Server │
|
|
│ ~12 CPU cores · ~40 GB RAM · single-node │
|
|
│ │
|
|
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
|
│ │ zopu-daemon │ │ zopu-agent │ │ Docker │ │
|
|
│ │ (systemd) │ │ (systemd) │ │ Engine │ │
|
|
│ │ │ │ │ │ │ │
|
|
│ │ Effect daemon│ │ Flue Node │ │ Orb sandboxes│ │
|
|
│ │ + RivetKit │ │ server.mjs │ │ (future) │ │
|
|
│ │ in-process │ │ :3583 │ │ │ │
|
|
│ │ engine │ │ │ │ │ │
|
|
│ │ + native │ │ │ │ │ │
|
|
│ │ sidecar │ │ │ │ │ │
|
|
│ │ :6420 │ │ │ │ │ │
|
|
│ └──────┬───────┘ └──────┬───────┘ └──────────────┘ │
|
|
│ │ │ │
|
|
│ └────────┬───────────┘ │
|
|
│ │ │
|
|
│ ┌────────▼─────────┐ │
|
|
│ │ Convex (cloud) │ ← control plane (pre-deployed)│
|
|
│ └──────────────────┘ │
|
|
│ │
|
|
│ systemd timers: health-check (60s), docker-cleanup (daily) │
|
|
│ cron: disk-monitor (daily 06:00) │
|
|
│ ufw: deny-incoming, SSH + tailscale0 │
|
|
│ Tailscale: optional private overlay │
|
|
└─────────────────────────────────────────────────────────────┘
|
|
```
|
|
|
|
### Single-node RivetKit topology
|
|
|
|
The daemon calls `registry.start()` from `rivetkit`, which boots an
|
|
**in-process RivetKit engine** (envoy mode) backed by a **native Rust
|
|
sidecar** binary (`@rivet-dev/agentos-sidecar`, platform-resolved). The engine
|
|
listens on `RIVET_ENDPOINT` (default `http://localhost:6420`). The daemon then
|
|
calls `createClient(RIVET_ENDPOINT)` to connect back to its own in-process
|
|
engine for actor dispatch.
|
|
|
|
Evidence: RivetKit source `chunk-YDUQHING.js` line 4751 —
|
|
`DEFAULT_ENDPOINT = "http://localhost:6420"`. The `Registry.start()` method
|
|
calls `#startEnvoy()` → `runtime.serveRegistry()` for serverful mode (Mode A).
|
|
The `createClient()` function reads `RIVET_ENDPOINT` env or defaults to the
|
|
same `http://localhost:6420`.
|
|
|
|
**No separate Rivet Engine process is required.** The engine, actor envoy, and
|
|
sidecar all run inside the daemon process. A future multi-node deployment
|
|
would externalize the engine, but that is out of scope.
|
|
|
|
### Docker / Orb boundary
|
|
|
|
Docker Engine is installed and the `zopu` service user is in the `docker`
|
|
group. The daemon's systemd unit includes `SupplementaryGroups=docker`.
|
|
However, **no Docker-backed sandbox code is currently wired**. The Orb
|
|
sandbox lane contract has not landed; Docker access is provisioned now so the
|
|
boundary is ready. The current agent uses the in-process AgentOS VM (Wasm/V8)
|
|
sandbox, not Docker.
|
|
|
|
## Files
|
|
|
|
```
|
|
deploy/zopu-runtime/
|
|
├── bootstrap.sh # One-shot Debian installer
|
|
├── .env.template # Environment template (all groups documented)
|
|
├── README.md # This file (runbook)
|
|
├── systemd/
|
|
│ ├── zopu-daemon.service # Daemon systemd unit
|
|
│ ├── zopu-agent.service # Agent systemd unit
|
|
│ ├── zopu-health.service # Health check oneshot
|
|
│ ├── zopu-health.timer # Health check every 60s
|
|
│ ├── zopu-docker-cleanup.service
|
|
│ └── zopu-docker-cleanup.timer
|
|
├── scripts/
|
|
│ ├── health-check.sh # TCP/process health probes
|
|
│ ├── update.sh # Update to branch or commit
|
|
│ ├── rollback.sh # Roll back to previous commit
|
|
│ ├── docker-cleanup.sh # Prune stopped containers/images/networks
|
|
│ └── disk-monitor.sh # Disk usage alerting
|
|
└── caddy/
|
|
└── Caddyfile # Optional reverse proxy config (documentation)
|
|
```
|
|
|
|
## Fresh install
|
|
|
|
```bash
|
|
# 1. SSH into the fresh Debian 12 server as root.
|
|
|
|
# 2. Set environment overrides (optional):
|
|
export ZOPU_REPO_URL="ssh://git@git.openputer.com:2222/puter/zopu-code.git"
|
|
export ZOPU_REPO_BRANCH="dogfood/v0"
|
|
# export TAILSCALE_AUTHKEY="tskey-..."
|
|
# export TAILSCALE_HOSTNAME="zopu-runtime"
|
|
|
|
# 3. Run the bootstrap script:
|
|
bash bootstrap.sh
|
|
|
|
# 4. Edit .env with real values:
|
|
nano /opt/zopu/.env
|
|
|
|
# 5. Start services:
|
|
systemctl start zopu-daemon
|
|
sleep 3
|
|
systemctl start zopu-agent
|
|
|
|
# 6. Enable timers:
|
|
systemctl enable --now zopu-health.timer zopu-docker-cleanup.timer
|
|
|
|
# 7. Verify:
|
|
/opt/zopu/deploy/zopu-runtime/scripts/health-check.sh
|
|
```
|
|
|
|
## Start / stop / restart
|
|
|
|
```bash
|
|
# Start both services
|
|
systemctl start zopu-daemon zopu-agent
|
|
|
|
# Stop both services
|
|
systemctl stop zopu-agent zopu-daemon
|
|
|
|
# Restart (daemon first — it owns the RivetKit engine)
|
|
systemctl restart zopu-daemon && sleep 3 && systemctl restart zopu-agent
|
|
|
|
# Enable on boot
|
|
systemctl enable zopu-daemon zopu-agent
|
|
|
|
# Disable on boot
|
|
systemctl disable zopu-daemon zopu-agent
|
|
```
|
|
|
|
## Log inspection
|
|
|
|
All service logs go to journald with `SyslogIdentifier` tags.
|
|
|
|
```bash
|
|
# Daemon logs (live follow)
|
|
journalctl -u zopu-daemon -f
|
|
|
|
# Agent logs (live follow)
|
|
journalctl -u zopu-agent -f
|
|
|
|
# Last 100 lines of daemon
|
|
journalctl -u zopu-daemon -n 100
|
|
|
|
# Logs since boot
|
|
journalctl -u zopu-daemon -b
|
|
|
|
# Health check timer logs
|
|
journalctl -u zopu-health.service -n 50
|
|
|
|
# Docker cleanup logs
|
|
journalctl -u zopu-docker-cleanup.service -n 50
|
|
|
|
# Disk monitor logs (cron → file)
|
|
tail -100 /var/log/zopu/disk-monitor.log
|
|
|
|
# All Zopu syslog identifiers
|
|
journalctl -t zopu-daemon -t zopu-agent --since "1 hour ago"
|
|
```
|
|
|
|
## Health checks
|
|
|
|
```bash
|
|
# Manual health check (prints all probes)
|
|
/opt/zopu/deploy/zopu-runtime/scripts/health-check.sh
|
|
|
|
# Quiet mode (exit code only)
|
|
/opt/zopu/deploy/zopu-runtime/scripts/health-check.sh --quiet
|
|
|
|
# Check systemd timer is running
|
|
systemctl status zopu-health.timer
|
|
systemctl list-timers zopu-health.timer
|
|
```
|
|
|
|
The health check probes:
|
|
1. `zopu-daemon` systemd unit is active
|
|
2. `zopu-agent` systemd unit is active
|
|
3. RivetKit engine port (default 6420) accepts TCP connections
|
|
4. Flue agent port (default 3583) accepts TCP connections
|
|
5. Docker daemon responds to `docker info`
|
|
|
|
No HTTP health endpoints are assumed. Flue does not expose one by design
|
|
(per Flue docs: "Flue does not add a health endpoint"). RivetKit's health
|
|
route is internal to the registry runtime and not documented as
|
|
publicly addressable on the engine endpoint.
|
|
|
|
## Update to commit
|
|
|
|
```bash
|
|
# Update to latest of dogfood/v0 (default)
|
|
/opt/zopu/deploy/zopu-runtime/scripts/update.sh
|
|
|
|
# Update to a specific branch
|
|
/opt/zopu/deploy/zopu-runtime/scripts/update.sh dogfood/runtime-deploy
|
|
|
|
# Update to a specific commit
|
|
/opt/zopu/deploy/zopu-runtime/scripts/update.sh abc123def456
|
|
```
|
|
|
|
The update script:
|
|
1. Records current HEAD to `.last-deployed-sha`
|
|
2. Fetches, resolves branch-or-commit, checks out
|
|
3. `bun install`, builds daemon and agent
|
|
4. Restarts daemon, waits, restarts agent
|
|
5. Runs health check; reports failure and rollback instructions
|
|
|
|
## Rollback
|
|
|
|
```bash
|
|
# Roll back to the previously deployed commit
|
|
/opt/zopu/deploy/zopu-runtime/scripts/rollback.sh
|
|
|
|
# Roll back to a specific commit
|
|
/opt/zopu/deploy/zopu-runtime/scripts/rollback.sh abc123def456
|
|
```
|
|
|
|
Rollback reads `.last-deployed-sha` (written by `update.sh`), checks out that
|
|
commit, rebuilds, and restarts services. The pre-rollback SHA is saved to
|
|
`.pre-rollback-sha` for re-rollback if needed.
|
|
|
|
## Docker cleanup
|
|
|
|
```bash
|
|
# Manual cleanup
|
|
/opt/zopu/deploy/zopu-runtime/scripts/docker-cleanup.sh
|
|
|
|
# Check Docker disk usage
|
|
docker system df
|
|
|
|
# Timer runs daily; check its schedule
|
|
systemctl list-timers zopu-docker-cleanup.timer
|
|
```
|
|
|
|
Cleanup prunes:
|
|
- Stopped containers older than 24 hours
|
|
- Dangling (untagged) images
|
|
- Unused networks
|
|
|
|
|
|
Named volumes and running containers are never removed.
|
|
|
|
## Disk-space monitoring
|
|
|
|
```bash
|
|
# Manual check
|
|
/opt/zopu/deploy/zopu-runtime/scripts/disk-monitor.sh
|
|
|
|
# Custom threshold (90%)
|
|
/opt/zopu/deploy/zopu-runtime/scripts/disk-monitor.sh --warn-percent 90
|
|
```
|
|
|
|
A cron job runs at 06:00 daily and writes to `/var/log/zopu/disk-monitor.log`.
|
|
Default alert threshold is 80%.
|
|
|
|
## Firewall and private networking
|
|
|
|
The firewall (`ufw`) is deny-by-default:
|
|
- SSH (port 22) is allowed on all interfaces
|
|
- All traffic on `tailscale0` is allowed (Tailscale private overlay)
|
|
- All other incoming traffic is denied
|
|
|
|
The RivetKit engine (`:6420`) and Flue agent (`:3583`) ports are **not**
|
|
exposed on public interfaces. Reachability options:
|
|
|
|
1. **Tailscale** (recommended): bootstrap runs `ufw allow in on tailscale0`
|
|
so all ports are reachable over the private overlay. Set
|
|
`TAILSCALE_AUTHKEY` before running bootstrap to configure automatically.
|
|
Other Tailscale-connected machines can reach the agent at
|
|
`http://zopu-runtime:3583` and the engine at `http://zopu-runtime:6420`.
|
|
2. **Custom private interface**: if you have a non-Tailscale private network
|
|
(e.g. a VLAN or wireguard interface), add an explicit rule:
|
|
```bash
|
|
ufw allow in on eth1 # or your private interface name
|
|
```
|
|
Do NOT assume direct private IP access works by default — the deny-incoming
|
|
policy blocks it until an interface-specific rule is added.
|
|
3. **Caddy** (optional): install Caddy and use the annotated Caddyfile in
|
|
`caddy/` if you need TLS termination or a public ingress point.
|
|
|
|
## Environment groups
|
|
|
|
See [`.env.template`](./.env.template) for the full annotated template. The
|
|
eight required groups:
|
|
|
|
| Group | Variables |
|
|
|-------|-----------|
|
|
| Convex | `CONVEX_URL`, `CONVEX_SITE_URL`, `SITE_URL` |
|
|
| Gitea | `GITEA_URL`, `GITEA_TOKEN` |
|
|
| Model gateway | `AGENT_MODEL_*` |
|
|
| AgentOS/RivetKit | `RIVET_ENDPOINT` (optional) |
|
|
| Zopu agent | `FLUE_DB_TOKEN`, `PORT` |
|
|
| Daemon | `DAEMON_ID`, `DAEMON_NAME`, `DAEMON_VERSION`, `DAEMON_HEARTBEAT_MS`, `DAEMON_COMMAND_LEASE_MS` |
|
|
| Docker sandbox | group membership (no env vars) |
|
|
| Service auth | `AUTH_SECRET` (if needed) |
|
|
|
|
## What is NOT deployed
|
|
|
|
- **Web app**: the web frontend is not deployed in this lane.
|
|
- **Kubernetes**: no container orchestration.
|
|
- **PostgreSQL for Rivet**: the in-process RivetKit engine uses its own
|
|
storage; no external PostgreSQL is required.
|
|
- **Multi-node coordination**: single-node only.
|
|
- **Public administration endpoints**: no admin HTTP surface.
|
|
- **Secrets in source**: `.env` is never committed; `.env.template` contains
|
|
only placeholder values.
|
|
- **Production generated-app hosting**: only the execution plane runs here.
|
|
- **Docker-backed Orb sandboxes**: Docker is installed and access is
|
|
provisioned, but no Orb sandbox code is wired. This is a boundary
|
|
prepared for the Orb lane, not a working feature.
|
|
|
|
## Reproducibility
|
|
|
|
The deployment does not require the developer's MacBook to remain online.
|
|
Once bootstrap completes and `.env` is filled in:
|
|
|
|
1. Services run under systemd with `Restart=always`.
|
|
2. Logs persist in journald.
|
|
3. Health checks run every 60 seconds via systemd timer.
|
|
4. Docker cleanup runs daily.
|
|
5. Disk usage is monitored daily.
|
|
6. Unattended-upgrades handles Debian security patches.
|