# Docker

Run ZevaiRouter in a container. The canonical image is `ghcr.io/verifiedlabs/zevairouter` (`linux/amd64` + `linux/arm64`). Docker Hub is an optional mirror.

---

# 👤 For Users

## Quick start

```bash
docker pull ghcr.io/verifiedlabs/zevairouter:latest
docker run -d \
  --shm-size=1g \
  -p 127.0.0.1:1997:1997 \
  -v "$HOME/.zevai:/app/data" \
  -e DATA_DIR=/app/data \
  --name zevairouter \
  ghcr.io/verifiedlabs/zevairouter:latest
```

App listens on port `1997`. Open: http://localhost:1997. For deliberate remote access, publish with `-p 1997:1997` and protect the port with a firewall and a strong password.

When `INITIAL_PASSWORD` is unset, the initial dashboard password is `123456` and the container persists it under `$DATA_DIR/initial-password`. Change it after first login. To choose it up front, add `-e INITIAL_PASSWORD='a-strong-password'` to `docker run`.

To build locally instead:

```bash
git clone https://github.com/Verifiedlabs/zevairouter.git
cd zevairouter
docker build -t zevairouter:local .
docker run -d --name zevairouter --shm-size=1g -p 127.0.0.1:1997:1997 \
  -v "$HOME/.zevai:/app/data" -e DATA_DIR=/app/data \
  zevairouter:local
```

## Manage container

```bash
docker logs -f zevairouter        # view logs
docker stop zevairouter           # stop
docker start zevairouter          # start again
docker rm -f zevairouter          # remove
```

## Data persistence

```bash
-v "$HOME/.zevai:/app/data" \
-e DATA_DIR=/app/data
```

Without `DATA_DIR`, the app falls back to `~/.zevai/` (macOS/Linux) or `%APPDATA%\zevai\` (Windows). In the container, `DATA_DIR=/app/data` makes the bind mount work.

Data layout under `$DATA_DIR/`:

```text
$DATA_DIR/
├── db/
│   ├── data.sqlite       # main SQLite database
│   └── backups/          # auto backups
└── ...                   # certs, logs, runtime configs
```

Host path: `$HOME/.zevai/db/data.sqlite`
Container path: `/app/data/db/data.sqlite`

## Optional env vars

```bash
docker run -d \
  --shm-size=1g \
  -p 127.0.0.1:1997:1997 \
  -v "$HOME/.zevai:/app/data" \
  -e DATA_DIR=/app/data \
  -e PORT=1997 \
  -e HOSTNAME=0.0.0.0 \
  -e INITIAL_PASSWORD='a-strong-password' \
  -e DEBUG=true \
  --name zevairouter \
  ghcr.io/verifiedlabs/zevairouter:latest
```

## Safe update and rollback

Keep the old container and a verified data snapshot until the replacement has
been stable long enough for you to accept it. The transaction below stops the
old container before copying SQLite data, verifies the copy byte-for-byte, and
automatically rolls back on a pull, copy, startup, health, or version-check
failure. If your bind mount is not `$HOME/.zevai`, export `ZEVAI_DATA_DIR`
before running it.

```bash
bash <<'ZEVAI_DOCKER_UPDATE'
set -Eeuo pipefail

zevai_image="ghcr.io/verifiedlabs/zevairouter:latest"
zevai_data_input="${ZEVAI_DATA_DIR:-$HOME/.zevai}"
while [ "$zevai_data_input" != "/" ] && [ "${zevai_data_input%/}" != "$zevai_data_input" ]; do
  zevai_data_input="${zevai_data_input%/}"
done
[ -n "$zevai_data_input" ] && [ "$zevai_data_input" != "/" ] || {
  echo "Refusing to use an empty or root data directory." >&2
  exit 1
}
zevai_data_parent="$(cd "$(dirname "$zevai_data_input")" && pwd -P)"
zevai_data_dir="$zevai_data_parent/$(basename "$zevai_data_input")"
zevai_data_name="$(basename "$zevai_data_dir")"
zevai_backup_dir=""
zevai_failed_dir=""
zevai_snapshot_ready=0
zevai_transaction_started=0
zevai_replacement_attempted=0
zevai_committed=0

zevai_rollback() {
  zevai_status="$1"
  trap - EXIT INT TERM
  if [ "$zevai_committed" -eq 1 ]; then
    exit "$zevai_status"
  fi

  set +e
  zevai_rollback_ok=1
  if [ "$zevai_replacement_attempted" -eq 1 ]; then
    if docker container inspect zevairouter >/dev/null 2>&1; then
      docker rm -f zevairouter >/dev/null || zevai_rollback_ok=0
    fi

    if [ "$zevai_rollback_ok" -eq 1 ] && [ "$zevai_snapshot_ready" -eq 1 ]; then
      zevai_failed_dir="$(mktemp -d "$zevai_data_parent/.${zevai_data_name}.failed.XXXXXXXX")" || zevai_rollback_ok=0
      if [ "$zevai_rollback_ok" -eq 1 ]; then
        mv "$zevai_data_dir" "$zevai_failed_dir/data" || zevai_rollback_ok=0
      fi
      if [ "$zevai_rollback_ok" -eq 1 ]; then
        if ! mv "$zevai_backup_dir" "$zevai_data_dir"; then
          mv "$zevai_failed_dir/data" "$zevai_data_dir"
          zevai_rollback_ok=0
        fi
      fi
    fi
  fi

  if [ "$zevai_transaction_started" -eq 1 ] && [ "$zevai_rollback_ok" -eq 1 ]; then
    if docker container inspect zevairouter-rollback >/dev/null 2>&1; then
      docker rename zevairouter-rollback zevairouter || zevai_rollback_ok=0
    fi
    if [ "$zevai_rollback_ok" -eq 1 ]; then
      docker start zevairouter >/dev/null || zevai_rollback_ok=0
    fi
  fi

  if [ "$zevai_rollback_ok" -eq 1 ]; then
    if [ "$zevai_replacement_attempted" -eq 1 ]; then
      echo "Update failed; the previous container and verified snapshot were restored." >&2
      echo "Failed replacement data was preserved at: $zevai_failed_dir/data" >&2
    elif [ "$zevai_transaction_started" -eq 1 ]; then
      echo "Update failed before replacement startup; the previous container was restarted on untouched live data." >&2
      [ -z "$zevai_backup_dir" ] || echo "Uncommitted snapshot directory: $zevai_backup_dir" >&2
    else
      echo "Update failed before the running container or its data was changed." >&2
    fi
  else
    echo "Automatic rollback needs manual attention; the old container was retained as zevairouter-rollback when it could not be restarted safely." >&2
    echo "Snapshot: ${zevai_backup_dir:-not-created}" >&2
    echo "Live/failed data: $zevai_data_dir ${zevai_failed_dir:+and $zevai_failed_dir/data}" >&2
  fi
  exit "$zevai_status"
}

trap 'zevai_rollback "$?"' EXIT
trap 'exit 130' INT
trap 'exit 143' TERM

[ -d "$zevai_data_dir" ] && [ ! -L "$zevai_data_dir" ] || {
  echo "Data directory is missing or is a symlink: $zevai_data_dir" >&2
  exit 1
}
docker container inspect zevairouter >/dev/null
[ "$(docker inspect --format '{{.State.Running}}' zevairouter)" = "true" ] || {
  echo "Container zevairouter must be running before an update." >&2
  exit 1
}
if docker container inspect zevairouter-rollback >/dev/null 2>&1; then
  echo "Remove or archive the existing zevairouter-rollback container first." >&2
  exit 1
fi

docker pull "$zevai_image"
zevai_next_image="$(docker image inspect "$zevai_image" --format '{{.Id}}')"

zevai_transaction_started=1
docker stop zevairouter >/dev/null
zevai_backup_dir="$(mktemp -d "$zevai_data_parent/.${zevai_data_name}.backup.XXXXXXXX")"
cp -a "$zevai_data_dir/." "$zevai_backup_dir/"
diff -qr "$zevai_data_dir" "$zevai_backup_dir" >/dev/null
zevai_snapshot_ready=1

docker rename zevairouter zevairouter-rollback
zevai_replacement_attempted=1
docker run -d \
  --shm-size=1g \
  -p 127.0.0.1:1997:1997 \
  -v "$zevai_data_dir:/app/data" \
  -e DATA_DIR=/app/data \
  --name zevairouter \
  "$zevai_next_image" >/dev/null

zevai_health_deadline=$((SECONDS + 180))
while :; do
  zevai_state="$(docker inspect --format '{{.State.Status}}' zevairouter)"
  zevai_health="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}missing{{end}}' zevairouter)"
  [ "$zevai_health" = "healthy" ] && break
  case "$zevai_state" in
    exited|dead)
      echo "Replacement entered state: $zevai_state" >&2
      exit 1
      ;;
  esac
  if [ "$SECONDS" -ge "$zevai_health_deadline" ]; then
    echo "Replacement did not become healthy within 180 seconds (health: $zevai_health)." >&2
    exit 1
  fi
  sleep 2
done

docker exec zevairouter node -e '
  const expected = require("./package.json").version;
  fetch("http://127.0.0.1:" + (process.env.PORT || 1997) + "/api/version?local=1")
    .then((response) => response.json())
    .then((version) => {
      const exact = ["currentVersion", "installedVersion", "buildVersion"]
        .every((field) => version[field] === expected);
      process.exit(version.product === "zevairouter" && exact && !version.buildMismatch ? 0 : 1);
    })
    .catch(() => process.exit(1));
'

zevai_committed=1
trap - EXIT INT TERM
echo "Update verified. Previous container: zevairouter-rollback"
echo "Verified snapshot: $zevai_backup_dir"
ZEVAI_DOCKER_UPDATE
```

Any automatic rollback preserves data written by the failed replacement under a
unique `.zevai.failed.*` directory next to the live data directory. If you find
a problem only after the transaction succeeds, use the printed snapshot path to
restore the old container while preserving the replacement's container and data:

```bash
export ZEVAI_BACKUP_DIR='/path/printed/by/the/update'
export ZEVAI_DATA_DIR="${ZEVAI_DATA_DIR:-$HOME/.zevai}"
bash <<'ZEVAI_DOCKER_ROLLBACK'
set -Eeuo pipefail

: "${ZEVAI_BACKUP_DIR:?Set this to the verified snapshot printed by the update}"
zevai_data_input="$ZEVAI_DATA_DIR"
zevai_backup_input="$ZEVAI_BACKUP_DIR"
while [ "$zevai_data_input" != "/" ] && [ "${zevai_data_input%/}" != "$zevai_data_input" ]; do
  zevai_data_input="${zevai_data_input%/}"
done
while [ "$zevai_backup_input" != "/" ] && [ "${zevai_backup_input%/}" != "$zevai_backup_input" ]; do
  zevai_backup_input="${zevai_backup_input%/}"
done
[ -n "$zevai_data_input" ] && [ "$zevai_data_input" != "/" ] || {
  echo "Refusing to use an empty or root data directory." >&2
  exit 1
}
[ -n "$zevai_backup_input" ] && [ "$zevai_backup_input" != "/" ] || {
  echo "Refusing to use an empty or root snapshot directory." >&2
  exit 1
}
zevai_data_parent="$(cd "$(dirname "$zevai_data_input")" && pwd -P)"
zevai_data_dir="$zevai_data_parent/$(basename "$zevai_data_input")"
zevai_data_name="$(basename "$zevai_data_dir")"
zevai_backup_parent="$(cd "$(dirname "$zevai_backup_input")" && pwd -P)"
zevai_backup_dir="$zevai_backup_parent/$(basename "$zevai_backup_input")"

[ "$zevai_backup_parent" = "$zevai_data_parent" ] || {
  echo "Snapshot and live data must be on the same filesystem for atomic restore." >&2
  exit 1
}
[ -d "$zevai_data_dir" ] && [ ! -L "$zevai_data_dir" ] || {
  echo "Live data directory is missing or is a symlink: $zevai_data_dir" >&2
  exit 1
}
[ -d "$zevai_backup_dir" ] && [ ! -L "$zevai_backup_dir" ] || {
  echo "Verified snapshot is missing or is a symlink: $zevai_backup_dir" >&2
  exit 1
}
docker container inspect zevairouter >/dev/null
docker container inspect zevairouter-rollback >/dev/null
[ "$(docker inspect --format '{{.State.Running}}' zevairouter)" = "true" ] || {
  echo "Replacement container zevairouter must be running." >&2
  exit 1
}

zevai_current_id="$(docker inspect --format '{{.Id}}' zevairouter)"
zevai_failed_container="zevairouter-failed-${zevai_current_id:0:12}"
if docker container inspect "$zevai_failed_container" >/dev/null 2>&1; then
  echo "Archive or remove the existing $zevai_failed_container container first." >&2
  exit 1
fi
zevai_failed_dir="$(mktemp -d "$zevai_data_parent/.${zevai_data_name}.failed.XXXXXXXX")"
zevai_current_stopped=0
zevai_current_renamed=0
zevai_live_archived=0
zevai_snapshot_installed=0
zevai_old_promoted=0
zevai_done=0

zevai_abort_rollback() {
  zevai_status="$1"
  trap - EXIT INT TERM
  [ "$zevai_done" -eq 0 ] || exit "$zevai_status"
  set +e
  zevai_recovery_ok=1

  if [ "$zevai_old_promoted" -eq 1 ]; then
    docker stop zevairouter >/dev/null || zevai_recovery_ok=0
    if [ "$zevai_recovery_ok" -eq 1 ]; then
      docker rename zevairouter zevairouter-rollback || zevai_recovery_ok=0
    fi
  fi
  if [ "$zevai_snapshot_installed" -eq 1 ] && [ "$zevai_recovery_ok" -eq 1 ]; then
    mv "$zevai_data_dir" "$zevai_backup_dir" || zevai_recovery_ok=0
  fi
  if [ "$zevai_live_archived" -eq 1 ] && [ "$zevai_recovery_ok" -eq 1 ]; then
    mv "$zevai_failed_dir/data" "$zevai_data_dir" || zevai_recovery_ok=0
  fi
  if [ "$zevai_current_renamed" -eq 1 ] && [ "$zevai_recovery_ok" -eq 1 ]; then
    docker rename "$zevai_failed_container" zevairouter || zevai_recovery_ok=0
  fi
  if [ "$zevai_current_stopped" -eq 1 ] && [ "$zevai_recovery_ok" -eq 1 ]; then
    docker start zevairouter >/dev/null || zevai_recovery_ok=0
  fi

  if [ "$zevai_recovery_ok" -eq 1 ]; then
    echo "Rollback was aborted; the replacement container and live data were restored." >&2
  else
    echo "Rollback needs manual attention. No container or data directory was deleted." >&2
    echo "Containers: zevairouter, zevairouter-rollback, $zevai_failed_container" >&2
    echo "Data paths: $zevai_data_dir, $zevai_backup_dir, $zevai_failed_dir/data" >&2
  fi
  exit "$zevai_status"
}

trap 'zevai_abort_rollback "$?"' EXIT
trap 'exit 130' INT
trap 'exit 143' TERM

docker stop zevairouter >/dev/null
zevai_current_stopped=1
docker rename zevairouter "$zevai_failed_container"
zevai_current_renamed=1
mv "$zevai_data_dir" "$zevai_failed_dir/data"
zevai_live_archived=1
mv "$zevai_backup_dir" "$zevai_data_dir"
zevai_snapshot_installed=1
docker rename zevairouter-rollback zevairouter
zevai_old_promoted=1
docker start zevairouter >/dev/null
zevai_done=1
trap - EXIT INT TERM
echo "Failed container: $zevai_failed_container"
echo "Failed data: $zevai_failed_dir/data"
ZEVAI_DOCKER_ROLLBACK
```

After the new version is stable, remove the stopped `zevairouter-rollback`
container. Keep or archive the printed snapshot path according to your backup
policy. Do not remove either until you no longer need rollback.

---

# 🛠 For Developers

## Build image locally (test)

```bash
docker build -t zevairouter:local .

docker run --rm --shm-size=1g -p 127.0.0.1:1997:1997 \
  -v "$HOME/.zevai:/app/data" \
  -e DATA_DIR=/app/data \
  zevairouter:local
```

## Publish (automatic via CI)

Push a git tag `v*` to publish version tags through GitHub Actions. Run the workflow manually on `main` to publish `latest`:
- `ghcr.io/verifiedlabs/zevairouter:{version}`
- `verifiedlabs/zevairouter:{version}` when Docker Hub credentials are configured

```bash
# The npm release script does not create a Docker tag.
VERSION="$(node -p "require('./package.json').version")"
git tag "v${VERSION}"
git push origin "v${VERSION}"
```

Workflow: `.github/workflows/docker-publish.yml`
