Zopu Single-Node Runtime Deployment
Deployment artifacts for the complete Zopu stack on a single Debian dedicated server: the React Router web app, a persistent self-hosted Convex control plane, the daemon (Bun/Effect + AgentOS/RivetKit), the Flue agent service, Docker Engine, and supporting infrastructure.
Architecture
┌─────────────────────────────────────────────────────────────┐
│ Debian Dedicated Server │
│ ~12 CPU cores · ~40 GB RAM · single-node │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ zopu-web │ │ zopu-agent │ │ Docker │ │
│ │ (systemd) │ │ (systemd) │ │ Engine │ │
│ │ │ │ │ │ │ │
│ │ React Router │ │ Flue Node 22 │ │ Convex │ │
│ │ :13100 │ │ server.mjs │ │ backend │ │
│ │ │ │ :3583 │ │ :3210/:3211 │ │
│ └──────┬───────┘ └──────┬───────┘ └──────────────┘ │
│ │ │ │
│ ┌──────▼───────┐ │
│ │ zopu-daemon │ Bun + Effect + RivetKit :6420 │
│ └──────────────┘ │
│ │
│ 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-web.service # React Router web 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
# 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-web 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
# Start all services
systemctl start zopu-web zopu-daemon zopu-agent
# Stop all services
systemctl stop zopu-agent zopu-daemon zopu-web
# Restart (daemon first — it owns the RivetKit engine)
systemctl restart zopu-web zopu-daemon && sleep 3 && systemctl restart zopu-agent
# Enable on boot
systemctl enable zopu-web zopu-daemon zopu-agent
# Disable on boot
systemctl disable zopu-web zopu-daemon zopu-agent
Log inspection
All service logs go to journald with SyslogIdentifier tags.
# 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
# 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:
zopu-daemonsystemd unit is activezopu-agentsystemd unit is active- RivetKit engine port (default 6420) accepts TCP connections
- Flue agent port (default 3583) accepts TCP connections
- 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
# 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:
- Records current HEAD to
.last-deployed-sha - Fetches, resolves branch-or-commit, checks out
bun install, builds daemon and agent- Restarts daemon, waits, restarts agent
- Runs health check; reports failure and rollback instructions
Rollback
# 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
# 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
# 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
tailscale0is 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:
- Tailscale (recommended): bootstrap runs
ufw allow in on tailscale0so all ports are reachable over the private overlay. SetTAILSCALE_AUTHKEYbefore running bootstrap to configure automatically. Other Tailscale-connected machines can reach the agent athttp://zopu-runtime:3583and the engine athttp://zopu-runtime:6420. - Custom private interface: if you have a non-Tailscale private network (e.g. a VLAN or wireguard interface), add an explicit rule:
Do NOT assume direct private IP access works by default — the deny-incoming policy blocks it until an interface-specific rule is added.
ufw allow in on eth1 # or your private interface name - 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 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 (zopu-agent.service sets port 3583) |
| 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) |
Public single-node routes
The checked-in Caddy example assumes Cloudflare Tunnel terminates TLS and sends the four Zopu hosts to Caddy on loopback:
zopu.sai-onchain.me→ React Router web app on127.0.0.1:13100zopu-api.sai-onchain.me→ Convex API on127.0.0.1:3210zopu-site.sai-onchain.me→ Convex HTTP actions on127.0.0.1:3211zopu-agent.sai-onchain.me→ Flue on127.0.0.1:3583
Self-hosted Convex state lives in the zopu-convex-data Docker volume. Generate the CLI admin key after the backend is healthy:
docker compose \
--env-file /opt/zopu/.env \
-f /opt/zopu/deploy/zopu-runtime/convex/docker-compose.yml \
exec backend ./generate_admin_key.sh
What is NOT deployed
- 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:
.envis never committed;.env.templatecontains only placeholder values. - 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:
- Services run under systemd with
Restart=always. - Logs persist in journald.
- Health checks run every 60 seconds via systemd timer.
- Docker cleanup runs daily.
- Disk usage is monitored daily.
- Unattended-upgrades handles Debian security patches.