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.
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
# 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
# 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.
# 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, 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:
.envis never committed;.env.templatecontains 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:
- 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.