# 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](https://zidane.si/docs/servers-and-machines.md)). 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](https://github.com/Tahul/zidane/blob/main/docs/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](#vps-with-systemd) |
| Docker Compose, the bundled Caddy | Caddy, automatic certificates | [Compose](#docker-compose) |
| The image behind a proxy you already run | Your proxy | [Reverse proxy](#reverse-proxy) |
| Coolify | Coolify's Traefik | [Coolify](#coolify) |
| No proxy at all | The server (`--tls-cert`, `--tls-key`) | [TLS in the server](#tls-in-the-server) |

## VPS with systemd

Use the linux [release archive](https://zidane.si/docs/server-install.md#run-it) 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](https://zidane.si/docs/verify.md) before you install it:

```bash
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/zidane
```

Its settings:

```ini title="server/zidane-server.service"
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.env
```

Settings 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](#reverse-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:

```bash
# 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 laptop
```

`ZIDANE_SERVER_VERSION` has no default: pin an exact release ([why](https://zidane.si/docs/server-install.md#versions)). The file is `server/.env`, next to the Compose file, and git ignores it.

The server service:

```yaml title="server/docker-compose.selfhost.yml"
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.com
```

The Caddy service and its configuration:

```yaml title="server/docker-compose.selfhost.yml"
  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:/data
```

```yaml title="server/docker-compose.selfhost.yml"
configs:
  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:

```bash
docker compose -f server/docker-compose.selfhost.yml -f server/docker-compose.build.yml up -d --build
```

## Reverse 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](https://zidane.si/docs/server-security.md#tls-and-proxies)).

**Caddy**

```text title="Caddyfile"
zidane.example.com {
  reverse_proxy 127.0.0.1:4280
}
```

Caddy gets the certificate, upgrades WebSockets and sets the forwarded headers.

**nginx**

```nginx
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.

**Cloudflare Tunnel**

```yaml
ingress:
  - hostname: zidane.example.com
    service: http://127.0.0.1:4280
  - service: http_status:404
```

`cloudflared` 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](https://github.com/Tahul/zidane/blob/main/docs/SERVER.md#tunnels-tailscale-and-cloudflare).

Make sure that the server answers from outside:

```bash
curl -fsS https://zidane.example.com/healthz     # ok
```

### Web app

With the [web app](https://zidane.si/docs/server-install.md#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:

```bash
docker exec zidane zidane-server pair
```

Pairing 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:

```yaml title="server/docker-compose.selfhost.yml"
    # --- 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:4280
```

With the binary:

```bash
zidane-server start --host 0.0.0.0 --tls-cert fullchain.pem --tls-key privkey.pem
```

The 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:

```bash
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.

## 1. Create the application

Choose + New › Docker Image, with the image `ghcr.io/tahul/zidane-server:8.1.0`. Pin an exact release ([why](https://zidane.si/docs/server-install.md#versions)). Under Configuration › General, set Ports Exposes to `4280` (it starts at `80`). Leave Ports Mappings empty, because a published port bypasses Traefik.

## 2. Add the domain

In Domains, enter `https://zidane.example.com`, or use a generated one. Traefik gets the certificate.

## 3. Add persistent storage

Choose Persistent Storage › Add › Volume Mount. Set the name to `zidane-data` and the destination to `/data`. A Directory Mount also works if uid 1000 can write to the host directory.

## 4. 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.

## 5. Set the variables

`ZIDANE_SERVER_BOOTSTRAP_TOKEN` from `zidane-server token generate`, provider keys such as `ANTHROPIC_API_KEY`, and anything else from [Configuration](https://zidane.si/docs/server-config.md#environment-variables). Mark secrets as runtime only.

## 6. Deploy

Choose Deploy. The log shows the listening line, then `Stored the bootstrap token`. The status turns healthy within about 30 seconds.

> **Warning.** Without storage on `/data`, every redeploy starts empty: sessions, settings, keys, tokens and the audit log are gone. The server [warns at start](https://zidane.si/docs/server-operations.md#storage-warning) 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:

```bash
zidane-server token create --name laptop
zidane-server token revoke bootstrap
```

To 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`](https://github.com/Tahul/zidane/blob/main/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`:

```text
/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 root
```

The 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:

```bash
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.gz
```

Schedule 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 `/`.

```bash
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-server
```

For 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:

1. Back up (above).
2. 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.
3. 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](https://zidane.si/docs/durability.md)). 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](https://zidane.si/docs/server-protocol.md#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](https://zidane.si/docs/server-security.md#limits).

## Health checks

The image's `HEALTHCHECK` runs `zidane-server health --http` every 10 seconds, after a 30-second start period:

```bash
docker inspect -f '{{.State.Health.Status}}' zidane
```

A proxy or uptime monitor uses `GET /healthz` ([all health checks](https://zidane.si/docs/server-operations.md#health)).
