Deploy the server
Run zidane-server in production with systemd, Docker Compose or Coolify, behind Caddy or nginx, with backups, upgrades, limits and health checks.
The quickest way is zidane server setup, run on the computer that hosts the server. It asks how the server runs: systemd, launchd or Docker. It asks how your devices reach it: Tailscale, Cloudflare Tunnel, a proxy, a certificate or Caddy. Then it shows the steps, runs them and prints the line for each device. It also prints a join code (a one-time code) for a first machine. A machine is a computer that runs the commands of the server's sessions (servers and machines). It installs the release's web app next to the binary, and on Linux gives the binary's folder to the server's user so it can install its own updates. When the server does not answer at the end, setup shows the service's last log lines. zidane server doctor checks it later, machines included. Details: SERVER.md § Guided setup.
Off loopback, the server needs TLS, in the server itself or in a proxy in front of it. Pick a setup:
| Setup | TLS by | Start here |
|---|---|---|
| A VPS, the binary under systemd | Caddy or nginx on the same host | systemd |
| Docker Compose, the bundled Caddy | Caddy, automatic certificates | Compose |
| The image behind a proxy you already run | Your proxy | Reverse proxy |
| Coolify | Coolify's Traefik | Coolify |
| No proxy at all | The server (--tls-cert, --tls-key) | TLS in the server |
VPS with systemd#
Use the linux release archive from https://zidane.si/dl. Next to the binary it holds zidane-server.service, a systemd unit with a dedicated user, resource caps and a sandbox. Verify the archive before you install it:
V=8.1.0 T=linux-x64 # or linux-arm64
curl -fLO "https://zidane.si/dl/v$V/zidane-server-v$V-$T.tar.gz"
tar -xzf "zidane-server-v$V-$T.tar.gz" && cd "zidane-server-v$V-$T"
sudo useradd --system --create-home --home-dir /var/lib/zidane --shell /bin/bash zidane
sudo install -m 0755 zidane-server /usr/local/bin/zidane-server
sudo install -m 0644 zidane-server.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now zidane-server
sudo -u zidane zidane-server token create --name laptop --data-dir /var/lib/zidaneIts settings:
Environment=HOME=/var/lib/zidane
Environment=ZIDANE_SERVER_DATA_DIR=/var/lib/zidane
Environment=ZIDANE_SERVER_WORKSPACE_ROOTS=/var/lib/zidane/workspaces
# Caddy or nginx on this host terminates TLS and sends X-Forwarded-Proto. To
# serve TLS here instead, set ZIDANE_SERVER_BEHIND_PROXY=0, an empty
# ZIDANE_SERVER_TRUSTED_PROXIES= and ZIDANE_SERVER_TLS_CERT / _TLS_KEY in the file.
Environment=ZIDANE_SERVER_BEHIND_PROXY=1
Environment=ZIDANE_SERVER_TRUSTED_PROXIES=127.0.0.1,::1
EnvironmentFile=-/etc/zidane-server.envSettings and provider keys go in /etc/zidane-server.env, one KEY=value per line, mode 0600. They win over the lines above. The unit caps memory at 4 GB and tasks at 2048. It lets the agents' tools write only to /var/lib/zidane and a private /tmp: for workspaces elsewhere, add them to ReadWritePaths and ZIDANE_SERVER_WORKSPACE_ROOTS. SIGTERM goes to the server alone, so it drains, and the unit gives it 30 seconds.
Token commands find the server's files through the data dir, so give them --data-dir too. Then add a proxy. With --behind-proxy, every upgrade must carry X-Forwarded-Proto: https.
Docker Compose#
server/docker-compose.selfhost.yml runs the released image with a named volume, a fixed network and optional Caddy:
# The release to run (required), in server/.env: every docker compose call reads it.
echo ZIDANE_SERVER_VERSION=8.1.0 >> server/.env
# The server only, on 127.0.0.1:4280, for a proxy you already run:
docker compose -f server/docker-compose.selfhost.yml up -d
# With the bundled Caddy (the domain must point at this machine, ports 80 and 443 open):
echo ZIDANE_DOMAIN=zidane.example.com >> server/.env
docker compose -f server/docker-compose.selfhost.yml --profile caddy up -d
docker compose -f server/docker-compose.selfhost.yml exec zidane-server zidane-server token create --name laptopZIDANE_SERVER_VERSION has no default: pin an exact release (why). The file is server/.env, next to the Compose file, and git ignores it.
The server service:
services:
zidane-server:
image: ghcr.io/tahul/zidane-server:${ZIDANE_SERVER_VERSION:?set ZIDANE_SERVER_VERSION to an exact release, e.g. 8.1.0}
restart: unless-stopped
# SIGTERM drains running turns (ZIDANE_SERVER_DRAIN_TIMEOUT_MS, default
# 8 s) before exiting; give it room before Docker's SIGKILL.
stop_grace_period: 30s
# Agents run commands here: cap what they can take from the host. Raise
# a limit for heavy builds; 0 removes it (no CPU limit by default).
mem_limit: ${ZIDANE_SERVER_MEMORY_LIMIT:-4g}
pids_limit: ${ZIDANE_SERVER_PIDS_LIMIT:-2048}
cpus: ${ZIDANE_SERVER_CPUS:-0}
# Nothing the agents run gains privileges (setuid binaries included).
security_opt:
- no-new-privileges:true
# The image's default command (`start`) and listening defaults: every
# interface of the container, plain HTTP for a TLS-terminating proxy only
# (ZIDANE_SERVER_HOST=0.0.0.0, ZIDANE_SERVER_BEHIND_PROXY=1: upgrades
# must carry X-Forwarded-Proto: https; without it they are refused).
# Loopback only: a proxy on this host (or the caddy service, over the
# compose network) reaches it; the network does not.
ports:
- 127.0.0.1:4280:4280
volumes:
# Sessions, settings, credentials, tokens, audit log: /data/.zidane.
- zidane-data:/data
# Projects the agents work on: sessions may only use folders under
# the workspace roots (ZIDANE_SERVER_WORKSPACE_ROOTS, /data/workspaces
# in the image), so mount them there.
# - /home/me/code:/data/workspaces
networks: [zidane]
environment:
ZIDANE_SERVER_NAME: ${ZIDANE_SERVER_NAME:-zidane-server}
# Only peers on the compose network are believed as proxies: the caddy
# service, and a proxy on this host (it reaches 127.0.0.1:4280 through
# the network's gateway, inside the same subnet). Any other peer is
# refused (403) and its X-Forwarded-* headers are never read. Change
# both together with ZIDANE_NETWORK_SUBNET if the subnet is taken.
# (Tighter than the image's default, `private`: this network is known.)
ZIDANE_SERVER_TRUSTED_PROXIES: ${ZIDANE_SERVER_TRUSTED_PROXIES:-${ZIDANE_NETWORK_SUBNET:-172.30.80.0/24}}
# A first client token without a shell (`zidane-server token generate`
# prints one); stored at start as the token "bootstrap".
# ZIDANE_SERVER_BOOTSTRAP_TOKEN: ${ZIDANE_SERVER_BOOTSTRAP_TOKEN}
# Provider credentials the server's agents use (the window's stay on the window's machine).
# ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY}
# Browser origins allowed to connect (none by default; CLI clients send no Origin).
# ZIDANE_SERVER_ALLOWED_ORIGINS: https://app.example.comThe Caddy service and its configuration:
caddy:
image: caddy:2
profiles: [caddy]
restart: unless-stopped
depends_on:
zidane-server:
condition: service_healthy
ports:
- 80:80
- 443:443
networks: [zidane]
environment:
ZIDANE_DOMAIN: ${ZIDANE_DOMAIN:-localhost}
# Caddy terminates TLS, upgrades WebSockets, sets X-Forwarded-Proto and
# keeps streams open without a read timeout.
command: [caddy, run, --config, /etc/caddy/Caddyfile, --adapter, caddyfile]
configs:
- source: caddyfile
target: /etc/caddy/Caddyfile
volumes:
- caddy-data:/dataconfigs:
caddyfile:
content: |
{$$ZIDANE_DOMAIN} {
reverse_proxy zidane-server:4280
}
networks:
# A fixed subnet, so the server can trust exactly this network's peers (ZIDANE_SERVER_TRUSTED_PROXIES).
zidane:
ipam:
config:
- subnet: ${ZIDANE_NETWORK_SUBNET:-172.30.80.0/24}
volumes:
zidane-data:
caddy-data:
# restate-data:The server trusts the peers in the network's subnet (172.30.80.0/24, or ZIDANE_NETWORK_SUBNET) as its proxies. These peers are the Caddy service and a proxy on the host that reaches 127.0.0.1:4280 through the gateway. Keep the published port on loopback.
To build the image from a checkout, add the override file:
docker compose -f server/docker-compose.selfhost.yml -f server/docker-compose.build.yml up -d --buildReverse proxy#
The proxy must upgrade WebSockets, send X-Forwarded-Proto: https and leave idle streams open. A run can stay quiet for minutes, and the server pings every 20 seconds. Run the server with --behind-proxy and name the peers that are proxies:
| Where the proxy runs | --trusted-proxies / ZIDANE_SERVER_TRUSTED_PROXIES |
|---|---|
| Same host, the server as a binary | 127.0.0.1,::1 |
| Same host, the server in Docker | The container network's subnet (docker network inspect <network>) |
| The Compose file above | Its network, 172.30.80.0/24 (already set) |
| Coolify, or any private network only the proxy reaches | private (the image's default) |
Without trusted proxies, the server believes every peer's X-Forwarded-* headers and warns at start (risks).
zidane.example.com {
reverse_proxy 127.0.0.1:4280
}Caddy gets the certificate, upgrades WebSockets and sets the forwarded headers.
location / {
proxy_pass http://127.0.0.1:4280;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_read_timeout 1h;
proxy_send_timeout 1h;
proxy_buffering off;
}Use $remote_addr for X-Forwarded-For. The server reads the client address from the right of that header, and $proxy_add_x_forwarded_for keeps a left part the client controls.
ingress:
- hostname: zidane.example.com
service: http://127.0.0.1:4280
- service: http_status:404cloudflared sets X-Forwarded-Proto: https and passes WebSockets. Cloudflare closes a WebSocket idle for 100 seconds, so leave the heartbeat on.
If you have a dashboard token, zidane server setup --cloudflared-token <token> writes the token to /etc/cloudflared/zidane.token (root, mode 600). Its own service (zidane-cloudflared on Linux, dev.zidane.cloudflared on macOS) reads it with --token-file, so the token never shows in the process list. If an earlier setup ran cloudflared service install <token>, setup leaves that service and tells you. If that service runs the same tunnel, remove it with sudo cloudflared service uninstall. Details: SERVER.md § Tunnels.
Make sure that the server answers from outside:
curl -fsS https://zidane.example.com/healthz # okWeb app#
With the web app on, the proxy above serves it too: send every path to the server, not only the WebSocket upgrade. The server decides the page's own origin from Host (or X-Forwarded-Host) and https, so keep the public host name in one of them (Caddy and the nginx block above do). In Docker or Compose, set ZIDANE_SERVER_WEB=1: the image bundles the app. Then pair a browser:
docker exec zidane zidane-server pairPairing attempts are limited to 10 a minute per client address, read from X-Forwarded-For behind a trusted proxy.
TLS in the server#
With a certificate and key, the server serves wss://host:4280 itself. TLS with behind-proxy is a boot error. The image turns behind-proxy on, so turn it off. The Compose file has this commented block:
# --- Alternative: TLS served by zidane-server itself (no proxy) ---------
# (trusted proxies only apply behind a proxy: replace the one above with the empty value)
# environment:
# ZIDANE_SERVER_BEHIND_PROXY: "0"
# ZIDANE_SERVER_TRUSTED_PROXIES: ""
# ZIDANE_SERVER_TLS_CERT: /certs/fullchain.pem
# ZIDANE_SERVER_TLS_KEY: /certs/privkey.pem
# volumes:
# - zidane-data:/data
# - ./certs:/certs:ro
# ports:
# - 4280:4280With the binary:
zidane-server start --host 0.0.0.0 --tls-cert fullchain.pem --tls-key privkey.pemThe server reads the files at start only: Bun can't swap a listening server's certificate, so a renewal takes a restart. The server warns on stderr when the files change on disk (checked every minute), and when the certificate it serves expires within 14 days (at start, then daily). Restart once nothing runs, so the restart cuts off no tool call. With certbot, a deploy hook does it:
certbot renew --deploy-hook 'sudo -u zidane zidane-server wait-idle --data-dir /var/lib/zidane; systemctl restart zidane-server'A proxy renews its certificate without a restart.
Coolify#
Coolify runs the image on its coolify network behind Traefik, which terminates TLS and sets the forwarded headers. The image's defaults (behind a proxy, proxies trusted from any private address) match.
Create the application
Choose + New › Docker Image, with the image
ghcr.io/tahul/zidane-server:8.1.0. Pin an exact release (why). Under Configuration › General, set Ports Exposes to4280(it starts at80). Leave Ports Mappings empty, because a published port bypasses Traefik.Add the domain
In Domains, enter
https://zidane.example.com, or use a generated one. Traefik gets the certificate.Add persistent storage
Choose Persistent Storage › Add › Volume Mount. Set the name to
zidane-dataand the destination to/data. A Directory Mount also works if uid 1000 can write to the host directory.Cap resources
The agents run commands in the container. In Resource Limits, set a memory limit, such as
4g. In Custom Docker Options, add--security-opt no-new-privileges. Coolify has no process limit for a Docker Image application; the Compose build pack sets one.Set the variables
ZIDANE_SERVER_BOOTSTRAP_TOKENfromzidane-server token generate, provider keys such asANTHROPIC_API_KEY, and anything else from Configuration. Mark secrets as runtime only.Deploy
Choose Deploy. The log shows the listening line, then
Stored the bootstrap token. The status turns healthy within about 30 seconds.
Without storage on /data, every redeploy starts empty: sessions, settings, keys, tokens and the audit log are gone. The server warns at start when its data dir is not on a persistent volume.
Leave Coolify's dashboard health check off. That check needs curl or wget, which the image does not have, and the image has its own HEALTHCHECK. In the application's Terminal, create device tokens and revoke the bootstrap one:
zidane-server token create --name laptop
zidane-server token revoke bootstrapTo tighten private, set ZIDANE_SERVER_TRUSTED_PROXIES to the coolify network's subnet. To deploy main instead of a release, use the Dockerfile build pack with Base Directory / and Dockerfile Location /server/Dockerfile.
The Docker Compose build pack (Docker Compose Location /server/docker-compose.yml) runs the same deployment from server/docker-compose.yml. It sets the health check, the stop grace period and the resource limits, and comments each variable. Its ZIDANE_SERVER_VERSION is required, so Coolify asks for it. Set an exact release.
Data directory and backups#
With --data-dir /data (the image's ZIDANE_SERVER_DATA_DIR), everything lives under /data/.zidane:
/data/.zidane/
sessions.db (+ -wal, -shm) sessions
settings, credentials as the host writes them
host-id the server's stable id
server.json optional configuration
server-tokens.json token hashes (0600)
server-tokens.usage.json last use per token (0600)
server-audit.jsonl (+ .1) audit log (0600)
server-audit.failures.jsonl (+ .1) refused upgrades (0600)
sync.json settings sync record (0600)
server.sock unix socket, while running
/data/workspaces/ the image's workspace rootThe image sets HOME=/data, so git config, ~/.ssh and other dotfiles the agents write persist on the volume.
zidane-server backup archives the server's state while it runs, as one tar.gz:
- the data dir's files (with a binary's XDG layout, the config, data and state dirs), and what you author in
~/.zidane(skills, extensions,mcps.json); - the sessions database as a consistent snapshot (SQLite's
VACUUM INTO), never a torn copy of it and its write-ahead log; - with
--include-workspaces, the workspace roots. They stay out otherwise, even inside the data dir.
The cache, sockets and live database files stay out. The archive is owner-only (0600), and never written inside a directory it archives. --out - streams it to stdout:
docker compose -f server/docker-compose.selfhost.yml exec -T zidane-server zidane-server backup --out - > zidane-$(date +%F).tar.gz
docker exec zidane zidane-server backup --out - > zidane-$(date +%F).tar.gz # plain Docker
sudo install -d -o zidane -m 700 /var/backups/zidane # systemd: once
sudo -u zidane zidane-server backup --data-dir /var/lib/zidane --out /var/backups/zidane/$(date +%F).tar.gzSchedule it with cron on the host (escape % there), or with Coolify's Scheduled Tasks. It exits 1 when a file could not be read, and names it on stderr.
To restore, stop the server and move the old data dir aside: a sessions.db-wal left from the database being replaced would be replayed into the restored one. Then extract with -C /. Entries are absolute paths without their leading /.
docker compose -f server/docker-compose.selfhost.yml stop zidane-server
docker run --rm -v zidane_zidane-data:/data -v "$PWD":/backup debian:trixie-slim \
sh -c 'mv /data/.zidane /data/.zidane.old && tar -xzf /backup/zidane-2026-10-17.tar.gz -C /'
docker compose -f server/docker-compose.selfhost.yml start zidane-serverFor the whole volume (workspaces, ~/.ssh and other dotfiles), stop the server and archive the volume: docker run --rm -v zidane_zidane-data:/data -v "$PWD":/backup debian:trixie-slim tar czf /backup/zidane-data.tgz -C /data .. Under systemd, the same goes for /var/lib/zidane.
Upgrades#
Pin an exact release, and upgrade the server and its clients together. Before an upgrade, or any planned restart:
- Back up (above).
- Run
zidane-server wait-idle. It waits until no run or background task is in flight, because a restart cuts off running tool calls. It waits up to 10 minutes (--timeout <ms>), exits 0 once idle, and 1 if work is still going. - Upgrade:
| Install | Upgrade |
|---|---|
| Compose | Change ZIDANE_SERVER_VERSION, then docker compose pull && docker compose up -d |
| Plain Docker | docker pull the new tag, then remove and docker run the container with the same volume |
| Coolify | Change the tag (or the Compose build pack's ZIDANE_SERVER_VERSION), then redeploy |
| Binary | Replace it (curl -fsSL https://zidane.si/install | sudo sh -s -- --server --prefix /usr/local does), then systemctl restart zidane-server |
To roll back, run the previous release on the same volume. The install script's --version <v> installs the release before the latest. If the newer one changed the sessions database's schema, the older one refuses to open it (has schema version N, but this zidane build only supports up to M). Then restore the backup from before the upgrade.
On SIGTERM the server stops accepting connections, closes every connection and stops its running turns. It exits 0 within ZIDANE_SERVER_DRAIN_TIMEOUT_MS (8 seconds by default). Running tool calls get half of that time to finish. With the durability setting on, the next start resumes the turns. With resume-ask (the default), it asks first (crash recovery). Windows reconnect and reload their open session. Keep the drain under the stop timeout. Docker's default is 10 seconds, and both Compose files set 30.
The server and the apps must share a protocol version. Each side speaks a range, so an app one release behind or ahead usually still attaches; past that, the server refuses it and names the side to upgrade (Compatibility).
Resource limits#
The agents' commands run inside the server's container or service, so its limits cap them. Both Compose files set these. Change them in server/.env, where 0 removes a cap:
| Variable | Default |
|---|---|
ZIDANE_SERVER_MEMORY_LIMIT | 4g |
ZIDANE_SERVER_PIDS_LIMIT | 2048 |
ZIDANE_SERVER_CPUS | 0 (no limit) |
They also run the server with no-new-privileges. With plain Docker, pass --memory 4g --pids-limit 2048 --security-opt no-new-privileges. The systemd unit sets MemoryMax=4G and TasksMax=2048. Add CPUQuota=200% for a CPU cap. A command over the memory cap is killed (Killed, exit 137): raise the cap for heavy builds. The server's own caps (message size, request rate, connections per token and per address) are in Security.
Health checks#
The image's HEALTHCHECK runs zidane-server health --http every 10 seconds, after a 30-second start period:
docker inspect -f '{{.State.Health.Status}}' zidaneA proxy or uptime monitor uses GET /healthz (all health checks).