feat(deploy): add one-click Docker/Podman Compose installer for Linux… (#2414)

* feat(deploy): add one-click Docker/Podman Compose installer for Linux and macOS

- Add install.sh with interactive wizard, Podman/Docker runtime detection,
  port conflict check, health verification, and systemd user unit creation
- Add update.sh for image pull and restart with health check
- Add uninstall.sh with interactive user data backup before removal
- Unify CLI output styling with step/ok/warn/error/info helpers
- Add install-guide.md documentation
- Add install.test.ts integration test suite

* feat(deploy): add one-click Docker/Podman Compose installer

- interactive setup wizard with port, image, CORS, memory prompts
- automatic Docker/Podman detection with install guidance
- systemd user unit for Linux, health check polling
- update.sh (pull + restart + prune) and uninstall.sh (backup + cleanup)
- node:test integration suite and install-guide.md

* style(deploy): improve POSIX sh compatibility and systemd unit handling

- unify shell shebangs to #!/usr/bin/env bash

- add pipefail option for better error handling

- fix systemd unit for Podman: remove After/Requires when no service

- correct documentation to match actual uninstall behavior

* fix(deploy): address review feedback for installer scripts

- remove curl | sh path, document clone-first only

- isolate tests via docker-compose.override.yml with unique names

- support both --image <ref> and --image=<ref> in update.sh

- add running container detection before install

* docs(install): remove demo scripts and add MCP note
This commit is contained in:
epic 2026-05-22 15:04:16 +09:00 committed by GitHub
parent bde64f8bdd
commit e8b5dd8aaf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 1305 additions and 0 deletions

2
deploy/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
*.bak
.env

505
deploy/scripts/install.sh Executable file
View file

@ -0,0 +1,505 @@
#!/usr/bin/env bash
# Open Design — One-Click Installer
# Docker Compose wrapper for Linux and macOS
#
# Usage:
# Clone this repo, then run:
# ./install.sh [--non-interactive] [--port 7456] [--image <ref>] [--skip-docker-install] [--no-systemd]
set -euo pipefail
# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
DEFAULT_PORT=7456
DEFAULT_IMAGE="docker.io/vanjayak/open-design:latest"
DEFAULT_MEM_LIMIT="384m"
HEALTH_TIMEOUT=60
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
DEPLOY_DIR="$(dirname "$SCRIPT_DIR")"
COMPOSE_FILE="${DEPLOY_DIR}/docker-compose.yml"
OVERRIDE_FILE="${DEPLOY_DIR}/docker-compose.override.yml"
# Build the -f argument list: always include the base file,
# and add the override if it exists (used by tests for isolation).
COMPOSE_FILES=(-f "$COMPOSE_FILE")
if [ -f "$OVERRIDE_FILE" ]; then
COMPOSE_FILES+=(-f "$OVERRIDE_FILE")
fi
if [ ! -f "$COMPOSE_FILE" ]; then
echo "WARN: docker-compose.yml not found at ${COMPOSE_FILE}" >&2
echo " Clone the repository and run this script from deploy/scripts/." >&2
exit 1
fi
ENV_FILE="${DEPLOY_DIR}/.env"
# ---------------------------------------------------------------------------
# Colors & formatting
# ---------------------------------------------------------------------------
BOLD="" DIM="" RED="" GREEN="" YELLOW="" CYAN="" RESET=""
if [ -t 1 ]; then
BOLD="\033[1m" DIM="\033[2m" RED="\033[31m" GREEN="\033[32m"
YELLOW="\033[33m" CYAN="\033[36m" RESET="\033[0m"
fi
step() { printf " ${DIM}${RESET} %s\n" "$1"; }
ok() { printf " ${GREEN}${RESET} %s\n" "$1"; }
warn() { printf " ${YELLOW}!${RESET} %s\n" "$1" >&2; }
error() { printf " ${RED}${RESET} %s\n" "$1" >&2; }
info() { printf " ${CYAN}${RESET} %s\n" "$1"; }
prompt_text() {
_prompt="$1" _default="$2"
printf "%s [%s]: " "$_prompt" "$_default" >&2
read -r _val
PROMPT_RESULT="${_val:-$_default}"
}
prompt_confirm() {
_question="$1" _default="$2"
if [ "$NON_INTERACTIVE" = "1" ]; then
return 0
fi
_yn_default="y"
if [ "$_default" = "0" ]; then _yn_default="n"; fi
printf "%s [%s]: " "$_question" "$_yn_default" >&2
read -r _yn
case "$_yn" in
[Yy]*) return 0 ;;
[Nn]*) return 1 ;;
*) [ "$_default" = "1" ] && return 0; return 1 ;;
esac
}
# ---------------------------------------------------------------------------
# Argument parsing
# ---------------------------------------------------------------------------
NON_INTERACTIVE=0
OPT_PORT=""
OPT_IMAGE=""
OPT_SKIP_DOCKER=0
OPT_NO_SYSTEMD=0
while [ $# -gt 0 ]; do
case "$1" in
--non-interactive) NON_INTERACTIVE=1 ;;
--port) shift; OPT_PORT="$1" ;;
--port=*) OPT_PORT="${1#--port=}" ;;
--image) shift; OPT_IMAGE="$1" ;;
--image=*) OPT_IMAGE="${1#--image=}" ;;
--skip-docker-install) OPT_SKIP_DOCKER=1 ;;
--no-systemd) OPT_NO_SYSTEMD=1 ;;
--help|-h)
echo "Usage: install.sh [options]"
echo ""
echo "Options:"
echo " --non-interactive Use defaults for all prompts"
echo " --port <n> Host port (default: ${DEFAULT_PORT})"
echo " --image <ref> Docker image reference"
echo " --skip-docker-install Do not attempt to install Docker"
echo " --no-systemd Skip systemd unit creation"
echo " --help Show this help"
exit 0
;;
*)
warn "Unknown argument: $1 (ignored)"
;;
esac
shift
done
# ---------------------------------------------------------------------------
# Banner
# ---------------------------------------------------------------------------
printf "\n"
printf "${BOLD} ┌──────────────────────────────────────┐${RESET}\n"
printf "${BOLD}${RESET} ${BOLD}${RESET}\n"
printf "${BOLD}${RESET} ${CYAN}${RESET} ${BOLD}Open Design${RESET} ${BOLD}${RESET}\n"
printf "${BOLD}${RESET} ${DIM}One-Click Installer${RESET} ${BOLD}${RESET}\n"
printf "${BOLD}${RESET} ${BOLD}${RESET}\n"
printf "${BOLD} └──────────────────────────────────────┘${RESET}\n"
printf "\n"
# ---------------------------------------------------------------------------
# 1. OS detection
# ---------------------------------------------------------------------------
OS="$(uname -s)"
ARCH="$(uname -m)"
case "$OS" in
Linux)
if [ -f /etc/os-release ]; then
# shellcheck disable=SC1091
. /etc/os-release
DISTRO="$ID"
DISTRO_VERSION="$VERSION_ID"
else
DISTRO="unknown"
DISTRO_VERSION=""
fi
step "OS: Linux ${DISTRO} ${DISTRO_VERSION} (${ARCH})"
;;
Darwin)
step "OS: macOS $(sw_vers -productVersion) (${ARCH})"
DISTRO="macos"
;;
*)
error "Unsupported OS: ${OS}. This script supports Linux and macOS."
exit 1
;;
esac
# ---------------------------------------------------------------------------
# 2. Container runtime detection (Docker or Podman)
# ---------------------------------------------------------------------------
CONTAINER_CMD=""
CONTAINER_RUNTIME=""
detect_container_runtime() {
if command -v docker >/dev/null 2>&1; then
CONTAINER_CMD="docker"
CONTAINER_RUNTIME="docker"
return 0
fi
if command -v podman >/dev/null 2>&1; then
CONTAINER_CMD="podman"
CONTAINER_RUNTIME="podman"
return 0
fi
return 1
}
detect_compose() {
if $CONTAINER_CMD compose version >/dev/null 2>&1; then
COMPOSE_CMD="${CONTAINER_CMD} compose"
return 0
fi
if [ "$CONTAINER_RUNTIME" = "podman" ] && command -v podman-compose >/dev/null 2>&1; then
COMPOSE_CMD="podman-compose"
return 0
fi
if [ "$CONTAINER_RUNTIME" = "docker" ] && command -v docker-compose >/dev/null 2>&1; then
COMPOSE_CMD="docker-compose"
return 0
fi
return 1
}
check_runtime_running() {
$CONTAINER_CMD info >/dev/null 2>&1
}
if ! detect_container_runtime; then
if [ "$OPT_SKIP_DOCKER" = "1" ]; then
error "No container runtime found (Docker or Podman) and --skip-docker-install was set."
exit 1
fi
warn "No container runtime found (Docker or Podman)."
case "$DISTRO" in
ubuntu|debian)
install_cmd="sudo apt-get update && sudo apt-get install -y docker.io docker-compose-plugin"
step "Install Docker with: ${install_cmd}"
step "Or Podman: sudo apt-get install -y podman podman-compose"
;;
fedora)
install_cmd="sudo dnf install -y docker docker-compose-plugin"
step "Install Docker with: ${install_cmd}"
step "Or Podman: sudo dnf install -y podman podman-compose"
;;
centos|rhel|rocky|alma)
install_cmd="sudo yum install -y docker docker-compose-plugin"
step "Install Docker with: ${install_cmd}"
step "Or Podman: sudo yum install -y podman podman-compose"
;;
macos)
install_cmd="brew install --cask docker"
step "Install Docker with: ${install_cmd}"
step "Or Podman: brew install podman && podman machine init && podman machine start"
;;
*)
error "Cannot auto-detect install method for: ${DISTRO}"
step "Install Docker: https://docs.docker.com/get-docker/"
step "Or Podman: https://podman.io/getting-started/installation"
exit 1
;;
esac
if [ "$NON_INTERACTIVE" = "0" ]; then
if prompt_confirm "Install Docker now?" 0; then
step "Running: ${install_cmd}"
eval "$install_cmd"
if ! detect_container_runtime; then
error "Installation did not succeed. Install manually and re-run."
exit 1
fi
else
error "A container runtime is required. Install Docker or Podman and re-run."
exit 1
fi
else
error "A container runtime is required in non-interactive mode. Install Docker or Podman first."
exit 1
fi
fi
if ! detect_compose; then
error "Container Compose is not available."
if [ "$CONTAINER_RUNTIME" = "podman" ]; then
case "$OS" in
Darwin) step "Install with: brew install podman-compose" ;;
*) step "Install with: sudo apt-get install podman-compose (or equivalent for your distro)" ;;
esac
else
step "Install the compose plugin: https://docs.docker.com/compose/install/"
fi
exit 1
fi
if ! check_runtime_running; then
warn "${CONTAINER_RUNTIME} is not running."
case "$DISTRO" in
ubuntu|debian|fedora|centos|rhel|rocky|alma)
if [ "$CONTAINER_RUNTIME" = "podman" ]; then
step "Start with: podman machine init && podman machine start"
else
step "Start with: sudo systemctl start docker"
fi
;;
macos)
if [ "$CONTAINER_RUNTIME" = "podman" ]; then
step "Start with: podman machine start"
else
step "Open Docker Desktop from your Applications folder."
fi
;;
esac
if [ "$NON_INTERACTIVE" = "0" ]; then
if prompt_confirm "Retry after starting ${CONTAINER_RUNTIME}?" 1; then
if ! check_runtime_running; then
error "${CONTAINER_RUNTIME} is still not running. Start it and re-run."
exit 1
fi
else
exit 1
fi
else
exit 1
fi
fi
RUNTIME_VERSION="$(${CONTAINER_CMD} --version 2>/dev/null || echo 'unknown')"
COMPOSE_VERSION="$(${COMPOSE_CMD} version 2>/dev/null || echo 'unknown')"
step "Runtime: ${CONTAINER_RUNTIME} ${RUNTIME_VERSION}"
step "Compose: ${COMPOSE_VERSION}"
# ---------------------------------------------------------------------------
# 2b. Check if Open Design is already running
# ---------------------------------------------------------------------------
if $CONTAINER_CMD ps --filter "name=open-design" --format '{{.Names}}' 2>/dev/null | grep -q 'open-design'; then
STATUS="$($CONTAINER_CMD inspect --format '{{.State.Status}}' open-design 2>/dev/null || echo 'unknown')"
IMAGE="$($CONTAINER_CMD inspect --format '{{.Config.Image}}' open-design 2>/dev/null || echo 'unknown')"
PORTS="$($CONTAINER_CMD port open-design 2>/dev/null || echo 'unknown')"
error "Open Design is already running."
printf "\n"
printf " Container: open-design\n"
printf " Status: %s\n" "$STATUS"
printf " Image: %s\n" "$IMAGE"
printf " Ports: %s\n" "$PORTS"
printf "\n"
step "To update: ${SCRIPT_DIR}/update.sh"
step "To remove: ${SCRIPT_DIR}/uninstall.sh"
printf "\n"
exit 1
fi
# ---------------------------------------------------------------------------
# 3. Port conflict detection
# ---------------------------------------------------------------------------
PORT="${OPT_PORT:-$DEFAULT_PORT}"
check_port_in_use() {
if command -v ss >/dev/null 2>&1; then
ss -tlnp 2>/dev/null | grep -q ":${1} " && return 0
elif command -v lsof >/dev/null 2>&1; then
lsof -i :"$1" 2>/dev/null | grep -q LISTEN && return 0
fi
return 1
}
if check_port_in_use "$PORT"; then
warn "Port ${PORT} is already in use."
if [ "$NON_INTERACTIVE" = "0" ]; then
_suggest=$((PORT + 1))
prompt_text "Enter a different port" "$_suggest"
PORT="$PROMPT_RESULT"
if check_port_in_use "$PORT"; then
error "Port ${PORT} is also in use. Please pick a free port."
exit 1
fi
else
error "Port ${PORT} is occupied. Use --port to specify a different one."
exit 1
fi
fi
# ---------------------------------------------------------------------------
# 4. Interactive prompts
# ---------------------------------------------------------------------------
IMAGE="${OPT_IMAGE:-$DEFAULT_IMAGE}"
ALLOWED_ORIGINS=""
MEM_LIMIT="$DEFAULT_MEM_LIMIT"
if [ "$NON_INTERACTIVE" = "0" ]; then
printf "\n"
prompt_text "Docker image" "$DEFAULT_IMAGE"
IMAGE="$PROMPT_RESULT"
prompt_text "Port" "$PORT"
PORT="$PROMPT_RESULT"
prompt_text "Allowed origins (CORS, comma-separated, or empty)" ""
ALLOWED_ORIGINS="$PROMPT_RESULT"
prompt_text "Memory limit" "$DEFAULT_MEM_LIMIT"
MEM_LIMIT="$PROMPT_RESULT"
if check_port_in_use "$PORT"; then
error "Port ${PORT} is already in use. Aborting."
exit 1
fi
fi
# ---------------------------------------------------------------------------
# 5. Generate .env
# ---------------------------------------------------------------------------
if [ -f "$ENV_FILE" ]; then
BACKUP="${ENV_FILE}.$(date +%Y%m%d%H%M%S).bak"
step "Existing .env found. Backing up to ${BACKUP}"
cp "$ENV_FILE" "$BACKUP"
fi
cat > "$ENV_FILE" << ENVFILE
# Generated by install.sh on $(date -u +%Y-%m-%dT%H:%M:%SZ)
OPEN_DESIGN_IMAGE=${IMAGE}
OPEN_DESIGN_PORT=${PORT}
OPEN_DESIGN_ALLOWED_ORIGINS=${ALLOWED_ORIGINS}
OPEN_DESIGN_MEM_LIMIT=${MEM_LIMIT}
NODE_OPTIONS=--max-old-space-size=192
ENVFILE
ok "Written ${ENV_FILE}"
# ---------------------------------------------------------------------------
# 6. Pull and start
# ---------------------------------------------------------------------------
step "Pulling image: ${IMAGE}"
$COMPOSE_CMD "${COMPOSE_FILES[@]}" pull
step "Starting Open Design..."
$COMPOSE_CMD "${COMPOSE_FILES[@]}" up -d --no-build
# ---------------------------------------------------------------------------
# 7. Health check
# ---------------------------------------------------------------------------
step "Waiting for health check (up to ${HEALTH_TIMEOUT}s)..."
HEALTH_URL="http://127.0.0.1:${PORT}/api/health"
HEALTH_OK=0
ELAPSED=0
while [ "$ELAPSED" -lt "$HEALTH_TIMEOUT" ]; do
if command -v curl >/dev/null 2>&1; then
HTTP_CODE="$(curl -s -o /dev/null -w '%{http_code}' "$HEALTH_URL" 2>/dev/null || echo '000')"
elif command -v wget >/dev/null 2>&1; then
HTTP_CODE="$(wget -q -O /dev/null --server-response "$HEALTH_URL" 2>&1 | grep 'HTTP/' | tail -1 | awk '{print $2}')"
else
HTTP_CODE="000"
fi
if [ "$HTTP_CODE" = "200" ]; then
HEALTH_OK=1
break
fi
sleep 2
ELAPSED=$((ELAPSED + 2))
done
if [ "$HEALTH_OK" = "1" ]; then
ok "Daemon is healthy (${HTTP_CODE} OK)"
else
warn "Health check did not pass within ${HEALTH_TIMEOUT}s."
step "Check status: ${COMPOSE_CMD} \"${COMPOSE_FILES[@]}\" logs"
fi
# ---------------------------------------------------------------------------
# 8. systemd unit (Linux only)
# ---------------------------------------------------------------------------
if [ "$OS" = "Linux" ] && [ "$OPT_NO_SYSTEMD" = "0" ]; then
if command -v systemctl >/dev/null 2>&1; then
SYSTEMD_DIR="${HOME}/.config/systemd/user"
SYSTEMD_UNIT="${SYSTEMD_DIR}/open-design.service"
mkdir -p "$SYSTEMD_DIR"
CONTAINER_BIN="$(command -v "$CONTAINER_CMD")"
{
echo "[Unit]"
echo "Description=Open Design daemon (${CONTAINER_RUNTIME} Compose)"
if [ "$CONTAINER_RUNTIME" = "docker" ]; then
echo "After=${CONTAINER_RUNTIME}.service"
echo "Requires=${CONTAINER_RUNTIME}.service"
fi
echo ""
echo "[Service]"
echo "Type=oneshot"
echo "RemainAfterExit=yes"
echo "WorkingDirectory=${DEPLOY_DIR}"
if [ -f "$OVERRIDE_FILE" ]; then
echo "ExecStart=${CONTAINER_BIN} compose -f ${COMPOSE_FILE} -f ${OVERRIDE_FILE} up -d --no-build"
echo "ExecStop=${CONTAINER_BIN} compose -f ${COMPOSE_FILE} -f ${OVERRIDE_FILE} down"
else
echo "ExecStart=${CONTAINER_BIN} compose -f ${COMPOSE_FILE} up -d --no-build"
echo "ExecStop=${CONTAINER_BIN} compose -f ${COMPOSE_FILE} down"
fi
echo "TimeoutStartSec=120"
echo ""
echo "[Install]"
echo "WantedBy=default.target"
} > "$SYSTEMD_UNIT"
systemctl --user daemon-reload
systemctl --user enable open-design 2>/dev/null || true
ok "systemd unit installed: open-design.service"
step "Manage with: systemctl --user start|stop|status open-design"
else
warn "systemd not found. Skipping service installation."
fi
fi
# ---------------------------------------------------------------------------
# 9. Summary
# ---------------------------------------------------------------------------
printf "\n"
printf "${BOLD}${GREEN} ── Installation Complete ──────────────────────────${RESET}\n"
printf "\n"
printf " URL: http://127.0.0.1:%s\n" "$PORT"
printf " Image: %s\n" "$IMAGE"
printf " Data volume: open_design_data\n"
printf " Config: %s\n" "$ENV_FILE"
if [ "$OS" = "Linux" ] && [ -f "${HOME}/.config/systemd/user/open-design.service" ]; then
printf " Service: systemd (open-design.service)\n"
fi
printf "\n"
printf " Next steps:\n"
printf " Update: %s/update.sh\n" "$SCRIPT_DIR"
printf " Uninstall: %s/uninstall.sh\n" "$SCRIPT_DIR"
printf " Logs: %s logs -f\n" "$COMPOSE_CMD"
printf "\n"
printf " Open http://127.0.0.1:%s in your browser\n\n" "$PORT"

233
deploy/scripts/uninstall.sh Executable file
View file

@ -0,0 +1,233 @@
#!/usr/bin/env bash
# Open Design — Uninstaller
# Stops and removes the Docker Compose deployment
#
# Usage: ./uninstall.sh [--keep-data] [--non-interactive]
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
DEPLOY_DIR="$(dirname "$SCRIPT_DIR")"
ENV_FILE="${DEPLOY_DIR}/.env"
COMPOSE_FILE="${DEPLOY_DIR}/docker-compose.yml"
OVERRIDE_FILE="${DEPLOY_DIR}/docker-compose.override.yml"
COMPOSE_FILES=(-f "$COMPOSE_FILE")
if [ -f "$OVERRIDE_FILE" ]; then
COMPOSE_FILES+=(-f "$OVERRIDE_FILE")
fi
# ---------------------------------------------------------------------------
# Colors & formatting
# ---------------------------------------------------------------------------
BOLD="" DIM="" RED="" GREEN="" YELLOW="" CYAN="" RESET=""
if [ -t 1 ]; then
BOLD="\033[1m" DIM="\033[2m" RED="\033[31m" GREEN="\033[32m"
YELLOW="\033[33m" CYAN="\033[36m" RESET="\033[0m"
fi
step() { printf " ${DIM}${RESET} %s\n" "$1"; }
ok() { printf " ${GREEN}${RESET} %s\n" "$1"; }
warn() { printf " ${YELLOW}!${RESET} %s\n" "$1" >&2; }
error() { printf " ${RED}${RESET} %s\n" "$1" >&2; }
info() { printf " ${CYAN}${RESET} %s\n" "$1"; }
prompt_text() {
_prompt="$1" _default="$2"
printf "%s [%s]: " "$_prompt" "$_default" >&2
read -r _val
PROMPT_RESULT="${_val:-$_default}"
}
prompt_confirm() {
_question="$1" _default="$2"
if [ "$NON_INTERACTIVE" = "1" ]; then
return 0
fi
_yn_default="y"
if [ "$_default" = "0" ]; then _yn_default="n"; fi
printf "%s [%s]: " "$_question" "$_yn_default" >&2
read -r _yn
case "$_yn" in
[Yy]*) return 0 ;;
[Nn]*) return 1 ;;
*) [ "$_default" = "1" ] && return 0; return 1 ;;
esac
}
# ---------------------------------------------------------------------------
# Detect container runtime
# ---------------------------------------------------------------------------
COMPOSE_CMD=""
if command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1; then
COMPOSE_CMD="docker compose"
elif command -v podman >/dev/null 2>&1 && podman compose version >/dev/null 2>&1; then
COMPOSE_CMD="podman compose"
elif command -v podman >/dev/null 2>&1 && command -v podman-compose >/dev/null 2>&1; then
COMPOSE_CMD="podman-compose"
elif command -v docker >/dev/null 2>&1 && command -v docker-compose >/dev/null 2>&1; then
COMPOSE_CMD="docker-compose"
else
error "No container runtime found. Install Docker or Podman."
exit 1
fi
RUNTIME="${COMPOSE_CMD%% *}"
NON_INTERACTIVE=0
KEEP_DATA=0
for arg in "$@"; do
case "$arg" in
--non-interactive) NON_INTERACTIVE=1 ;;
--keep-data) KEEP_DATA=1 ;;
--help|-h)
echo "Usage: uninstall.sh [options]"
echo " --keep-data Preserve the open_design_data volume"
echo " --non-interactive Skip confirmation prompts"
exit 0
;;
esac
done
# ---------------------------------------------------------------------------
# Banner
# ---------------------------------------------------------------------------
printf "\n"
printf "${BOLD} ┌──────────────────────────────────────┐${RESET}\n"
printf "${BOLD}${RESET} ${BOLD}${RESET}\n"
printf "${BOLD}${RESET} ${CYAN}${RESET} ${BOLD}Open Design${RESET} ${BOLD}${RESET}\n"
printf "${BOLD}${RESET} ${DIM}Uninstaller${RESET} ${BOLD}${RESET}\n"
printf "${BOLD}${RESET} ${BOLD}${RESET}\n"
printf "${BOLD} └──────────────────────────────────────┘${RESET}\n"
printf "\n"
# ---------------------------------------------------------------------------
# Find data volume (Compose prepends project name)
# ---------------------------------------------------------------------------
CONTAINER_NAME="${COMPOSE_CONTAINER_NAME:-open-design}"
VOLUME_BASE="${COMPOSE_VOLUME_NAME:-open_design_data}"
PROJECT_NAME="${COMPOSE_PROJECT_NAME:-open-design}"
DATA_VOLUME=""
for _vol in "${PROJECT_NAME}_${VOLUME_BASE}" "${VOLUME_BASE}"; do
if $RUNTIME volume inspect "$_vol" >/dev/null 2>&1; then
DATA_VOLUME="$_vol"
break
fi
done
# ---------------------------------------------------------------------------
# Backup user data
# ---------------------------------------------------------------------------
BACKUP_DIR=""
_do_backup() {
_dest="$1"
mkdir -p "$_dest"
step "Backing up user data to ${_dest}..."
# Try container cp first (works if container exists, running or stopped)
if $RUNTIME inspect "$CONTAINER_NAME" >/dev/null 2>&1; then
$RUNTIME cp "$CONTAINER_NAME":/app/.od/. "$_dest/" 2>/dev/null
fi
# If container cp didn't work or container doesn't exist, use temp container
if [ ! -f "${_dest}/app.sqlite" ] && [ -n "$DATA_VOLUME" ]; then
_image="$($RUNTIME images -q | head -1)"
if [ -n "$_image" ]; then
$RUNTIME run --rm \
-v "${DATA_VOLUME}:/source:ro" \
-v "${_dest}:/backup" \
--user root \
"$_image" \
sh -c "cp -r /source/. /backup/" 2>/dev/null || true
fi
fi
if [ -f "${_dest}/app.sqlite" ] || [ -d "${_dest}/projects" ]; then
ok "Backup saved to ${_dest}"
ok "Contents: app database, projects, artifacts, media config"
else
warn "No user data found to back up."
rm -rf "$_dest"
BACKUP_DIR=""
fi
}
if [ "$KEEP_DATA" = "0" ] && [ -n "$DATA_VOLUME" ]; then
if [ "$NON_INTERACTIVE" = "0" ]; then
_default_backup="${HOME}/open-design-backup-$(date +%Y%m%d%H%M%S)"
prompt_text "Backup destination" "$_default_backup"
BACKUP_DIR="$PROMPT_RESULT"
_do_backup "$BACKUP_DIR"
else
BACKUP_DIR="${HOME}/open-design-backup-$(date +%Y%m%d%H%M%S)"
_do_backup "$BACKUP_DIR"
fi
fi
# ---------------------------------------------------------------------------
# Confirm destructive action
# ---------------------------------------------------------------------------
if [ "$NON_INTERACTIVE" = "0" ]; then
printf "\n"
warn "Everything will now be removed: containers, data volume, and config."
printf " Continue? [y/N]: "
read -r _confirm
case "$_confirm" in
[Yy]*) ;;
*) step "Cancelled."; exit 0 ;;
esac
fi
# ---------------------------------------------------------------------------
# Stop and remove containers
# ---------------------------------------------------------------------------
if $COMPOSE_CMD "${COMPOSE_FILES[@]}" ps -q 2>/dev/null | grep -q .; then
step "Stopping containers..."
$COMPOSE_CMD "${COMPOSE_FILES[@]}" down
ok "Containers stopped."
else
# Try removing stopped container directly
if $RUNTIME inspect "$CONTAINER_NAME" >/dev/null 2>&1; then
step "Removing stopped container..."
$RUNTIME rm -f "$CONTAINER_NAME" 2>/dev/null || true
else
step "No containers found."
fi
fi
# Remove data volume (unless --keep-data)
if [ "$KEEP_DATA" = "0" ] && [ -n "$DATA_VOLUME" ]; then
step "Removing data volume ${DATA_VOLUME}..."
$RUNTIME volume rm "$DATA_VOLUME" >/dev/null 2>&1 || true
fi
# Remove systemd unit (Linux)
SYSTEMD_UNIT="${HOME}/.config/systemd/user/open-design.service"
if [ -f "$SYSTEMD_UNIT" ]; then
step "Removing systemd unit..."
systemctl --user disable --now open-design 2>/dev/null || true
rm -f "$SYSTEMD_UNIT"
systemctl --user daemon-reload
ok "systemd unit removed."
fi
# Remove .env
if [ -f "$ENV_FILE" ]; then
step "Removing ${ENV_FILE}..."
rm -f "$ENV_FILE"
fi
printf "\n"
printf "${BOLD}${GREEN} ── Uninstall Complete ────────────────────────────${RESET}\n"
printf "\n"
if [ "$KEEP_DATA" = "1" ]; then
info "Data volume was preserved."
step "Remove it manually: $RUNTIME volume rm $DATA_VOLUME"
elif [ -n "$BACKUP_DIR" ] && [ -d "$BACKUP_DIR" ]; then
info "Your data was backed up to: ${BACKUP_DIR}"
step "To restore, copy contents into a new deployment's data volume."
fi
printf "\n"

141
deploy/scripts/update.sh Executable file
View file

@ -0,0 +1,141 @@
#!/usr/bin/env bash
# Open Design — Updater
# Pulls the latest image and restarts the service
#
# Usage: ./update.sh [--image <ref>] [--non-interactive]
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
DEPLOY_DIR="$(dirname "$SCRIPT_DIR")"
COMPOSE_FILE="${DEPLOY_DIR}/docker-compose.yml"
OVERRIDE_FILE="${DEPLOY_DIR}/docker-compose.override.yml"
HEALTH_TIMEOUT=60
COMPOSE_FILES=(-f "$COMPOSE_FILE")
if [ -f "$OVERRIDE_FILE" ]; then
COMPOSE_FILES+=(-f "$OVERRIDE_FILE")
fi
# ---------------------------------------------------------------------------
# Colors & formatting
# ---------------------------------------------------------------------------
BOLD="" DIM="" RED="" GREEN="" YELLOW="" CYAN="" RESET=""
if [ -t 1 ]; then
BOLD="\033[1m" DIM="\033[2m" RED="\033[31m" GREEN="\033[32m"
YELLOW="\033[33m" CYAN="\033[36m" RESET="\033[0m"
fi
step() { printf " ${DIM}${RESET} %s\n" "$1"; }
ok() { printf " ${GREEN}${RESET} %s\n" "$1"; }
warn() { printf " ${YELLOW}!${RESET} %s\n" "$1" >&2; }
error() { printf " ${RED}${RESET} %s\n" "$1" >&2; }
info() { printf " ${CYAN}${RESET} %s\n" "$1"; }
# ---------------------------------------------------------------------------
# Detect container runtime
# ---------------------------------------------------------------------------
COMPOSE_CMD=""
if command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1; then
COMPOSE_CMD="docker compose"
elif command -v podman >/dev/null 2>&1 && podman compose version >/dev/null 2>&1; then
COMPOSE_CMD="podman compose"
elif command -v podman >/dev/null 2>&1 && command -v podman-compose >/dev/null 2>&1; then
COMPOSE_CMD="podman-compose"
elif command -v docker >/dev/null 2>&1 && command -v docker-compose >/dev/null 2>&1; then
COMPOSE_CMD="docker-compose"
else
error "No container runtime found. Install Docker or Podman."
exit 1
fi
OPT_IMAGE=""
NON_INTERACTIVE=0
while [ $# -gt 0 ]; do
case "$1" in
--image) shift; OPT_IMAGE="$1" ;;
--image=*) OPT_IMAGE="${1#--image=}" ;;
--non-interactive) NON_INTERACTIVE=1 ;;
--help|-h)
echo "Usage: update.sh [options]"
echo " --image <ref> Pull a specific image instead of latest"
echo " --non-interactive Skip confirmation prompts"
exit 0
;;
esac
shift
done
# ---------------------------------------------------------------------------
# Banner
# ---------------------------------------------------------------------------
printf "\n"
printf "${BOLD} ┌──────────────────────────────────────┐${RESET}\n"
printf "${BOLD}${RESET} ${BOLD}${RESET}\n"
printf "${BOLD}${RESET} ${CYAN}${RESET} ${BOLD}Open Design${RESET} ${BOLD}${RESET}\n"
printf "${BOLD}${RESET} ${DIM}Updater${RESET} ${BOLD}${RESET}\n"
printf "${BOLD}${RESET} ${BOLD}${RESET}\n"
printf "${BOLD} └──────────────────────────────────────┘${RESET}\n"
printf "\n"
# Read current port from .env if it exists
PORT=7456
ENV_FILE="${DEPLOY_DIR}/.env"
if [ -f "$ENV_FILE" ]; then
_port="$(grep '^OPEN_DESIGN_PORT=' "$ENV_FILE" | cut -d= -f2)"
if [ -n "$_port" ]; then PORT="$_port"; fi
fi
# Override image if specified
if [ -n "$OPT_IMAGE" ]; then
export OPEN_DESIGN_IMAGE="$OPT_IMAGE"
fi
# Pull latest image
step "Pulling latest image..."
$COMPOSE_CMD "${COMPOSE_FILES[@]}" pull
# Restart with new image
step "Restarting service..."
$COMPOSE_CMD "${COMPOSE_FILES[@]}" up -d --no-build
# Health check
step "Waiting for health check (up to ${HEALTH_TIMEOUT}s)..."
HEALTH_URL="http://127.0.0.1:${PORT}/api/health"
HEALTH_OK=0
ELAPSED=0
while [ "$ELAPSED" -lt "$HEALTH_TIMEOUT" ]; do
if command -v curl >/dev/null 2>&1; then
HTTP_CODE="$(curl -s -o /dev/null -w '%{http_code}' "$HEALTH_URL" 2>/dev/null || echo '000')"
elif command -v wget >/dev/null 2>&1; then
HTTP_CODE="$(wget -q -O /dev/null --server-response "$HEALTH_URL" 2>&1 | grep 'HTTP/' | tail -1 | awk '{print $2}')"
else
HTTP_CODE="000"
fi
if [ "$HTTP_CODE" = "200" ]; then
HEALTH_OK=1
break
fi
sleep 2
ELAPSED=$((ELAPSED + 2))
done
if [ "$HEALTH_OK" = "1" ]; then
ok "Update complete. Daemon is healthy."
else
warn "Health check did not pass."
step "Check logs: ${COMPOSE_CMD} \"${COMPOSE_FILES[@]}\" logs"
fi
# Clean up dangling images
step "Cleaning up old images..."
# shellcheck disable=SC2086
${COMPOSE_CMD%% *}$ image prune -f >/dev/null 2>&1 || true
printf "\n"
printf "${BOLD}${GREEN} ── Update Complete ───────────────────────────────${RESET}\n"
printf "\n"
printf " URL: http://127.0.0.1:%s\n" "$PORT"
printf "\n"

View file

@ -0,0 +1,222 @@
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { test } from 'node:test';
import assert from 'node:assert/strict';
const execFileAsync = promisify(execFile);
const repoRoot = join(import.meta.dirname, '../..');
const installScript = join(repoRoot, 'deploy/scripts/install.sh');
const uninstallScript = join(repoRoot, 'deploy/scripts/uninstall.sh');
const updateScript = join(repoRoot, 'deploy/scripts/update.sh');
// Skip entire suite if Docker is not available
function isDockerAvailable(): Promise<boolean> {
return new Promise((resolve) => {
execFile('docker', ['info'], { timeout: 5000 }, (err) => resolve(!err));
});
}
const dockerAvailable = await isDockerAvailable();
// Unique test identifier to isolate from real deployments
const TEST_ID = `od-test-${process.pid}`;
async function waitForHealth(port: number, timeoutMs = 30000): Promise<boolean> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
const resp = await fetch(`http://127.0.0.1:${port}/api/health`);
if (resp.ok) return true;
} catch {}
await new Promise((r) => setTimeout(r, 1000));
}
return false;
}
interface TestContext {
tmpDir: string;
port: number;
projectName: string;
containerName: string;
volumeName: string;
}
async function setupTestDir(port: number): Promise<TestContext> {
const projectName = `${TEST_ID}-${port}`;
const containerName = projectName;
const volumeName = `${projectName}-data`;
const tmpDir = await mkdtemp(join(tmpdir(), `${TEST_ID}-`));
await execFileAsync('cp', ['-r', join(repoRoot, 'deploy/.'), tmpDir]);
// Write a compose override that replaces the hardcoded names
const override = {
name: projectName,
services: {
'open-design': {
container_name: containerName,
volumes: [`${volumeName}:/app/.od`],
},
},
volumes: {
[volumeName]: {},
},
};
await writeFile(
join(tmpDir, 'docker-compose.override.yml'),
JSON.stringify(override),
);
return { tmpDir, port, projectName, containerName, volumeName };
}
function testEnv(ctx: TestContext): Record<string, string> {
return {
...process.env as Record<string, string>,
COMPOSE_PROJECT_NAME: ctx.projectName,
};
}
async function teardownTestDir(ctx: TestContext): Promise<void> {
const script = join(ctx.tmpDir, 'scripts/uninstall.sh');
const override = join(ctx.tmpDir, 'docker-compose.override.yml');
// Run uninstall with the same override file so it targets the test container
try {
await readFile(override);
await execFileAsync('bash', [script, '--non-interactive'], {
timeout: 60_000,
env: testEnv(ctx),
});
} catch {}
// Force-remove the named volume as a safety net
try {
await execFileAsync('docker', ['volume', 'rm', '-f', ctx.volumeName], {
timeout: 10_000,
});
} catch {}
if (ctx.tmpDir) await rm(ctx.tmpDir, { recursive: true, force: true });
}
// ---------------------------------------------------------------------------
// help flag tests — do not require Docker
// ---------------------------------------------------------------------------
test('install.sh --help exits 0', async () => {
const { stdout } = await execFileAsync('bash', [installScript, '--help']);
assert.match(stdout, /Usage/);
assert.match(stdout, /--non-interactive/);
assert.match(stdout, /--port/);
});
test('uninstall.sh --help exits 0', async () => {
const { stdout } = await execFileAsync('bash', [uninstallScript, '--help']);
assert.match(stdout, /Usage/);
assert.match(stdout, /--keep-data/);
});
test('update.sh --help exits 0', async () => {
const { stdout } = await execFileAsync('bash', [updateScript, '--help']);
assert.match(stdout, /Usage/);
assert.match(stdout, /--image/);
});
// ---------------------------------------------------------------------------
// Docker integration tests — skipped when Docker is unavailable
// ---------------------------------------------------------------------------
test('install.sh --non-interactive creates .env and starts container', { skip: !dockerAvailable ? 'Docker not available' : false }, async () => {
const ctx = await setupTestDir(17456);
try {
const script = join(ctx.tmpDir, 'scripts/install.sh');
await execFileAsync('bash', [
script,
'--non-interactive',
`--port=${ctx.port}`,
'--no-systemd',
], {
timeout: 120_000,
env: testEnv(ctx),
});
// .env should contain the port
const envContent = await readFile(join(ctx.tmpDir, '.env'), 'utf8');
assert.match(envContent, new RegExp(`OPEN_DESIGN_PORT=${ctx.port}`));
// Container should be healthy
const healthy = await waitForHealth(ctx.port, 60_000);
assert.ok(healthy, 'daemon did not become healthy within 60s');
} finally {
await teardownTestDir(ctx);
}
});
test('update.sh restarts service and remains healthy', { skip: !dockerAvailable ? 'Docker not available' : false }, async () => {
const ctx = await setupTestDir(17457);
try {
const installRun = join(ctx.tmpDir, 'scripts/install.sh');
await execFileAsync('bash', [
installRun,
'--non-interactive',
`--port=${ctx.port}`,
'--no-systemd',
], {
timeout: 120_000,
env: testEnv(ctx),
});
await waitForHealth(ctx.port, 30_000);
// Update
await execFileAsync('bash', [
join(ctx.tmpDir, 'scripts/update.sh'),
], {
timeout: 120_000,
cwd: ctx.tmpDir,
env: testEnv(ctx),
});
const healthy = await waitForHealth(ctx.port, 30_000);
assert.ok(healthy, 'daemon not healthy after update');
} finally {
await teardownTestDir(ctx);
}
});
test('uninstall.sh removes containers and .env', { skip: !dockerAvailable ? 'Docker not available' : false }, async () => {
const ctx = await setupTestDir(17458);
try {
const installRun = join(ctx.tmpDir, 'scripts/install.sh');
await execFileAsync('bash', [
installRun,
'--non-interactive',
`--port=${ctx.port}`,
'--no-systemd',
], {
timeout: 120_000,
env: testEnv(ctx),
});
// Uninstall
await execFileAsync('bash', [
join(ctx.tmpDir, 'scripts/uninstall.sh'),
'--non-interactive',
], {
timeout: 60_000,
env: testEnv(ctx),
});
// .env should be gone
const envGone = await readFile(join(ctx.tmpDir, '.env'), 'utf8').catch(() => null);
assert.equal(envGone, null, '.env should have been removed');
// Container should not be running
const { stdout: containers } = await execFileAsync('docker', ['ps', '--format', '{{.Names}}']);
assert.ok(!containers.includes(ctx.containerName), 'container should not be running after uninstall');
} finally {
await teardownTestDir(ctx);
}
});

202
docs/install-guide.md Normal file
View file

@ -0,0 +1,202 @@
# One-Click Install Guide
**Parent:** [`spec.md`](spec.md) · **Siblings:** [`self-hosting.md`](self-hosting.md) · [`network-security.md`](network-security.md)
Deploy Open Design on Linux or macOS with a single command. The installer wraps the existing Docker Compose stack — no build step required.
## Quick reference
Clone the repository and run the installer:
```bash
git clone https://github.com/nexu-io/open-design.git
cd open-design
bash deploy/scripts/install.sh
```
## Prerequisites
The only requirement is Docker with the Compose plugin.
| Platform | Minimum version | Install |
|----------|----------------|---------|
| Docker Engine | 24.0 | [docs.docker.com/engine/install](https://docs.docker.com/engine/install/) |
| Docker Compose plugin | 2.20 | Bundled with Docker Desktop; `apt install docker-compose-plugin` on Linux |
| Docker Desktop (macOS/Windows) | 4.25 | [docker.com/products/docker-desktop](https://www.docker.com/products/docker-desktop/) |
The installer checks for Docker and offers to install it automatically on Ubuntu/Debian, Fedora, and macOS (via Homebrew). Use `--skip-docker-install` to skip this step.
> **MCP note:** Docker/Compose installs run the daemon inside the container. The MCP client snippets shown in Settings are stdio/local-path based and require a local/source install for now. Container-friendly MCP transport will be added in a follow-up.
## Interactive install walkthrough
Running the installer without flags launches an interactive wizard:
```
╔══════════════════════════════════════╗
║ O P E N D E S I G N ║
║ One-Click Installer ║
╚══════════════════════════════════════╝
[open-design] OS: Linux ubuntu 24.04 (x86_64)
[open-design] Docker: Docker version 26.1.3, build b72abbb
[open-design] Compose: Docker Compose version v2.27.1
Docker image [docker.io/vanjayak/open-design:latest]:
Port [7456]:
Allowed origins (CORS, comma-separated, or empty) []:
Memory limit [384m]:
[open-design] Pulling image: docker.io/vanjayak/open-design:latest
[open-design] Starting Open Design...
[open-design] Waiting for health check (up to 60s)...
[open-design] Daemon is healthy (200 OK)
```
### What each prompt does
| Prompt | Default | Notes |
|--------|---------|-------|
| **Docker image** | `docker.io/vanjayak/open-design:latest` | Pin a digest for reproducibility: `docker.io/vanjayak/open-design@sha256:<digest>` |
| **Port** | `7456` | The port the daemon listens on. Must not be in use. |
| **Allowed origins** | _(empty)_ | CORS origins for reverse-proxy setups. See [`network-security.md`](network-security.md). Leave empty for localhost-only use. |
| **Memory limit** | `384m` | Container memory cap. Raise for large concurrent agent runs. |
After you confirm, the installer:
1. Writes a `deploy/.env` file (backs up any existing one).
2. Runs `docker compose pull` to fetch the image.
3. Runs `docker compose up -d --no-build` to start the container.
4. Polls `/api/health` for up to 60 seconds to confirm the daemon is ready.
5. On Linux: installs a `systemd --user` unit so the service starts on login.
## Non-interactive install
For CI, headless servers, and automated provisioning:
```bash
bash deploy/scripts/install.sh --non-interactive [--port 7456] [--image <ref>] [--no-systemd]
```
All prompts are skipped and defaults are used. If Docker is not installed, the script exits with an error instead of offering to install it.
### All flags
**`install.sh`**
| Flag | Description |
|------|-------------|
| `--non-interactive` | Skip all prompts |
| `--port <n>` | Host port (default: `7456`) |
| `--image <ref>` | Docker image reference |
| `--skip-docker-install` | Never attempt to install Docker |
| `--no-systemd` | Skip systemd unit creation |
## Service management
### Linux (systemd)
The installer creates a `systemd --user` unit that wraps Docker Compose. No `sudo` required.
```bash
# Check status
systemctl --user status open-design
# Start / stop / restart
systemctl --user start open-design
systemctl --user stop open-design
systemctl --user restart open-design
# View logs
journalctl --user -u open-design -f
# Disable auto-start
systemctl --user disable open-design
# Re-enable auto-start
systemctl --user enable open-design
```
To skip systemd unit creation, pass `--no-systemd` to the installer.
### macOS (Docker Desktop)
Docker Desktop manages the container lifecycle. Use Docker Desktop's dashboard to start, stop, or restart the `open-design` container, or use the CLI:
```bash
# Using docker compose directly
docker compose -f deploy/docker-compose.yml start
docker compose -f deploy/docker-compose.yml stop
docker compose -f deploy/docker-compose.yml logs -f
```
## Update
Pull the latest image and restart with a single command:
```bash
bash deploy/scripts/update.sh
```
To update to a specific image:
```bash
bash deploy/scripts/update.sh --image=docker.io/vanjayak/open-design@sha256:<digest>
```
The update script:
1. Pulls the new image.
2. Restarts the container with `docker compose up -d --no-build`.
3. Waits for `/api/health` to return 200.
4. Prunes dangling old images.
## Uninstall
```bash
# Remove containers and data
bash deploy/scripts/uninstall.sh
# Remove containers but keep data volume
bash deploy/scripts/uninstall.sh --keep-data
```
The uninstaller:
1. Stops and removes containers (`docker compose down`), then removes the data volume separately.
2. On Linux: disables and removes the systemd unit.
3. Removes `deploy/.env`.
> **Data:** By default, the `open_design_data` volume (projects, artifacts, config) is also deleted. Pass `--keep-data` to preserve it. Remove the volume manually later: `docker volume rm open_design_data`.
## Configuration
All settings live in `deploy/.env`. Edit it directly or re-run the installer to regenerate it.
| Variable | Default | Description |
|----------|---------|-------------|
| `OPEN_DESIGN_IMAGE` | `docker.io/vanjayak/open-design:latest` | Full image reference |
| `OPEN_DESIGN_PORT` | `7456` | Host-side port (bound to `127.0.0.1`) |
| `OPEN_DESIGN_ALLOWED_ORIGINS` | _(empty)_ | CORS origins for reverse-proxy setups |
| `OPEN_DESIGN_MEM_LIMIT` | `384m` | Container memory cap |
| `NODE_OPTIONS` | `--max-old-space-size=192` | Node.js heap cap inside the container |
The container always binds `127.0.0.1:<port>:7456` — the daemon is never directly exposed to the network. To allow remote access, put an authenticated reverse proxy in front. See [`network-security.md`](network-security.md).
## Troubleshooting
| Problem | Likely cause | Fix |
|---------|-------------|-----|
| `Docker is not installed` | Docker not on PATH | Install Docker Desktop or Docker Engine |
| `Docker daemon is not running` | Docker Desktop not started | Open Docker Desktop or run `sudo systemctl start docker` |
| `Port 7456 is already in use` | Another service on that port | Re-run with `--port 8080` |
| Health check times out | Image pull slow or daemon slow to start | Wait and check `docker compose -f deploy/docker-compose.yml logs` |
| `Permission denied` on install.sh | Script not executable | Run `chmod +x deploy/scripts/install.sh` |
| systemd unit not created | `systemd` not found | Omit `--no-systemd` if systemd is available, or manage via Docker CLI |
| `.env` has wrong port after re-install | Old backup not restored | Edit `deploy/.env` directly or delete it and re-run |
| Container exits immediately | Image incompatibility | Check `docker compose -f deploy/docker-compose.yml logs` for errors |
## References
- Docker Compose config: [`deploy/docker-compose.yml`](../deploy/docker-compose.yml)
- Environment template: [`deploy/.env.example`](../deploy/.env.example)
- Self-hosting topologies (PM2, systemd native): [`docs/self-hosting.md`](self-hosting.md)
- Network security and remote access: [`docs/network-security.md`](network-security.md)