Publish deployment and backup scripts

This commit is contained in:
2026-04-11 00:21:08 +02:00
parent 86204e0836
commit 9de9acec48
27 changed files with 3709 additions and 206 deletions

View File

@@ -1,20 +0,0 @@
# openclaw-deploy-scripts
Deployment and backup scripts for services on this host.
## Highlights
- Docker and local deployment helpers under `/opt/deploy`
- Shared Traefik helpers in `common.sh`
- Local OpenClaw deployment now supports automatic Traefik wiring
- Companion how-to docs live under `how-to/`
## Docs
- `how-to/traefik-openclaw.md`
- `how-to/mattermost-openclaw.md`
- `how-to/gitea-openclaw-bootstrap.md`
## Notes
These scripts are evolving toward unattended rebuilds.

50
backup-agenticseek.sh Executable file
View File

@@ -0,0 +1,50 @@
#!/usr/bin/env bash
# ==============================================================================
# AgenticSeek Backup Script
# ==============================================================================
# Usage: backup-agenticseek.sh
# backup-agenticseek.sh --cold
# ==============================================================================
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "${SCRIPT_DIR}/common.sh"
parse_args "$@"
SERVICE_NAME="agenticseek"
BASE_DIR="/opt/${SERVICE_NAME}"
CONFIG_NAME="${SERVICE_NAME}"
BACKUP_DIR="/opt/backup"
STAMP="$(date +%Y%m%d-%H%M%S)"
BACKUP_FILE="${BACKUP_DIR}/backup-${SERVICE_NAME}-${STAMP}.tar.gz"
[[ -d "$BASE_DIR" ]] || fatal "AgenticSeek is not deployed at ${BASE_DIR}."
mkdir -p "$BACKUP_DIR"
# --- Cold backup: stop containers ---
if [[ "$ARG_COLD" == "1" ]]; then
info "Running cold backup (stopping containers)..."
(cd "$BASE_DIR" && docker compose down)
fi
# --- Create archive ---
info "Creating archive..."
tar czf "$BACKUP_FILE" -C / \
"opt/${SERVICE_NAME}" \
"opt/traefik/dynamic/${CONFIG_NAME}.yml" 2>/dev/null || \
tar czf "$BACKUP_FILE" -C / \
"opt/${SERVICE_NAME}"
# --- Cold: restart ---
if [[ "$ARG_COLD" == "1" ]]; then
(cd "$BASE_DIR" && docker compose up -d)
ok "Containers restarted."
fi
# --- Rotate old backups (keep last 5) ---
# shellcheck disable=SC2012
ls -t "${BACKUP_DIR}/backup-${SERVICE_NAME}-"*.tar.gz 2>/dev/null | tail -n +6 | xargs -r rm --
ok "Backup: ${BACKUP_FILE} ($(du -h "$BACKUP_FILE" | cut -f1))"

120
backup-all.sh Executable file
View File

@@ -0,0 +1,120 @@
#!/usr/bin/env bash
# ==============================================================================
# Full Server Backup Script
# ==============================================================================
# Discovers deployed services by scanning /opt/*/docker-compose.yml and
# local service units, runs the appropriate backup script for each, then
# creates a combined full-server archive.
#
# Usage: backup-all.sh
# backup-all.sh --cold
# ==============================================================================
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "${SCRIPT_DIR}/common.sh"
parse_args "$@"
BACKUP_DIR="/opt/backup"
STAMP="$(date +%Y%m%d-%H%M%S)"
COLD_FLAG=""
[[ "$ARG_COLD" == "1" ]] && COLD_FLAG="--cold"
mkdir -p "$BACKUP_DIR"
echo ""
echo -e "${CYAN}══════════════════════════════════════════════${NC}"
echo -e "${CYAN} Full Server Backup${NC}"
echo -e "${CYAN}══════════════════════════════════════════════${NC}"
if [[ -n "$COLD_FLAG" ]]; then
echo -e " Mode: ${YELLOW}Cold (services will be stopped)${NC}"
else
echo -e " Mode: Hot (services stay running)"
fi
echo -e "${CYAN}══════════════════════════════════════════════${NC}"
echo ""
failed=0
# --- Traefik ---
if [[ -d /opt/traefik ]]; then
info "Backing up traefik..."
"${SCRIPT_DIR}/backup-traefik.sh" $COLD_FLAG || { warn "Traefik backup failed."; (( failed++ )); }
fi
# --- Mattermost ---
if [[ -f /opt/mattermost/docker-compose.yml ]]; then
info "Backing up mattermost..."
"${SCRIPT_DIR}/backup-mattermost.sh" $COLD_FLAG || { warn "Mattermost backup failed."; (( failed++ )); }
fi
# --- Gitea ---
if [[ -f /opt/gitea/docker-compose.yml ]]; then
info "Backing up gitea..."
"${SCRIPT_DIR}/backup-gitea.sh" $COLD_FLAG || { warn "Gitea backup failed."; (( failed++ )); }
fi
# --- Hermes ---
if [[ -f /opt/hermes/docker-compose.yml ]] || [[ -f /etc/systemd/system/hermes.service ]]; then
info "Backing up hermes..."
"${SCRIPT_DIR}/backup-hermes.sh" $COLD_FLAG || { warn "Hermes backup failed."; (( failed++ )); }
fi
# --- Paperclip ---
if [[ -d /opt/paperclip ]]; then
info "Backing up paperclip..."
"${SCRIPT_DIR}/backup-paperclip.sh" $COLD_FLAG || { warn "Paperclip backup failed."; (( failed++ )); }
fi
# --- OpenClaw ---
if [[ -d /opt/openclaw ]]; then
info "Backing up openclaw..."
"${SCRIPT_DIR}/backup-openclaw.sh" $COLD_FLAG || { warn "OpenClaw backup failed."; (( failed++ )); }
fi
# --- Open WebUI ---
if [[ -f /opt/open-webui/docker-compose.yml ]]; then
info "Backing up open-webui..."
"${SCRIPT_DIR}/backup-open-webui.sh" $COLD_FLAG || { warn "Open WebUI backup failed."; (( failed++ )); }
fi
# --- AgenticSeek ---
if [[ -f /opt/agenticseek/docker-compose.yml ]]; then
info "Backing up agenticseek..."
"${SCRIPT_DIR}/backup-agenticseek.sh" $COLD_FLAG || { warn "AgenticSeek backup failed."; (( failed++ )); }
fi
# --- Web instances (discover by /opt/*/docker-compose.yml with web- container) ---
for dir in /opt/*/; do
dir_name="$(basename "$dir")"
compose="${dir}docker-compose.yml"
if [[ -f "$compose" ]] && grep -q "web-${dir_name}" "$compose" 2>/dev/null; then
info "Backing up web instance: ${dir_name}..."
"${SCRIPT_DIR}/backup-web.sh" --dir "$dir_name" $COLD_FLAG || { warn "Web (${dir_name}) backup failed."; (( failed++ )); }
fi
done
# --- Combined full-server archive ---
FULL_BACKUP="${BACKUP_DIR}/backup-all-${STAMP}.tar.gz"
info "Creating combined full-server archive..."
tar czf "$FULL_BACKUP" -C / opt/ 2>/dev/null || warn "Full archive creation failed."
# --- Rotate full archives (keep last 3) ---
# shellcheck disable=SC2012
ls -t "${BACKUP_DIR}/backup-all-"*.tar.gz 2>/dev/null | tail -n +4 | xargs -r rm --
# --- Summary ---
echo ""
echo -e "${GREEN}══════════════════════════════════════════════${NC}"
echo -e "${GREEN} Backup complete${NC}"
echo -e "${GREEN}══════════════════════════════════════════════${NC}"
if [[ -f "$FULL_BACKUP" ]]; then
echo -e " Full: ${FULL_BACKUP} ($(du -h "$FULL_BACKUP" | cut -f1))"
fi
echo -e " Dir: ${BACKUP_DIR}/"
if (( failed > 0 )); then
echo -e " ${YELLOW}Warnings: ${failed} backup(s) had issues${NC}"
fi
echo -e "${GREEN}══════════════════════════════════════════════${NC}"
echo ""

77
backup-gitea.sh Executable file
View File

@@ -0,0 +1,77 @@
#!/usr/bin/env bash
# ==============================================================================
# Gitea Backup Script
# ==============================================================================
# Creates a backup archive of Gitea + PostgreSQL data and Traefik config.
#
# Hot backup (default): runs gitea dump + pg_dump while containers stay up.
# Cold backup (--cold): stops containers, tars data, restarts.
#
# Usage: backup-gitea.sh
# backup-gitea.sh --cold
# ==============================================================================
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "${SCRIPT_DIR}/common.sh"
parse_args "$@"
SERVICE_NAME="gitea"
BASE_DIR="/opt/${SERVICE_NAME}"
CONFIG_NAME="${SERVICE_NAME}"
BACKUP_DIR="/opt/backup"
STAMP="$(date +%Y%m%d-%H%M%S)"
BACKUP_FILE="${BACKUP_DIR}/backup-${SERVICE_NAME}-${STAMP}.tar.gz"
[[ -d "$BASE_DIR" ]] || fatal "Gitea is not deployed at ${BASE_DIR}."
mkdir -p "$BACKUP_DIR"
# --- Hot backup: application-level dumps ---
if [[ "$ARG_COLD" != "1" ]]; then
info "Running hot backup (containers stay up)..."
# Gitea built-in dump
info "Running gitea dump..."
mkdir -p "${BASE_DIR}/gitea/data/backup"
docker exec -u git gitea bash -c \
'/app/gitea/gitea dump -c /data/gitea/conf/app.ini --type tar.gz -f /data/backup/gitea-dump.tar.gz' \
2>/dev/null || warn "Gitea dump failed — archive will contain live data files only."
# PostgreSQL dump
info "Running pg_dump..."
docker exec gitea-postgres pg_dump -U gitea -d gitea \
> "${BASE_DIR}/postgres/dump.sql" \
2>/dev/null || warn "pg_dump failed — archive will contain live data files only."
fi
# --- Cold backup: stop containers ---
if [[ "$ARG_COLD" == "1" ]]; then
info "Running cold backup (stopping containers)..."
(cd "$BASE_DIR" && docker compose down)
fi
# --- Create archive ---
info "Creating archive..."
tar czf "$BACKUP_FILE" -C / \
"opt/${SERVICE_NAME}" \
"opt/traefik/dynamic/${CONFIG_NAME}.yml" 2>/dev/null || \
tar czf "$BACKUP_FILE" -C / \
"opt/${SERVICE_NAME}"
# --- Cold: restart ---
if [[ "$ARG_COLD" == "1" ]]; then
(cd "$BASE_DIR" && docker compose up -d)
ok "Containers restarted."
fi
# --- Clean up dump files ---
rm -f "${BASE_DIR}/gitea/data/backup/gitea-dump.tar.gz" 2>/dev/null
rm -f "${BASE_DIR}/postgres/dump.sql" 2>/dev/null
# --- Rotate old backups (keep last 5) ---
# shellcheck disable=SC2012
ls -t "${BACKUP_DIR}/backup-${SERVICE_NAME}-"*.tar.gz 2>/dev/null | tail -n +6 | xargs -r rm --
ok "Backup: ${BACKUP_FILE} ($(du -h "$BACKUP_FILE" | cut -f1))"

55
backup-hermes.sh Executable file
View File

@@ -0,0 +1,55 @@
#!/usr/bin/env bash
# ==============================================================================
# Hermes Agent Backup Script
# ==============================================================================
# Works for both local and Docker deployments.
#
# Usage: backup-hermes.sh
# backup-hermes.sh --cold
# ==============================================================================
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "${SCRIPT_DIR}/common.sh"
parse_args "$@"
SERVICE_NAME="hermes"
BASE_DIR="/opt/${SERVICE_NAME}"
BACKUP_DIR="/opt/backup"
STAMP="$(date +%Y%m%d-%H%M%S)"
BACKUP_FILE="${BACKUP_DIR}/backup-${SERVICE_NAME}-${STAMP}.tar.gz"
[[ -d "$BASE_DIR" ]] || fatal "Hermes is not deployed at ${BASE_DIR}."
mkdir -p "$BACKUP_DIR"
# --- Cold backup: stop service ---
if [[ "$ARG_COLD" == "1" ]]; then
info "Running cold backup (stopping service)..."
if [[ -f "${BASE_DIR}/docker-compose.yml" ]]; then
(cd "$BASE_DIR" && docker compose down)
elif systemctl is-active --quiet "${SERVICE_NAME}.service" 2>/dev/null; then
systemctl stop "${SERVICE_NAME}.service"
fi
fi
# --- Create archive (no traefik config for hermes) ---
info "Creating archive..."
tar czf "$BACKUP_FILE" -C / "opt/${SERVICE_NAME}"
# --- Cold: restart ---
if [[ "$ARG_COLD" == "1" ]]; then
if [[ -f "${BASE_DIR}/docker-compose.yml" ]]; then
(cd "$BASE_DIR" && docker compose up -d)
elif [[ -f "/etc/systemd/system/${SERVICE_NAME}.service" ]]; then
systemctl start "${SERVICE_NAME}.service"
fi
ok "Service restarted."
fi
# --- Rotate old backups (keep last 5) ---
# shellcheck disable=SC2012
ls -t "${BACKUP_DIR}/backup-${SERVICE_NAME}-"*.tar.gz 2>/dev/null | tail -n +6 | xargs -r rm --
ok "Backup: ${BACKUP_FILE} ($(du -h "$BACKUP_FILE" | cut -f1))"

66
backup-mattermost.sh Executable file
View File

@@ -0,0 +1,66 @@
#!/usr/bin/env bash
# ==============================================================================
# Mattermost Backup Script
# ==============================================================================
# Hot backup (default): pg_dump while containers stay up.
# Cold backup (--cold): stops containers, tars data, restarts.
#
# Usage: backup-mattermost.sh
# backup-mattermost.sh --cold
# ==============================================================================
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "${SCRIPT_DIR}/common.sh"
parse_args "$@"
SERVICE_NAME="mattermost"
BASE_DIR="/opt/${SERVICE_NAME}"
CONFIG_NAME="${SERVICE_NAME}"
BACKUP_DIR="/opt/backup"
STAMP="$(date +%Y%m%d-%H%M%S)"
BACKUP_FILE="${BACKUP_DIR}/backup-${SERVICE_NAME}-${STAMP}.tar.gz"
[[ -d "$BASE_DIR" ]] || fatal "Mattermost is not deployed at ${BASE_DIR}."
mkdir -p "$BACKUP_DIR"
# --- Hot backup: pg_dump ---
if [[ "$ARG_COLD" != "1" ]]; then
info "Running hot backup (containers stay up)..."
info "Running pg_dump..."
docker exec mm-postgres pg_dump -U mmuser -d mattermost \
> "${BASE_DIR}/postgres/dump.sql" \
2>/dev/null || warn "pg_dump failed — archive will contain live data files only."
fi
# --- Cold backup: stop containers ---
if [[ "$ARG_COLD" == "1" ]]; then
info "Running cold backup (stopping containers)..."
(cd "$BASE_DIR" && docker compose down)
fi
# --- Create archive ---
info "Creating archive..."
tar czf "$BACKUP_FILE" -C / \
"opt/${SERVICE_NAME}" \
"opt/traefik/dynamic/${CONFIG_NAME}.yml" 2>/dev/null || \
tar czf "$BACKUP_FILE" -C / \
"opt/${SERVICE_NAME}"
# --- Cold: restart ---
if [[ "$ARG_COLD" == "1" ]]; then
(cd "$BASE_DIR" && docker compose up -d)
ok "Containers restarted."
fi
# --- Clean up dump ---
rm -f "${BASE_DIR}/postgres/dump.sql" 2>/dev/null
# --- Rotate old backups (keep last 5) ---
# shellcheck disable=SC2012
ls -t "${BACKUP_DIR}/backup-${SERVICE_NAME}-"*.tar.gz 2>/dev/null | tail -n +6 | xargs -r rm --
ok "Backup: ${BACKUP_FILE} ($(du -h "$BACKUP_FILE" | cut -f1))"

69
backup-open-webui.sh Executable file
View File

@@ -0,0 +1,69 @@
#!/usr/bin/env bash
# ==============================================================================
# Open WebUI Backup Script
# ==============================================================================
# Creates a backup archive of Open WebUI data and Traefik config.
#
# Open WebUI uses SQLite internally. Hot backup copies the data directory
# while running (safe for most cases). Use --cold for guaranteed consistency.
#
# Usage: backup-open-webui.sh
# backup-open-webui.sh --cold
# ==============================================================================
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "${SCRIPT_DIR}/common.sh"
parse_args "$@"
SERVICE_NAME="open-webui"
BASE_DIR="/opt/${SERVICE_NAME}"
CONFIG_NAME="${SERVICE_NAME}"
BACKUP_DIR="/opt/backup"
STAMP="$(date +%Y%m%d-%H%M%S)"
BACKUP_FILE="${BACKUP_DIR}/backup-${SERVICE_NAME}-${STAMP}.tar.gz"
[[ -d "$BASE_DIR" ]] || fatal "Open WebUI is not deployed at ${BASE_DIR}."
mkdir -p "$BACKUP_DIR"
# --- Hot backup: attempt SQLite .backup if sqlite3 is available ---
if [[ "$ARG_COLD" != "1" ]]; then
info "Running hot backup (containers stay up)..."
local_db="${BASE_DIR}/data/webui.db"
if [[ -f "$local_db" ]] && command -v sqlite3 &>/dev/null; then
info "Creating SQLite snapshot..."
sqlite3 "$local_db" ".backup '${BASE_DIR}/data/webui-backup.db'" 2>/dev/null || \
warn "SQLite .backup failed — archive will contain live database file."
fi
fi
# --- Cold backup: stop containers ---
if [[ "$ARG_COLD" == "1" ]]; then
info "Running cold backup (stopping containers)..."
(cd "$BASE_DIR" && docker compose down)
fi
# --- Create archive ---
info "Creating archive..."
tar czf "$BACKUP_FILE" -C / \
"opt/${SERVICE_NAME}" \
"opt/traefik/dynamic/${CONFIG_NAME}.yml" 2>/dev/null || \
tar czf "$BACKUP_FILE" -C / \
"opt/${SERVICE_NAME}"
# --- Cold: restart ---
if [[ "$ARG_COLD" == "1" ]]; then
(cd "$BASE_DIR" && docker compose up -d)
ok "Containers restarted."
fi
# --- Clean up snapshot ---
rm -f "${BASE_DIR}/data/webui-backup.db" 2>/dev/null
# --- Rotate old backups (keep last 5) ---
# shellcheck disable=SC2012
ls -t "${BACKUP_DIR}/backup-${SERVICE_NAME}-"*.tar.gz 2>/dev/null | tail -n +6 | xargs -r rm --
ok "Backup: ${BACKUP_FILE} ($(du -h "$BACKUP_FILE" | cut -f1))"

60
backup-openclaw.sh Executable file
View File

@@ -0,0 +1,60 @@
#!/usr/bin/env bash
# ==============================================================================
# OpenClaw Backup Script
# ==============================================================================
# Works for both local and Docker deployments.
#
# Usage: backup-openclaw.sh
# backup-openclaw.sh --cold
# ==============================================================================
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "${SCRIPT_DIR}/common.sh"
parse_args "$@"
SERVICE_NAME="openclaw"
BASE_DIR="/opt/${SERVICE_NAME}"
CONFIG_NAME="${SERVICE_NAME}"
BACKUP_DIR="/opt/backup"
STAMP="$(date +%Y%m%d-%H%M%S)"
BACKUP_FILE="${BACKUP_DIR}/backup-${SERVICE_NAME}-${STAMP}.tar.gz"
[[ -d "$BASE_DIR" ]] || fatal "OpenClaw is not deployed at ${BASE_DIR}."
mkdir -p "$BACKUP_DIR"
# --- Cold backup: stop service ---
if [[ "$ARG_COLD" == "1" ]]; then
info "Running cold backup (stopping service)..."
if [[ -f "${BASE_DIR}/docker-compose.yml" ]]; then
(cd "$BASE_DIR" && docker compose down)
elif systemctl is-active --quiet "${SERVICE_NAME}.service" 2>/dev/null; then
systemctl stop "${SERVICE_NAME}.service"
fi
fi
# --- Create archive ---
info "Creating archive..."
tar_paths=("opt/${SERVICE_NAME}")
if [[ -f "${TRAEFIK_DYNAMIC_DIR}/${CONFIG_NAME}.yml" ]]; then
tar_paths+=("opt/traefik/dynamic/${CONFIG_NAME}.yml")
fi
tar czf "$BACKUP_FILE" -C / "${tar_paths[@]}"
# --- Cold: restart ---
if [[ "$ARG_COLD" == "1" ]]; then
if [[ -f "${BASE_DIR}/docker-compose.yml" ]]; then
(cd "$BASE_DIR" && docker compose up -d)
elif [[ -f "/etc/systemd/system/${SERVICE_NAME}.service" ]]; then
systemctl start "${SERVICE_NAME}.service"
fi
ok "Service restarted."
fi
# --- Rotate old backups (keep last 5) ---
# shellcheck disable=SC2012
ls -t "${BACKUP_DIR}/backup-${SERVICE_NAME}-"*.tar.gz 2>/dev/null | tail -n +6 | xargs -r rm --
ok "Backup: ${BACKUP_FILE} ($(du -h "$BACKUP_FILE" | cut -f1))"

64
backup-paperclip.sh Executable file
View File

@@ -0,0 +1,64 @@
#!/usr/bin/env bash
# ==============================================================================
# Paperclip Backup Script
# ==============================================================================
# Creates a backup archive of Paperclip data (works for both local and Docker).
#
# Usage: backup-paperclip.sh
# backup-paperclip.sh --cold # Stop service before backup
# ==============================================================================
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "${SCRIPT_DIR}/common.sh"
parse_args "$@"
SERVICE_NAME="paperclip"
BASE_DIR="/opt/${SERVICE_NAME}"
CONFIG_NAME="${SERVICE_NAME}"
BACKUP_DIR="/opt/backup"
STAMP="$(date +%Y%m%d-%H%M%S)"
BACKUP_FILE="${BACKUP_DIR}/backup-${SERVICE_NAME}-${STAMP}.tar.gz"
[[ -d "$BASE_DIR" ]] || fatal "Paperclip is not deployed at ${BASE_DIR}."
mkdir -p "$BACKUP_DIR"
# --- Cold backup: stop service ---
if [[ "$ARG_COLD" == "1" ]]; then
info "Running cold backup (stopping service)..."
# Handle both Docker and local deployments
if [[ -f "${BASE_DIR}/docker-compose.yml" ]]; then
(cd "$BASE_DIR" && docker compose down)
elif systemctl is-active --quiet "${SERVICE_NAME}.service" 2>/dev/null; then
systemctl stop "${SERVICE_NAME}.service"
fi
fi
# --- Create archive ---
info "Creating archive..."
tar_paths=("opt/${SERVICE_NAME}")
# Include traefik dynamic config if it exists (Docker variant or manual setup)
if [[ -f "${TRAEFIK_DYNAMIC_DIR}/${CONFIG_NAME}.yml" ]]; then
tar_paths+=("opt/traefik/dynamic/${CONFIG_NAME}.yml")
fi
tar czf "$BACKUP_FILE" -C / "${tar_paths[@]}"
# --- Cold: restart ---
if [[ "$ARG_COLD" == "1" ]]; then
if [[ -f "${BASE_DIR}/docker-compose.yml" ]]; then
(cd "$BASE_DIR" && docker compose up -d)
elif [[ -f "/etc/systemd/system/${SERVICE_NAME}.service" ]]; then
systemctl start "${SERVICE_NAME}.service"
fi
ok "Service restarted."
fi
# --- Rotate old backups (keep last 5) ---
# shellcheck disable=SC2012
ls -t "${BACKUP_DIR}/backup-${SERVICE_NAME}-"*.tar.gz 2>/dev/null | tail -n +6 | xargs -r rm --
ok "Backup: ${BACKUP_FILE} ($(du -h "$BACKUP_FILE" | cut -f1))"

34
backup-traefik.sh Executable file
View File

@@ -0,0 +1,34 @@
#!/usr/bin/env bash
# ==============================================================================
# Traefik Backup Script
# ==============================================================================
# Archives /opt/traefik/ (acme.json certs + all dynamic configs).
# Always hot — Traefik doesn't need to be stopped for file copy.
#
# Usage: backup-traefik.sh
# ==============================================================================
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "${SCRIPT_DIR}/common.sh"
parse_args "$@"
SERVICE_NAME="traefik"
BASE_DIR="/opt/${SERVICE_NAME}"
BACKUP_DIR="/opt/backup"
STAMP="$(date +%Y%m%d-%H%M%S)"
BACKUP_FILE="${BACKUP_DIR}/backup-${SERVICE_NAME}-${STAMP}.tar.gz"
[[ -d "$BASE_DIR" ]] || fatal "Traefik is not deployed at ${BASE_DIR}."
mkdir -p "$BACKUP_DIR"
info "Creating archive (hot — Traefik stays running)..."
tar czf "$BACKUP_FILE" -C / "opt/${SERVICE_NAME}"
# --- Rotate old backups (keep last 5) ---
# shellcheck disable=SC2012
ls -t "${BACKUP_DIR}/backup-${SERVICE_NAME}-"*.tar.gz 2>/dev/null | tail -n +6 | xargs -r rm --
ok "Backup: ${BACKUP_FILE} ($(du -h "$BACKUP_FILE" | cut -f1))"

55
backup-web.sh Executable file
View File

@@ -0,0 +1,55 @@
#!/usr/bin/env bash
# ==============================================================================
# Web Instance Backup Script
# ==============================================================================
# Requires --dir to identify which instance to back up.
# No pre-backup commands — just static files.
#
# Usage: backup-web.sh --dir acme
# backup-web.sh --dir acme --cold
# ==============================================================================
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "${SCRIPT_DIR}/common.sh"
parse_args "$@"
[[ -n "$ARG_DIR" ]] || fatal "--dir is required. Example: $0 --dir acme"
DIR_NAME="$ARG_DIR"
BASE_DIR="/opt/${DIR_NAME}"
CONFIG_NAME="web-${DIR_NAME}"
BACKUP_DIR="/opt/backup"
STAMP="$(date +%Y%m%d-%H%M%S)"
BACKUP_FILE="${BACKUP_DIR}/backup-${DIR_NAME}-${STAMP}.tar.gz"
[[ -d "$BASE_DIR" ]] || fatal "Web instance '${DIR_NAME}' is not deployed at ${BASE_DIR}."
mkdir -p "$BACKUP_DIR"
# --- Cold backup: stop container ---
if [[ "$ARG_COLD" == "1" ]]; then
info "Running cold backup (stopping container)..."
(cd "$BASE_DIR" && docker compose down)
fi
# --- Create archive ---
info "Creating archive..."
tar czf "$BACKUP_FILE" -C / \
"opt/${DIR_NAME}" \
"opt/traefik/dynamic/${CONFIG_NAME}.yml" 2>/dev/null || \
tar czf "$BACKUP_FILE" -C / \
"opt/${DIR_NAME}"
# --- Cold: restart ---
if [[ "$ARG_COLD" == "1" ]]; then
(cd "$BASE_DIR" && docker compose up -d)
ok "Container restarted."
fi
# --- Rotate old backups (keep last 5) ---
# shellcheck disable=SC2012
ls -t "${BACKUP_DIR}/backup-${DIR_NAME}-"*.tar.gz 2>/dev/null | tail -n +6 | xargs -r rm --
ok "Backup: ${BACKUP_FILE} ($(du -h "$BACKUP_FILE" | cut -f1))"

514
common.sh Normal file
View File

@@ -0,0 +1,514 @@
# common.sh — Shared functions sourced by all deploy and backup scripts
# This file should NOT be executed directly.
#
# Usage: source "${SCRIPT_DIR}/common.sh"
# ──────────────────────────── VARIABLES ───────────────────────────────────────
ACME_EMAIL="${ACME_EMAIL:-admin@an2.io}"
TRAEFIK_DIR="/opt/traefik"
TRAEFIK_DYNAMIC_DIR="/opt/traefik/dynamic"
DOCKER_NETWORK="traefik-public"
# ──────────────────────────── COLOURS / LOGGING ───────────────────────────────
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; NC='\033[0m'
info() { echo -e "${CYAN}[INFO]${NC} $*"; }
ok() { echo -e "${GREEN}[ OK ]${NC} $*"; }
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
fatal() { echo -e "${RED}[FAIL]${NC} $*"; exit 1; }
# ──────────────────────────── ARGUMENT PARSING ────────────────────────────────
parse_args() {
ARG_DOMAIN=""
ARG_REMOVE="0"
ARG_PURGE="0"
ARG_YES="0"
ARG_COLD="0"
ARG_DIR=""
ARG_EXTRA=()
while [[ $# -gt 0 ]]; do
case "$1" in
--domain)
[[ $# -ge 2 ]] || fatal "--domain requires a value"
ARG_DOMAIN="$2"; shift 2 ;;
--dir)
[[ $# -ge 2 ]] || fatal "--dir requires a value"
ARG_DIR="$2"; shift 2 ;;
--remove) ARG_REMOVE="1"; shift ;;
--purge) ARG_PURGE="1"; shift ;;
--yes) ARG_YES="1"; shift ;;
--cold) ARG_COLD="1"; shift ;;
*) ARG_EXTRA+=("$1"); shift ;;
esac
done
}
# ──────────────────────────── REQUIRE ROOT ────────────────────────────────────
require_root() {
[[ $EUID -eq 0 ]] || fatal "This script must be run as root (or via sudo)."
}
# ──────────────────────────── OS DETECTION ────────────────────────────────────
detect_os() {
if [[ -f /etc/os-release ]]; then
# shellcheck source=/dev/null
source /etc/os-release
OS_ID="${ID,,}"
OS_VERSION="${VERSION_ID:-}"
OS_NAME="${PRETTY_NAME}"
else
fatal "Cannot detect OS — /etc/os-release not found."
fi
case "$OS_ID" in
fedora|rhel|centos|rocky|alma|amzn)
DISTRO_FAMILY="fedora" ;;
debian|ubuntu|pop|linuxmint|raspbian|kali)
DISTRO_FAMILY="debian" ;;
*)
fatal "Unsupported distro: $OS_ID ($OS_NAME). Supported: Fedora/RHEL family, Debian/Ubuntu family." ;;
esac
ok "Detected OS: $OS_NAME (family: $DISTRO_FAMILY)"
}
# ──────────────────────────── INSTALL PREREQUISITES ───────────────────────────
install_prerequisites() {
case "$DISTRO_FAMILY" in
debian)
info "Installing prerequisites (Debian/Ubuntu)..."
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq
apt-get install -y -qq \
apt-transport-https ca-certificates curl gnupg lsb-release \
openssl jq net-tools > /dev/null 2>&1
if ! command -v docker &>/dev/null; then
info "Installing Docker Engine..."
install -m 0755 -d /etc/apt/keyrings
curl -fsSL "https://download.docker.com/linux/${OS_ID}/gpg" | \
gpg --dearmor -o /etc/apt/keyrings/docker.gpg 2>/dev/null
chmod a+r /etc/apt/keyrings/docker.gpg
local codename
codename="$(. /etc/os-release && echo "${VERSION_CODENAME:-$(lsb_release -cs)}")"
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \
https://download.docker.com/linux/${OS_ID} ${codename} stable" \
> /etc/apt/sources.list.d/docker.list
apt-get update -qq
apt-get install -y -qq docker-ce docker-ce-cli containerd.io docker-compose-plugin > /dev/null 2>&1
fi
ok "Prerequisites installed (Debian/Ubuntu)."
;;
fedora)
info "Installing prerequisites (Fedora/RHEL)..."
dnf install -y -q \
ca-certificates curl gnupg2 openssl jq net-tools > /dev/null 2>&1
if ! command -v docker &>/dev/null; then
info "Installing Docker Engine..."
dnf -y install dnf-plugins-core > /dev/null 2>&1
dnf config-manager --add-repo \
https://download.docker.com/linux/fedora/docker-ce.repo 2>/dev/null || \
dnf config-manager --add-repo \
https://download.docker.com/linux/centos/docker-ce.repo 2>/dev/null
dnf install -y -q docker-ce docker-ce-cli containerd.io docker-compose-plugin > /dev/null 2>&1
fi
ok "Prerequisites installed (Fedora/RHEL)."
;;
esac
systemctl enable --now docker > /dev/null 2>&1
ok "Docker $(docker --version | awk '{print $3}') is running."
if ! docker compose version &>/dev/null; then
fatal "docker compose v2 plugin not found. Please install docker-compose-plugin."
fi
ok "Docker Compose $(docker compose version --short) available."
}
# ──────────────────────────── DOCKER NETWORK ──────────────────────────────────
ensure_docker_network() {
docker network inspect "$DOCKER_NETWORK" &>/dev/null || \
docker network create "$DOCKER_NETWORK"
ok "Docker network '$DOCKER_NETWORK' ready."
}
# ──────────────────────────── TRAEFIK ─────────────────────────────────────────
# Deploys shared Traefik at /opt/traefik/ if not already present.
# Uses file provider (not Docker socket) — Docker Engine 29+ has API
# negotiation bugs with Traefik's Go client, and the docs warn about
# Docker socket security.
ensure_traefik() {
# Already running — nothing to do
if [[ -f "${TRAEFIK_DIR}/docker-compose.yml" ]]; then
if docker ps --format '{{.Names}}' | grep -q '^traefik$'; then
ok "Traefik is already running."
return 0
fi
info "Traefik compose exists but not running. Starting..."
(cd "$TRAEFIK_DIR" && docker compose up -d)
ok "Traefik started."
return 0
fi
# Full deploy
info "Deploying Traefik (file provider, Let's Encrypt)..."
# Check port 443
if ss -tlnp 2>/dev/null | grep -q ':443 '; then
local proc
proc="$(ss -tlnp 2>/dev/null | grep ':443 ')"
if ! echo "$proc" | grep -q 'traefik'; then
echo "$proc" | sed 's/^/ /'
fatal "Port 443 is in use by another process. Free it before deploying Traefik."
fi
fi
# Warn if port 80 is busy (LE HTTP challenge needs it)
if ss -tlnp 2>/dev/null | grep -q ':80 '; then
warn "Port 80 is in use. Let's Encrypt HTTP challenge requires port 80."
fi
mkdir -p "$TRAEFIK_DIR" "$TRAEFIK_DYNAMIC_DIR"
touch "${TRAEFIK_DIR}/acme.json"
chmod 600 "${TRAEFIK_DIR}/acme.json"
cat > "${TRAEFIK_DIR}/docker-compose.yml" <<EOF
# Traefik reverse proxy — shared by all services
# Static config via CLI flags (per official Traefik docs)
# Dynamic routing: one .yml per service in ./dynamic/, hot-reloaded
# NO Docker socket — secure, avoids Docker Engine 29+ API bugs
services:
traefik:
image: traefik:v3.4
container_name: traefik
restart: unless-stopped
security_opt:
- no-new-privileges:true
mem_limit: 1G
extra_hosts:
- "host.docker.internal:host-gateway"
ports:
- "80:80"
- "443:443"
command:
- "--entrypoints.web.address=:80"
- "--entrypoints.websecure.address=:443"
- "--entrypoints.web.http.redirections.entrypoint.to=websecure"
- "--entrypoints.web.http.redirections.entrypoint.scheme=https"
- "--entrypoints.websecure.transport.respondingTimeouts.readTimeout=600s"
- "--entrypoints.websecure.transport.respondingTimeouts.writeTimeout=600s"
- "--entrypoints.websecure.transport.respondingTimeouts.idleTimeout=600s"
- "--certificatesresolvers.letsencrypt.acme.email=${ACME_EMAIL}"
- "--certificatesresolvers.letsencrypt.acme.storage=/acme.json"
- "--certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web"
- "--providers.file.directory=/dynamic"
- "--providers.file.watch=true"
- "--log.level=WARN"
volumes:
- ./acme.json:/acme.json
- ./dynamic:/dynamic:ro
networks:
- traefik-public
logging:
driver: json-file
options:
max-size: "50m"
max-file: "5"
networks:
traefik-public:
external: true
EOF
(cd "$TRAEFIK_DIR" && docker compose up -d)
install_systemd_unit "traefik-docker" "$TRAEFIK_DIR"
ok "Traefik deployed at $TRAEFIK_DIR."
}
# ──────────────────────────── FIREWALL ────────────────────────────────────────
configure_firewall() {
local extra_ports=("$@")
if command -v ufw &>/dev/null && ufw status 2>/dev/null | grep -q "active"; then
ufw allow 80/tcp > /dev/null 2>&1
ufw allow 443/tcp > /dev/null 2>&1
for port in "${extra_ports[@]}"; do
ufw allow "$port" > /dev/null 2>&1
done
ok "UFW rules added (80, 443${extra_ports:+, ${extra_ports[*]}})."
elif command -v firewall-cmd &>/dev/null && systemctl is-active --quiet firewalld; then
firewall-cmd --permanent --add-service=http > /dev/null 2>&1
firewall-cmd --permanent --add-service=https > /dev/null 2>&1
for port in "${extra_ports[@]}"; do
firewall-cmd --permanent --add-port="$port" > /dev/null 2>&1
done
firewall-cmd --reload > /dev/null 2>&1
ok "Firewalld rules added (http, https${extra_ports:+, ${extra_ports[*]}})."
else
warn "No active firewall detected. Ensure ports 80, 443${extra_ports:+, ${extra_ports[*]}} are open."
fi
}
# ──────────────────────────── TRAEFIK DYNAMIC CONFIG ──────────────────────────
write_traefik_dynamic_config() {
local config_name="$1"
local domain="$2"
local backend_url="$3"
info "Writing Traefik dynamic config: ${config_name}.yml"
cat > "${TRAEFIK_DYNAMIC_DIR}/${config_name}.yml" <<EOF
http:
routers:
${config_name}:
rule: "Host(\`${domain}\`)"
entryPoints:
- websecure
tls:
certResolver: letsencrypt
service: ${config_name}
middlewares:
- ${config_name}-headers
services:
${config_name}:
loadBalancer:
servers:
- url: "${backend_url}"
middlewares:
${config_name}-headers:
headers:
customRequestHeaders:
X-Forwarded-Proto: "https"
stsSeconds: 63072000
stsIncludeSubdomains: true
stsPreload: true
forceSTSHeader: true
frameDeny: true
contentTypeNosniff: true
browserXssFilter: true
EOF
ok "Traefik dynamic config: ${TRAEFIK_DYNAMIC_DIR}/${config_name}.yml"
}
remove_traefik_dynamic_config() {
local config_name="$1"
local config_file="${TRAEFIK_DYNAMIC_DIR}/${config_name}.yml"
if [[ -f "$config_file" ]]; then
rm -f "$config_file"
ok "Removed Traefik dynamic config: ${config_file}"
fi
}
# ──────────────────────────── BACKUP / RESTORE ────────────────────────────────
check_and_restore_backup() {
local service_name="$1"
local backup_file=""
# Search order: scp'd backups near the script, then server-side locations
local search_paths=(
"${SCRIPT_DIR}/backups/backup-${service_name}.tar.gz"
"${SCRIPT_DIR}/backup-${service_name}.tar.gz"
"/root/backup-${service_name}.tar.gz"
)
for path in "${search_paths[@]}"; do
if [[ -f "$path" ]]; then
backup_file="$path"
break
fi
done
# Check /opt/backup/ for timestamped backups (most recent first)
if [[ -z "$backup_file" ]]; then
local latest
latest="$(ls -t "/opt/backup/backup-${service_name}-"*.tar.gz 2>/dev/null | head -1)" || true
if [[ -n "$latest" ]]; then
backup_file="$latest"
fi
fi
if [[ -z "$backup_file" ]]; then
return 1
fi
local size
size="$(du -h "$backup_file" | cut -f1)"
info "Found backup: $backup_file ($size)"
if [[ "$ARG_YES" != "1" ]]; then
read -rp "Restore from this backup? [y/N] " confirm
if [[ "${confirm,,}" != "y" ]]; then
info "Skipping backup restore."
return 1
fi
fi
info "Restoring backup..."
tar xf "$backup_file" -C /
ok "Backup restored from $backup_file"
return 0
}
# ──────────────────────────── HEALTH CHECK ────────────────────────────────────
wait_for_healthy() {
local container_name="$1"
local health_url="$2"
local timeout="${3:-120}"
info "Waiting for ${container_name} to become healthy (up to ${timeout}s)..."
local elapsed=0
while (( elapsed < timeout )); do
# Try curl first, fall back to wget (Alpine images lack curl)
if docker exec "$container_name" curl -sf "$health_url" > /dev/null 2>&1 || \
docker exec "$container_name" wget -q --spider "$health_url" 2>/dev/null; then
ok "${container_name} is healthy."
return 0
fi
sleep 5
(( elapsed += 5 ))
done
warn "${container_name} did not become healthy within ${timeout}s. Check: docker logs ${container_name}"
return 1
}
# ──────────────────────────── PASSWORD GENERATION ─────────────────────────────
generate_password() {
openssl rand -base64 32 | tr -d '/+=' | head -c 32
}
# ──────────────────────────── SYSTEMD UNITS ───────────────────────────────────
install_systemd_unit() {
local unit_name="$1"
local working_directory="$2"
cat > "/etc/systemd/system/${unit_name}.service" <<EOF
[Unit]
Description=${unit_name} Docker Compose Stack
Requires=docker.service
After=docker.service traefik-docker.service
[Service]
Type=oneshot
RemainAfterExit=yes
WorkingDirectory=${working_directory}
ExecStart=/usr/bin/docker compose up -d
ExecStop=/usr/bin/docker compose down
TimeoutStartSec=300
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable "${unit_name}.service" > /dev/null 2>&1
ok "Systemd unit installed: ${unit_name}.service"
}
remove_systemd_unit() {
local unit_name="$1"
local unit_file="/etc/systemd/system/${unit_name}.service"
if [[ -f "$unit_file" ]]; then
systemctl stop "${unit_name}.service" > /dev/null 2>&1 || true
systemctl disable "${unit_name}.service" > /dev/null 2>&1 || true
rm -f "$unit_file"
systemctl daemon-reload
ok "Removed systemd unit: ${unit_name}.service"
fi
}
# ──────────────────────────── REMOVAL ─────────────────────────────────────────
do_remove() {
local service_name="$1"
local config_name="$2"
local unit_name="$3"
local base_dir="$4"
local backup_script="${SCRIPT_DIR}/backup-${service_name}.sh"
echo ""
echo -e "${YELLOW}══════════════════════════════════════════════${NC}"
echo -e "${YELLOW} Removing ${service_name}${NC}"
echo -e "${YELLOW}══════════════════════════════════════════════${NC}"
if [[ "$ARG_PURGE" == "1" ]]; then
echo -e " Mode: ${RED}Purge (delete all data)${NC}"
else
echo -e " Mode: Safe (keep data)"
fi
echo -e " Data: ${base_dir}"
[[ -n "$config_name" ]] && \
echo -e " Traefik: ${TRAEFIK_DYNAMIC_DIR}/${config_name}.yml"
echo -e " Systemd: ${unit_name}.service"
echo -e "${YELLOW}══════════════════════════════════════════════${NC}"
echo ""
# Suggest backup before purge
if [[ "$ARG_PURGE" == "1" ]] && [[ -d "$base_dir" ]]; then
if [[ -f "$backup_script" ]]; then
warn "Back up first? ${backup_script}"
fi
echo ""
fi
# Stop containers
if [[ -d "$base_dir" ]] && [[ -f "${base_dir}/docker-compose.yml" ]]; then
(cd "$base_dir" && docker compose down) || true
ok "Containers stopped."
fi
# Remove traefik dynamic config
[[ -n "$config_name" ]] && remove_traefik_dynamic_config "$config_name"
# Remove systemd unit
remove_systemd_unit "$unit_name"
# Purge data if requested
if [[ "$ARG_PURGE" == "1" ]]; then
echo ""
warn "This will permanently delete: $base_dir"
if [[ "$ARG_YES" != "1" ]]; then
read -rp "Are you sure? [y/N] " confirm
if [[ "${confirm,,}" != "y" ]]; then
info "Purge cancelled."
return 0
fi
fi
rm -rf "$base_dir"
ok "Purged: $base_dir"
fi
# Summary
echo ""
echo -e "${GREEN}══════════════════════════════════════════════${NC}"
echo -e "${GREEN} ${service_name} removed${NC}"
echo -e "${GREEN}══════════════════════════════════════════════${NC}"
if [[ "$ARG_PURGE" != "1" ]]; then
echo -e " Data preserved at ${base_dir}"
echo -e " To purge: $0 --remove --purge"
fi
echo -e " To redeploy: $0"
echo -e "${GREEN}══════════════════════════════════════════════${NC}"
echo ""
return 0
}

199
deploy Executable file
View File

@@ -0,0 +1,199 @@
#!/usr/bin/env bash
# ==============================================================================
# Unified Deploy Wrapper
# ==============================================================================
# Auto-discovers deploy-{docker,local}-*.sh scripts in the same directory and
# dispatches to them with a shorter CLI interface.
#
# Usage:
# deploy.sh # List available services
# deploy.sh mattermost # Deploy via docker (default)
# deploy.sh -l paperclip # Deploy via local (bare-metal)
# deploy.sh --local hermes --domain h.an2.io # Local deploy with flags
# deploy.sh -r mattermost # --remove
# deploy.sh -r -p mattermost # --remove --purge
# deploy.sh -r -p -y mattermost # --remove --purge --yes
# deploy.sh web --domain w.an2.io --dir acme # Pass-through flags
# ==============================================================================
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# ── Colours ──────────────────────────────────────────────────────────────────
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'
BOLD='\033[1m'; DIM='\033[2m'; NC='\033[0m'
# ── Discover available deploy scripts ────────────────────────────────────────
# Builds two associative arrays:
# DOCKER_SCRIPTS[service] = script_path
# LOCAL_SCRIPTS[service] = script_path
# And a sorted list of unique service names.
declare -A DOCKER_SCRIPTS=()
declare -A LOCAL_SCRIPTS=()
declare -a ALL_SERVICES=()
discover_scripts() {
local script basename method service
for script in "${SCRIPT_DIR}"/deploy-docker-*.sh "${SCRIPT_DIR}"/deploy-local-*.sh; do
[[ -f "$script" ]] || continue
basename="$(basename "$script" .sh)" # deploy-docker-hermes
method="${basename#deploy-}" # docker-hermes
method="${method%%-*}" # docker or local
service="${basename#deploy-${method}-}" # hermes
case "$method" in
docker) DOCKER_SCRIPTS["$service"]="$script" ;;
local) LOCAL_SCRIPTS["$service"]="$script" ;;
esac
done
# Build sorted unique service list
local -A seen=()
for s in "${!DOCKER_SCRIPTS[@]}" "${!LOCAL_SCRIPTS[@]}"; do
seen["$s"]=1
done
IFS=$'\n' read -r -d '' -a ALL_SERVICES < <(printf '%s\n' "${!seen[@]}" | sort && printf '\0') || true
}
# ── Usage / list ─────────────────────────────────────────────────────────────
print_usage() {
echo ""
echo -e "${CYAN}══════════════════════════════════════════════${NC}"
echo -e "${CYAN} Deploy — Unified Wrapper${NC}"
echo -e "${CYAN}══════════════════════════════════════════════${NC}"
echo ""
echo -e " ${BOLD}Usage:${NC} deploy.sh [flags] <service>"
echo ""
echo -e " ${BOLD}Flags:${NC}"
echo -e " ${GREEN}--local, -l${NC} Use bare-metal (local) deploy instead of Docker"
echo -e " ${GREEN}--remove, -r${NC} Remove the service (keep data)"
echo -e " ${GREEN}--purge, -p${NC} Delete all data (requires --remove)"
echo -e " ${GREEN}--yes, -y${NC} Skip confirmations"
echo -e " ${GREEN}--domain <fqdn>${NC} Set the service domain"
echo -e " ${GREEN}--dir <name>${NC} Instance directory (for web)"
echo -e " ${GREEN}--cold${NC} Cold backup mode (passed through)"
echo ""
echo -e " ${BOLD}Available services:${NC}"
echo ""
# Table header
printf " ${DIM}%-16s %-8s %-8s${NC}\n" "SERVICE" "DOCKER" "LOCAL"
printf " ${DIM}%-16s %-8s %-8s${NC}\n" "───────────────" "────────" "────────"
for service in "${ALL_SERVICES[@]}"; do
local d_mark l_mark
if [[ -n "${DOCKER_SCRIPTS[$service]+x}" ]]; then
d_mark="${GREEN} ✓${NC}"
else
d_mark="${DIM} —${NC}"
fi
if [[ -n "${LOCAL_SCRIPTS[$service]+x}" ]]; then
l_mark="${GREEN} ✓${NC}"
else
l_mark="${DIM} —${NC}"
fi
printf " %-16s %b %b\n" "$service" "$d_mark" "$l_mark"
done
echo ""
echo -e " ${BOLD}Examples:${NC}"
echo -e " ${DIM}deploy.sh mattermost${NC} # Docker deploy"
echo -e " ${DIM}deploy.sh -l paperclip --domain clip.an2.io${NC} # Local deploy"
echo -e " ${DIM}deploy.sh -r -p gitea${NC} # Remove + purge"
echo -e " ${DIM}deploy.sh web --domain w.an2.io --dir acme${NC} # Multi-instance"
echo ""
}
# ── Main ─────────────────────────────────────────────────────────────────────
discover_scripts
# Parse wrapper flags — separate them from pass-through flags
USE_LOCAL=0
SERVICE=""
PASSTHROUGH=()
while [[ $# -gt 0 ]]; do
case "$1" in
--local|-l)
USE_LOCAL=1
shift
;;
--remove|-r)
PASSTHROUGH+=("--remove")
shift
;;
--purge|-p)
PASSTHROUGH+=("--purge")
shift
;;
--yes|-y)
PASSTHROUGH+=("--yes")
shift
;;
--domain)
PASSTHROUGH+=("--domain" "$2")
shift 2
;;
--dir)
PASSTHROUGH+=("--dir" "$2")
shift 2
;;
--cold)
PASSTHROUGH+=("--cold")
shift
;;
-*)
# Unknown flag — pass through
PASSTHROUGH+=("$1")
shift
;;
*)
if [[ -z "$SERVICE" ]]; then
SERVICE="$1"
else
# Extra positional — pass through
PASSTHROUGH+=("$1")
fi
shift
;;
esac
done
# No service specified — show usage
if [[ -z "$SERVICE" ]]; then
print_usage
exit 0
fi
# Resolve script
TARGET_SCRIPT=""
if (( USE_LOCAL )); then
if [[ -n "${LOCAL_SCRIPTS[$SERVICE]+x}" ]]; then
TARGET_SCRIPT="${LOCAL_SCRIPTS[$SERVICE]}"
else
echo -e "${RED}[FAIL]${NC} No local deploy script for '${SERVICE}'."
if [[ -n "${DOCKER_SCRIPTS[$SERVICE]+x}" ]]; then
echo -e " A Docker variant exists — run without ${GREEN}-l${NC}."
fi
exit 1
fi
else
if [[ -n "${DOCKER_SCRIPTS[$SERVICE]+x}" ]]; then
TARGET_SCRIPT="${DOCKER_SCRIPTS[$SERVICE]}"
elif [[ -n "${LOCAL_SCRIPTS[$SERVICE]+x}" ]]; then
echo -e "${YELLOW}[WARN]${NC} No Docker variant for '${SERVICE}' — using local."
TARGET_SCRIPT="${LOCAL_SCRIPTS[$SERVICE]}"
else
echo -e "${RED}[FAIL]${NC} Unknown service '${SERVICE}'."
echo ""
echo -e " Available: ${ALL_SERVICES[*]}"
exit 1
fi
fi
# Dispatch
exec bash "$TARGET_SCRIPT" "${PASSTHROUGH[@]}"

173
deploy-docker-agenticseek.sh Executable file
View File

@@ -0,0 +1,173 @@
#!/usr/bin/env bash
# ==============================================================================
# AgenticSeek Deployment Script (Docker Compose + Traefik)
# ==============================================================================
# Clones and deploys AgenticSeek (multi-container) behind Traefik.
# Source: https://github.com/Fosowl/agenticSeek
#
# Usage: deploy-docker-agenticseek.sh
# deploy-docker-agenticseek.sh --domain agent.an2.io
# deploy-docker-agenticseek.sh --remove [--purge] [--yes]
# ==============================================================================
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "${SCRIPT_DIR}/common.sh"
# --- Parse arguments ---
parse_args "$@"
# --- Service config ---
SERVICE_NAME="agenticseek"
BASE_DIR="/opt/${SERVICE_NAME}"
CONFIG_NAME="${SERVICE_NAME}"
UNIT_NAME="${SERVICE_NAME}-docker"
AGENTICSEEK_REPO="${AGENTICSEEK_REPO:-https://github.com/Fosowl/agenticSeek.git}"
# --- Handle --remove ---
if [[ "$ARG_REMOVE" == "1" ]]; then
require_root
do_remove "$SERVICE_NAME" "$CONFIG_NAME" "$UNIT_NAME" "$BASE_DIR"
exit 0
fi
# --- Domain resolution ---
if [[ -n "$ARG_DOMAIN" ]]; then
DOMAIN="$ARG_DOMAIN"
elif [[ -f "${BASE_DIR}/.env" ]] && grep -q '^DOMAIN=' "${BASE_DIR}/.env"; then
DOMAIN="$(grep '^DOMAIN=' "${BASE_DIR}/.env" | cut -d= -f2)"
else
DOMAIN="seek.an2.io"
fi
# --- Detect current state for banner ---
traefik_status="Not found — will deploy"
if docker ps --format '{{.Names}}' 2>/dev/null | grep -q '^traefik$'; then
traefik_status="Running"
elif [[ -f "${TRAEFIK_DIR}/docker-compose.yml" ]]; then
traefik_status="Stopped — will start"
fi
install_mode="Fresh install"
if [[ -f "${BASE_DIR}/.env" ]]; then
install_mode="Re-run (preserving data)"
fi
# --- Print deployment plan ---
echo ""
echo -e "${CYAN}══════════════════════════════════════════════${NC}"
echo -e "${CYAN} Deploying AgenticSeek (Docker)${NC}"
echo -e "${CYAN}══════════════════════════════════════════════${NC}"
echo -e " Domain: ${DOMAIN}"
echo -e " Data: ${BASE_DIR}"
echo -e " Traefik: ${traefik_status}"
echo -e " Mode: ${install_mode}"
echo -e "${CYAN}══════════════════════════════════════════════${NC}"
echo ""
# --- Check if already deployed and running ---
# AgenticSeek uses repo compose — check for frontend container
if [[ -f "${BASE_DIR}/docker-compose.yml" ]] && \
docker compose -f "${BASE_DIR}/docker-compose.yml" ps --format '{{.State}}' 2>/dev/null | grep -q 'running'; then
echo -e "${GREEN} AgenticSeek is already running — nothing to do.${NC}"
echo ""
echo -e " Logs: cd ${BASE_DIR} && docker compose logs -f"
echo -e " Backup: ${SCRIPT_DIR}/backup-agenticseek.sh"
echo -e " Remove: $0 --remove [--purge]"
echo ""
exit 0
fi
# --- Shared infrastructure ---
require_root
detect_os
install_prerequisites
ensure_docker_network
ensure_traefik
configure_firewall
# --- Backup restore check ---
if check_and_restore_backup "$SERVICE_NAME"; then
saved_domain="$DOMAIN"
# shellcheck source=/dev/null
source "${BASE_DIR}/.env"
DOMAIN="$saved_domain"
else
# --- Fresh install / update ---
# Clone or update repo
if [[ -d "${BASE_DIR}/.git" ]]; then
info "Updating AgenticSeek source..."
(cd "$BASE_DIR" && git pull)
else
info "Cloning AgenticSeek from ${AGENTICSEEK_REPO}..."
git clone "$AGENTICSEEK_REPO" "$BASE_DIR"
fi
# Set up .env from example if fresh
if [[ ! -f "${BASE_DIR}/.env" ]] && [[ -f "${BASE_DIR}/.env.example" ]]; then
cp "${BASE_DIR}/.env.example" "${BASE_DIR}/.env"
fi
# Ensure our DOMAIN is in .env
if grep -q '^DOMAIN=' "${BASE_DIR}/.env" 2>/dev/null; then
sed -i "s|^DOMAIN=.*|DOMAIN=${DOMAIN}|" "${BASE_DIR}/.env"
else
echo "DOMAIN=${DOMAIN}" >> "${BASE_DIR}/.env"
fi
chmod 600 "${BASE_DIR}/.env"
mkdir -p "${BASE_DIR}/data"
# Create override to add traefik-public network to frontend
cat > "${BASE_DIR}/docker-compose.override.yml" <<'OVERRIDE'
services:
frontend:
networks:
- default
- traefik-public
networks:
traefik-public:
external: true
OVERRIDE
ok "docker-compose.override.yml written (traefik-public network)."
fi
# --- Always do these (fresh or restored) ---
write_traefik_dynamic_config "$CONFIG_NAME" "$DOMAIN" "http://frontend:3000"
# AgenticSeek uses repo compose + our override — no custom systemd unit for compose
# since the compose file is the repo's own, install unit pointing to BASE_DIR
install_systemd_unit "$UNIT_NAME" "$BASE_DIR"
(cd "$BASE_DIR" && docker compose up -d --build)
info "Waiting for AgenticSeek frontend (up to 120s)..."
elapsed=0
while (( elapsed < 120 )); do
if curl -sf "http://localhost:3000" > /dev/null 2>&1; then
ok "AgenticSeek frontend is responding."
break
fi
sleep 5
(( elapsed += 5 ))
done
if (( elapsed >= 120 )); then
warn "Frontend did not respond within 120s. Check: cd ${BASE_DIR} && docker compose logs"
fi
# --- Summary ---
echo ""
echo -e "${GREEN}══════════════════════════════════════════════${NC}"
echo -e "${GREEN} AgenticSeek deployed successfully${NC}"
echo -e "${GREEN}══════════════════════════════════════════════${NC}"
echo -e " URL: https://${DOMAIN}"
echo -e " Data: ${BASE_DIR}/data/"
echo -e " Traefik: ${TRAEFIK_DYNAMIC_DIR}/${CONFIG_NAME}.yml"
echo -e " Systemd: systemctl status ${UNIT_NAME}"
echo -e " Logs: cd ${BASE_DIR} && docker compose logs -f"
echo ""
echo -e " ${YELLOW}Configure API keys in ${BASE_DIR}/.env${NC}"
echo -e "${GREEN}══════════════════════════════════════════════${NC}"
echo ""

218
deploy-docker-gitea.sh Executable file
View File

@@ -0,0 +1,218 @@
#!/usr/bin/env bash
# ==============================================================================
# Gitea Deployment Script (Docker Compose + Traefik)
# ==============================================================================
# Deploys Gitea + PostgreSQL behind the shared Traefik reverse proxy.
#
# Usage: deploy-docker-gitea.sh
# deploy-docker-gitea.sh --domain git.us.an2.io
# deploy-docker-gitea.sh --remove [--purge] [--yes]
# ==============================================================================
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "${SCRIPT_DIR}/common.sh"
# --- Parse arguments ---
parse_args "$@"
# --- Service config ---
SERVICE_NAME="gitea"
BASE_DIR="/opt/${SERVICE_NAME}"
CONFIG_NAME="${SERVICE_NAME}"
UNIT_NAME="${SERVICE_NAME}-docker"
SSH_PORT="${SSH_PORT:-222}"
# --- Handle --remove ---
if [[ "$ARG_REMOVE" == "1" ]]; then
require_root
do_remove "$SERVICE_NAME" "$CONFIG_NAME" "$UNIT_NAME" "$BASE_DIR"
exit 0
fi
# --- Domain resolution: --domain > existing .env > default ---
# Read domain from .env without sourcing (avoids clobbering other vars)
if [[ -n "$ARG_DOMAIN" ]]; then
DOMAIN="$ARG_DOMAIN"
elif [[ -f "${BASE_DIR}/.env" ]] && grep -q '^DOMAIN=' "${BASE_DIR}/.env"; then
DOMAIN="$(grep '^DOMAIN=' "${BASE_DIR}/.env" | cut -d= -f2)"
else
DOMAIN="git.an2.io"
fi
# --- Detect current state for banner ---
traefik_status="Not found — will deploy"
if docker ps --format '{{.Names}}' 2>/dev/null | grep -q '^traefik$'; then
traefik_status="Running"
elif [[ -f "${TRAEFIK_DIR}/docker-compose.yml" ]]; then
traefik_status="Stopped — will start"
fi
install_mode="Fresh install"
if [[ -f "${BASE_DIR}/.env" ]]; then
install_mode="Re-run (preserving data)"
fi
# --- Print deployment plan ---
echo ""
echo -e "${CYAN}══════════════════════════════════════════════${NC}"
echo -e "${CYAN} Deploying Gitea (Docker)${NC}"
echo -e "${CYAN}══════════════════════════════════════════════${NC}"
echo -e " Domain: ${DOMAIN}"
echo -e " Data: ${BASE_DIR}"
echo -e " SSH Port: ${SSH_PORT}"
echo -e " Traefik: ${traefik_status}"
echo -e " Mode: ${install_mode}"
echo -e "${CYAN}══════════════════════════════════════════════${NC}"
echo ""
# --- Check if already deployed and running ---
if [[ -f "${BASE_DIR}/docker-compose.yml" ]] && \
docker ps --format '{{.Names}}' 2>/dev/null | grep -q '^gitea$'; then
echo -e "${GREEN} Gitea is already running — nothing to do.${NC}"
echo ""
echo -e " Logs: docker logs -f gitea"
echo -e " Backup: ${SCRIPT_DIR}/backup-gitea.sh"
echo -e " Remove: $0 --remove [--purge]"
echo ""
exit 0
fi
# --- Shared infrastructure ---
require_root
detect_os
install_prerequisites
ensure_docker_network
ensure_traefik
configure_firewall "${SSH_PORT}/tcp"
# --- Backup restore check ---
if check_and_restore_backup "$SERVICE_NAME"; then
# Backup restored — source .env for credentials, but keep our resolved domain
saved_domain="$DOMAIN"
# shellcheck source=/dev/null
source "${BASE_DIR}/.env"
DOMAIN="$saved_domain"
else
# --- Fresh install ---
# Preserve credentials from existing .env on re-run
if [[ -f "${BASE_DIR}/.env" ]]; then
saved_domain="$DOMAIN"
# shellcheck source=/dev/null
source "${BASE_DIR}/.env"
DOMAIN="$saved_domain"
else
POSTGRES_USER="gitea"
POSTGRES_PASSWORD="$(generate_password)"
POSTGRES_DB="gitea"
fi
info "Creating directory layout..."
mkdir -p "${BASE_DIR}"/{postgres/data,gitea/data}
# --- Generate .env ---
cat > "${BASE_DIR}/.env" <<DOTENV
# Generated by deploy-docker-gitea.sh on $(date -Iseconds)
DOMAIN=${DOMAIN}
SSH_PORT=${SSH_PORT}
POSTGRES_USER=${POSTGRES_USER}
POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
POSTGRES_DB=${POSTGRES_DB}
DOTENV
chmod 600 "${BASE_DIR}/.env"
ok ".env written."
# --- Generate docker-compose.yml ---
cat > "${BASE_DIR}/docker-compose.yml" <<'COMPOSE'
services:
postgres:
image: postgres:16-alpine
container_name: gitea-postgres
restart: unless-stopped
security_opt:
- no-new-privileges:true
mem_limit: 2G
read_only: true
tmpfs:
- /tmp
- /var/run/postgresql
volumes:
- ./postgres/data:/var/lib/postgresql/data
environment:
- POSTGRES_USER
- POSTGRES_PASSWORD
- POSTGRES_DB
networks:
- gitea-internal
logging:
driver: json-file
options:
max-size: "50m"
max-file: "5"
gitea:
image: docker.gitea.com/gitea:1.25.5
container_name: gitea
restart: unless-stopped
depends_on:
postgres:
condition: service_started
security_opt:
- no-new-privileges:true
mem_limit: 2G
environment:
- USER_UID=1000
- USER_GID=1000
- GITEA__database__DB_TYPE=postgres
- GITEA__database__HOST=gitea-postgres:5432
- GITEA__database__NAME=${POSTGRES_DB}
- GITEA__database__USER=${POSTGRES_USER}
- GITEA__database__PASSWD=${POSTGRES_PASSWORD}
- GITEA__server__ROOT_URL=https://${DOMAIN}
- GITEA__server__SSH_DOMAIN=${DOMAIN}
- GITEA__server__SSH_PORT=${SSH_PORT}
volumes:
- ./gitea/data:/data
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
ports:
- "${SSH_PORT}:22"
networks:
- gitea-internal
- traefik-public
logging:
driver: json-file
options:
max-size: "50m"
max-file: "5"
networks:
gitea-internal:
driver: bridge
traefik-public:
external: true
COMPOSE
ok "docker-compose.yml written."
fi
# --- Always do these (fresh or restored) ---
write_traefik_dynamic_config "$CONFIG_NAME" "$DOMAIN" "http://gitea:3000"
install_systemd_unit "$UNIT_NAME" "$BASE_DIR"
(cd "$BASE_DIR" && docker compose up -d)
wait_for_healthy "gitea" "http://localhost:3000/" 120
# --- Summary ---
echo ""
echo -e "${GREEN}══════════════════════════════════════════════${NC}"
echo -e "${GREEN} Gitea deployed successfully${NC}"
echo -e "${GREEN}══════════════════════════════════════════════${NC}"
echo -e " URL: https://${DOMAIN}"
echo -e " SSH: ssh://git@${DOMAIN}:${SSH_PORT}"
echo -e " Data: ${BASE_DIR}/"
echo -e " Traefik: ${TRAEFIK_DYNAMIC_DIR}/${CONFIG_NAME}.yml"
echo -e " Systemd: systemctl status ${UNIT_NAME}"
echo -e " Logs: docker logs -f gitea"
echo -e "${GREEN}══════════════════════════════════════════════${NC}"
echo ""

134
deploy-docker-hermes.sh Executable file
View File

@@ -0,0 +1,134 @@
#!/usr/bin/env bash
# ==============================================================================
# Hermes Agent Deployment Script (Docker — Gateway Mode)
# ==============================================================================
# Deploys Hermes Agent as a gateway connecting to Telegram/Discord/Slack/WhatsApp.
# No web UI — does NOT use Traefik.
#
# First run requires interactive setup for API keys:
# docker run -it --rm -v /opt/hermes/data:/opt/data nousresearch/hermes-agent setup
#
# Usage: deploy-docker-hermes.sh
# deploy-docker-hermes.sh --remove [--purge] [--yes]
# ==============================================================================
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "${SCRIPT_DIR}/common.sh"
# --- Parse arguments ---
parse_args "$@"
# --- Service config ---
SERVICE_NAME="hermes"
BASE_DIR="/opt/${SERVICE_NAME}"
CONFIG_NAME="" # No Traefik
UNIT_NAME="${SERVICE_NAME}-docker"
# --- Handle --remove ---
if [[ "$ARG_REMOVE" == "1" ]]; then
require_root
do_remove "$SERVICE_NAME" "$CONFIG_NAME" "$UNIT_NAME" "$BASE_DIR"
exit 0
fi
# --- Detect current state for banner ---
install_mode="Fresh install"
if [[ -f "${BASE_DIR}/.env" ]]; then
install_mode="Re-run (preserving data)"
fi
# --- Print deployment plan ---
echo ""
echo -e "${CYAN}══════════════════════════════════════════════${NC}"
echo -e "${CYAN} Deploying Hermes Agent (Docker/Gateway)${NC}"
echo -e "${CYAN}══════════════════════════════════════════════${NC}"
echo -e " Data: ${BASE_DIR}/data/"
echo -e " Traefik: Not used (no web UI)"
echo -e " Mode: ${install_mode}"
echo -e "${CYAN}══════════════════════════════════════════════${NC}"
echo ""
# --- Check if already deployed and running ---
if [[ -f "${BASE_DIR}/docker-compose.yml" ]] && \
docker ps --format '{{.Names}}' 2>/dev/null | grep -q '^hermes$'; then
echo -e "${GREEN} Hermes is already running — nothing to do.${NC}"
echo ""
echo -e " Logs: docker logs -f hermes"
echo -e " Backup: ${SCRIPT_DIR}/backup-hermes.sh"
echo -e " Remove: $0 --remove [--purge]"
echo ""
exit 0
fi
# --- Infrastructure (Docker only, no Traefik) ---
require_root
detect_os
install_prerequisites
ensure_docker_network
# --- Backup restore check ---
if check_and_restore_backup "$SERVICE_NAME"; then
ok "Backup restored."
else
# --- Fresh install ---
mkdir -p "${BASE_DIR}/data"
if [[ ! -f "${BASE_DIR}/.env" ]]; then
cat > "${BASE_DIR}/.env" <<DOTENV
# Generated by deploy-docker-hermes.sh on $(date -Iseconds)
DOTENV
chmod 600 "${BASE_DIR}/.env"
fi
# --- Generate docker-compose.yml ---
cat > "${BASE_DIR}/docker-compose.yml" <<'COMPOSE'
services:
hermes:
image: nousresearch/hermes-agent:latest
container_name: hermes
restart: unless-stopped
command: gateway run
security_opt:
- no-new-privileges:true
mem_limit: 4G
shm_size: 1g
volumes:
- ./data:/opt/data
logging:
driver: json-file
options:
max-size: "50m"
max-file: "5"
COMPOSE
ok "docker-compose.yml written."
fi
# --- Start ---
install_systemd_unit "$UNIT_NAME" "$BASE_DIR"
(cd "$BASE_DIR" && docker compose up -d)
# Health check — hermes doesn't have HTTP, use version command
info "Checking Hermes is running..."
sleep 5
if docker exec hermes hermes version > /dev/null 2>&1; then
ok "Hermes is running."
else
warn "Hermes container started but 'hermes version' failed. May need initial setup."
fi
# --- Summary ---
echo ""
echo -e "${GREEN}══════════════════════════════════════════════${NC}"
echo -e "${GREEN} Hermes Agent deployed successfully${NC}"
echo -e "${GREEN}══════════════════════════════════════════════${NC}"
echo -e " Data: ${BASE_DIR}/data/"
echo -e " Systemd: systemctl status ${UNIT_NAME}"
echo -e " Logs: docker logs -f hermes"
echo ""
echo -e " ${YELLOW}First run? Set up API keys:${NC}"
echo -e " docker run -it --rm -v ${BASE_DIR}/data:/opt/data nousresearch/hermes-agent setup"
echo -e " Then: cd ${BASE_DIR} && docker compose restart"
echo -e "${GREEN}══════════════════════════════════════════════${NC}"
echo ""

255
deploy-docker-mattermost.sh Executable file
View File

@@ -0,0 +1,255 @@
#!/usr/bin/env bash
# ==============================================================================
# Mattermost Deployment Script (Docker Compose + Traefik)
# ==============================================================================
# Deploys Mattermost + PostgreSQL behind the shared Traefik reverse proxy.
# ChatOps-optimized: bot accounts, webhooks, personal access tokens enabled.
#
# Usage: deploy-docker-mattermost.sh
# deploy-docker-mattermost.sh --domain chat.us.an2.io
# deploy-docker-mattermost.sh --remove [--purge] [--yes]
# ==============================================================================
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "${SCRIPT_DIR}/common.sh"
# --- Parse arguments ---
parse_args "$@"
# --- Service config ---
SERVICE_NAME="mattermost"
BASE_DIR="/opt/${SERVICE_NAME}"
CONFIG_NAME="${SERVICE_NAME}"
UNIT_NAME="${SERVICE_NAME}-docker"
CALLS_PORT="${CALLS_PORT:-8443}"
# --- Handle --remove ---
if [[ "$ARG_REMOVE" == "1" ]]; then
require_root
do_remove "$SERVICE_NAME" "$CONFIG_NAME" "$UNIT_NAME" "$BASE_DIR"
exit 0
fi
# --- Domain resolution: --domain > existing .env > default ---
if [[ -n "$ARG_DOMAIN" ]]; then
DOMAIN="$ARG_DOMAIN"
elif [[ -f "${BASE_DIR}/.env" ]] && grep -q '^DOMAIN=' "${BASE_DIR}/.env"; then
DOMAIN="$(grep '^DOMAIN=' "${BASE_DIR}/.env" | cut -d= -f2)"
else
DOMAIN="mm.an2.io"
fi
# --- Detect current state for banner ---
traefik_status="Not found — will deploy"
if docker ps --format '{{.Names}}' 2>/dev/null | grep -q '^traefik$'; then
traefik_status="Running"
elif [[ -f "${TRAEFIK_DIR}/docker-compose.yml" ]]; then
traefik_status="Stopped — will start"
fi
install_mode="Fresh install"
if [[ -f "${BASE_DIR}/.env" ]]; then
install_mode="Re-run (preserving data)"
fi
# --- Print deployment plan ---
echo ""
echo -e "${CYAN}══════════════════════════════════════════════${NC}"
echo -e "${CYAN} Deploying Mattermost (Docker)${NC}"
echo -e "${CYAN}══════════════════════════════════════════════${NC}"
echo -e " Domain: ${DOMAIN}"
echo -e " Data: ${BASE_DIR}"
echo -e " Calls Port: ${CALLS_PORT}"
echo -e " Traefik: ${traefik_status}"
echo -e " Mode: ${install_mode}"
echo -e "${CYAN}══════════════════════════════════════════════${NC}"
echo ""
# --- Check if already deployed and running ---
if [[ -f "${BASE_DIR}/docker-compose.yml" ]] && \
docker ps --format '{{.Names}}' 2>/dev/null | grep -q '^mm-app$'; then
echo -e "${GREEN} Mattermost is already running — nothing to do.${NC}"
echo ""
echo -e " Logs: docker logs -f mm-app"
echo -e " Backup: ${SCRIPT_DIR}/backup-mattermost.sh"
echo -e " Remove: $0 --remove [--purge]"
echo ""
exit 0
fi
# --- Shared infrastructure ---
require_root
detect_os
install_prerequisites
ensure_docker_network
ensure_traefik
configure_firewall "${CALLS_PORT}/udp" "${CALLS_PORT}/tcp"
# --- Backup restore check ---
if check_and_restore_backup "$SERVICE_NAME"; then
saved_domain="$DOMAIN"
# shellcheck source=/dev/null
source "${BASE_DIR}/.env"
DOMAIN="$saved_domain"
else
# --- Fresh install ---
if [[ -f "${BASE_DIR}/.env" ]]; then
saved_domain="$DOMAIN"
# shellcheck source=/dev/null
source "${BASE_DIR}/.env"
DOMAIN="$saved_domain"
else
POSTGRES_USER="mmuser"
POSTGRES_PASSWORD="$(generate_password)"
POSTGRES_DB="mattermost"
fi
info "Creating directory layout..."
mkdir -p "${BASE_DIR}"/{postgres/data,mattermost/{config,data,logs,plugins,client-plugins,bleve-indexes}}
chown -R 2000:2000 "${BASE_DIR}/mattermost"
# --- Generate .env ---
cat > "${BASE_DIR}/.env" <<DOTENV
# Generated by deploy-docker-mattermost.sh on $(date -Iseconds)
DOMAIN=${DOMAIN}
TZ=UTC
CALLS_PORT=${CALLS_PORT}
# PostgreSQL
POSTGRES_USER=${POSTGRES_USER}
POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
POSTGRES_DB=${POSTGRES_DB}
# Mattermost
MM_SQLSETTINGS_DRIVERNAME=postgres
MM_SQLSETTINGS_DATASOURCE=postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@mm-postgres:5432/${POSTGRES_DB}?sslmode=disable&connect_timeout=10
MM_BLEVESETTINGS_INDEXDIR=/mattermost/bleve-indexes
MM_SERVICESETTINGS_SITEURL=https://${DOMAIN}
# ChatOps settings
MM_SERVICESETTINGS_ENABLEBOTACCOUNTCREATION=true
MM_SERVICESETTINGS_ENABLEOAUTHSERVICEPROVIDER=true
MM_SERVICESETTINGS_ENABLEINCOMINGWEBHOOKS=true
MM_SERVICESETTINGS_ENABLEOUTGOINGWEBHOOKS=true
MM_SERVICESETTINGS_ENABLEPOSTUSERNAMEOVERRIDE=true
MM_SERVICESETTINGS_ENABLEPOSTICONOVERRIDE=true
MM_SERVICESETTINGS_ENABLECUSTOMEMOJI=true
MM_SERVICESETTINGS_ENABLEUSERACCESSTOKENS=true
MM_PLUGINSETTINGS_ENABLEUPLOADS=true
MM_TEAMSETTINGS_ENABLEOPENSERVER=false
DOTENV
chmod 600 "${BASE_DIR}/.env"
ok ".env written."
# --- Generate docker-compose.yml ---
cat > "${BASE_DIR}/docker-compose.yml" <<'COMPOSE'
# Mattermost + PostgreSQL
# Based on https://github.com/mattermost/docker
services:
postgres:
image: postgres:16-alpine
container_name: mm-postgres
restart: unless-stopped
security_opt:
- no-new-privileges:true
mem_limit: 4G
read_only: true
tmpfs:
- /tmp
- /var/run/postgresql
volumes:
- ./postgres/data:/var/lib/postgresql
environment:
- TZ
- POSTGRES_USER
- POSTGRES_PASSWORD
- POSTGRES_DB
networks:
- mm-internal
logging:
driver: json-file
options:
max-size: "50m"
max-file: "5"
mattermost:
depends_on:
postgres:
condition: service_started
image: mattermost/mattermost-enterprise-edition:release-11.0
container_name: mm-app
restart: unless-stopped
security_opt:
- no-new-privileges:true
mem_limit: 4G
tmpfs:
- /tmp
volumes:
- ./mattermost/config:/mattermost/config:rw
- ./mattermost/data:/mattermost/data:rw
- ./mattermost/logs:/mattermost/logs:rw
- ./mattermost/plugins:/mattermost/plugins:rw
- ./mattermost/client-plugins:/mattermost/client/plugins:rw
- ./mattermost/bleve-indexes:/mattermost/bleve-indexes:rw
environment:
- TZ
- MM_SQLSETTINGS_DRIVERNAME
- MM_SQLSETTINGS_DATASOURCE
- MM_BLEVESETTINGS_INDEXDIR
- MM_SERVICESETTINGS_SITEURL
- MM_SERVICESETTINGS_ENABLEBOTACCOUNTCREATION
- MM_SERVICESETTINGS_ENABLEOAUTHSERVICEPROVIDER
- MM_SERVICESETTINGS_ENABLEINCOMINGWEBHOOKS
- MM_SERVICESETTINGS_ENABLEOUTGOINGWEBHOOKS
- MM_SERVICESETTINGS_ENABLEPOSTUSERNAMEOVERRIDE
- MM_SERVICESETTINGS_ENABLEPOSTICONOVERRIDE
- MM_SERVICESETTINGS_ENABLECUSTOMEMOJI
- MM_SERVICESETTINGS_ENABLEUSERACCESSTOKENS
- MM_PLUGINSETTINGS_ENABLEUPLOADS
- MM_TEAMSETTINGS_ENABLEOPENSERVER
ports:
- "${CALLS_PORT}:${CALLS_PORT}/udp"
- "${CALLS_PORT}:${CALLS_PORT}/tcp"
networks:
- mm-internal
- traefik-public
logging:
driver: json-file
options:
max-size: "50m"
max-file: "5"
networks:
mm-internal:
driver: bridge
traefik-public:
external: true
COMPOSE
ok "docker-compose.yml written."
fi
# --- Always do these (fresh or restored) ---
write_traefik_dynamic_config "$CONFIG_NAME" "$DOMAIN" "http://mm-app:8065"
install_systemd_unit "$UNIT_NAME" "$BASE_DIR"
(cd "$BASE_DIR" && docker compose up -d)
wait_for_healthy "mm-app" "http://localhost:8443/api/v4/system/ping" 120
# --- Summary ---
echo ""
echo -e "${GREEN}══════════════════════════════════════════════${NC}"
echo -e "${GREEN} Mattermost deployed successfully${NC}"
echo -e "${GREEN}══════════════════════════════════════════════${NC}"
echo -e " URL: https://${DOMAIN}"
echo -e " Calls: Port ${CALLS_PORT} (UDP+TCP)"
echo -e " Data: ${BASE_DIR}/"
echo -e " Traefik: ${TRAEFIK_DYNAMIC_DIR}/${CONFIG_NAME}.yml"
echo -e " Systemd: systemctl status ${UNIT_NAME}"
echo -e " Logs: docker logs -f mm-app"
echo ""
echo -e " ${YELLOW}Next: Open https://${DOMAIN} and create your admin account.${NC}"
echo -e "${GREEN}══════════════════════════════════════════════${NC}"
echo ""

169
deploy-docker-open-webui.sh Executable file
View File

@@ -0,0 +1,169 @@
#!/usr/bin/env bash
# ==============================================================================
# Open WebUI Deployment Script (Docker Compose + Traefik)
# ==============================================================================
# Deploys Open WebUI behind the shared Traefik reverse proxy.
#
# Usage: deploy-docker-open-webui.sh
# deploy-docker-open-webui.sh --domain chat.us.an2.io
# deploy-docker-open-webui.sh --remove [--purge] [--yes]
# ==============================================================================
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "${SCRIPT_DIR}/common.sh"
# --- Parse arguments ---
parse_args "$@"
# --- Service config ---
SERVICE_NAME="open-webui"
BASE_DIR="/opt/${SERVICE_NAME}"
CONFIG_NAME="${SERVICE_NAME}"
UNIT_NAME="${SERVICE_NAME}-docker"
# --- Handle --remove ---
if [[ "$ARG_REMOVE" == "1" ]]; then
require_root
do_remove "$SERVICE_NAME" "$CONFIG_NAME" "$UNIT_NAME" "$BASE_DIR"
exit 0
fi
# --- Domain resolution: --domain > existing .env > default ---
if [[ -n "$ARG_DOMAIN" ]]; then
DOMAIN="$ARG_DOMAIN"
elif [[ -f "${BASE_DIR}/.env" ]] && grep -q '^DOMAIN=' "${BASE_DIR}/.env"; then
DOMAIN="$(grep '^DOMAIN=' "${BASE_DIR}/.env" | cut -d= -f2)"
else
DOMAIN="ai.an2.io"
fi
# --- Detect current state for banner ---
traefik_status="Not found — will deploy"
if docker ps --format '{{.Names}}' 2>/dev/null | grep -q '^traefik$'; then
traefik_status="Running"
elif [[ -f "${TRAEFIK_DIR}/docker-compose.yml" ]]; then
traefik_status="Stopped — will start"
fi
install_mode="Fresh install"
if [[ -f "${BASE_DIR}/.env" ]]; then
install_mode="Re-run (preserving data)"
fi
# --- Print deployment plan ---
echo ""
echo -e "${CYAN}══════════════════════════════════════════════${NC}"
echo -e "${CYAN} Deploying Open WebUI (Docker)${NC}"
echo -e "${CYAN}══════════════════════════════════════════════${NC}"
echo -e " Domain: ${DOMAIN}"
echo -e " Data: ${BASE_DIR}"
echo -e " Traefik: ${traefik_status}"
echo -e " Mode: ${install_mode}"
echo -e "${CYAN}══════════════════════════════════════════════${NC}"
echo ""
# --- Check if already deployed and running ---
if [[ -f "${BASE_DIR}/docker-compose.yml" ]] && \
docker ps --format '{{.Names}}' 2>/dev/null | grep -q '^open-webui$'; then
echo -e "${GREEN} Open WebUI is already running — nothing to do.${NC}"
echo ""
echo -e " Logs: docker logs -f open-webui"
echo -e " Backup: ${SCRIPT_DIR}/backup-open-webui.sh"
echo -e " Remove: $0 --remove [--purge]"
echo ""
exit 0
fi
# --- Shared infrastructure ---
require_root
detect_os
install_prerequisites
ensure_docker_network
ensure_traefik
configure_firewall
# --- Backup restore check ---
if check_and_restore_backup "$SERVICE_NAME"; then
saved_domain="$DOMAIN"
# shellcheck source=/dev/null
source "${BASE_DIR}/.env"
DOMAIN="$saved_domain"
else
# --- Fresh install ---
if [[ -f "${BASE_DIR}/.env" ]]; then
saved_domain="$DOMAIN"
# shellcheck source=/dev/null
source "${BASE_DIR}/.env"
DOMAIN="$saved_domain"
else
WEBUI_SECRET_KEY="$(openssl rand -hex 32)"
fi
info "Creating directory layout..."
mkdir -p "${BASE_DIR}/data"
# --- Generate .env ---
cat > "${BASE_DIR}/.env" <<DOTENV
# Generated by deploy-docker-open-webui.sh on $(date -Iseconds)
DOMAIN=${DOMAIN}
WEBUI_SECRET_KEY=${WEBUI_SECRET_KEY}
DOTENV
chmod 600 "${BASE_DIR}/.env"
ok ".env written."
# --- Generate docker-compose.yml ---
cat > "${BASE_DIR}/docker-compose.yml" <<'COMPOSE'
services:
open-webui:
image: ghcr.io/open-webui/open-webui:main
container_name: open-webui
restart: unless-stopped
security_opt:
- no-new-privileges:true
mem_limit: 4G
volumes:
- ./data:/app/backend/data
environment:
- WEBUI_SECRET_KEY
- WEBUI_AUTH=False
# Use host gateway so container can reach host-side Ollama
- OLLAMA_BASE_URL=http://host.docker.internal:11434
extra_hosts:
- "host.docker.internal:host-gateway"
networks:
- traefik-public
logging:
driver: json-file
options:
max-size: "50m"
max-file: "5"
networks:
traefik-public:
external: true
COMPOSE
ok "docker-compose.yml written."
fi
# --- Always do these (fresh or restored) ---
write_traefik_dynamic_config "$CONFIG_NAME" "$DOMAIN" "http://open-webui:8080"
install_systemd_unit "$UNIT_NAME" "$BASE_DIR"
(cd "$BASE_DIR" && docker compose up -d)
wait_for_healthy "open-webui" "http://localhost:8080" 120
# --- Summary ---
echo ""
echo -e "${GREEN}══════════════════════════════════════════════${NC}"
echo -e "${GREEN} Open WebUI deployed successfully${NC}"
echo -e "${GREEN}══════════════════════════════════════════════${NC}"
echo -e " URL: https://${DOMAIN}"
echo -e " Data: ${BASE_DIR}/data/"
echo -e " Auth: disabled (single-user mode)"
echo -e " Traefik: ${TRAEFIK_DYNAMIC_DIR}/${CONFIG_NAME}.yml"
echo -e " Systemd: systemctl status ${UNIT_NAME}"
echo -e " Logs: docker logs -f open-webui"
echo -e "${GREEN}══════════════════════════════════════════════${NC}"
echo ""

162
deploy-docker-openclaw.sh Executable file
View File

@@ -0,0 +1,162 @@
#!/usr/bin/env bash
# ==============================================================================
# OpenClaw Deployment Script (Docker Compose + Traefik)
# ==============================================================================
# Deploys OpenClaw gateway behind the shared Traefik reverse proxy.
# Source: https://docs.openclaw.ai/install/docker
#
# Usage: deploy-docker-openclaw.sh
# deploy-docker-openclaw.sh --domain claw.an2.io
# deploy-docker-openclaw.sh --remove [--purge] [--yes]
# ==============================================================================
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "${SCRIPT_DIR}/common.sh"
# --- Parse arguments ---
parse_args "$@"
# --- Service config ---
SERVICE_NAME="openclaw"
BASE_DIR="/opt/${SERVICE_NAME}"
CONFIG_NAME="${SERVICE_NAME}"
UNIT_NAME="${SERVICE_NAME}-docker"
# --- Handle --remove ---
if [[ "$ARG_REMOVE" == "1" ]]; then
require_root
do_remove "$SERVICE_NAME" "$CONFIG_NAME" "$UNIT_NAME" "$BASE_DIR"
exit 0
fi
# --- Domain resolution ---
if [[ -n "$ARG_DOMAIN" ]]; then
DOMAIN="$ARG_DOMAIN"
elif [[ -f "${BASE_DIR}/.env" ]] && grep -q '^DOMAIN=' "${BASE_DIR}/.env"; then
DOMAIN="$(grep '^DOMAIN=' "${BASE_DIR}/.env" | cut -d= -f2)"
else
DOMAIN="openclaw.an2.io"
fi
# --- Detect current state for banner ---
traefik_status="Not found — will deploy"
if docker ps --format '{{.Names}}' 2>/dev/null | grep -q '^traefik$'; then
traefik_status="Running"
elif [[ -f "${TRAEFIK_DIR}/docker-compose.yml" ]]; then
traefik_status="Stopped — will start"
fi
install_mode="Fresh install"
if [[ -f "${BASE_DIR}/.env" ]]; then
install_mode="Re-run (preserving data)"
fi
# --- Print deployment plan ---
echo ""
echo -e "${CYAN}══════════════════════════════════════════════${NC}"
echo -e "${CYAN} Deploying OpenClaw (Docker)${NC}"
echo -e "${CYAN}══════════════════════════════════════════════${NC}"
echo -e " Domain: ${DOMAIN}"
echo -e " Data: ${BASE_DIR}"
echo -e " Traefik: ${traefik_status}"
echo -e " Mode: ${install_mode}"
echo -e "${CYAN}══════════════════════════════════════════════${NC}"
echo ""
# --- Check if already deployed and running ---
if [[ -f "${BASE_DIR}/docker-compose.yml" ]] && \
docker ps --format '{{.Names}}' 2>/dev/null | grep -q '^openclaw$'; then
echo -e "${GREEN} OpenClaw is already running — nothing to do.${NC}"
echo ""
echo -e " Logs: docker logs -f openclaw"
echo -e " Backup: ${SCRIPT_DIR}/backup-openclaw.sh"
echo -e " Remove: $0 --remove [--purge]"
echo ""
exit 0
fi
# --- Shared infrastructure ---
require_root
detect_os
install_prerequisites
ensure_docker_network
ensure_traefik
configure_firewall
# --- Backup restore check ---
if check_and_restore_backup "$SERVICE_NAME"; then
saved_domain="$DOMAIN"
# shellcheck source=/dev/null
source "${BASE_DIR}/.env"
DOMAIN="$saved_domain"
else
# --- Fresh install ---
if [[ -f "${BASE_DIR}/.env" ]]; then
saved_domain="$DOMAIN"
# shellcheck source=/dev/null
source "${BASE_DIR}/.env"
DOMAIN="$saved_domain"
fi
mkdir -p "${BASE_DIR}/data"
chown -R 1000:1000 "${BASE_DIR}/data"
# --- Generate .env ---
cat > "${BASE_DIR}/.env" <<DOTENV
# Generated by deploy-docker-openclaw.sh on $(date -Iseconds)
DOMAIN=${DOMAIN}
DOTENV
chmod 600 "${BASE_DIR}/.env"
ok ".env written."
# --- Generate docker-compose.yml ---
cat > "${BASE_DIR}/docker-compose.yml" <<'COMPOSE'
services:
openclaw:
image: ghcr.io/openclaw/openclaw:latest
container_name: openclaw
restart: unless-stopped
security_opt:
- no-new-privileges:true
mem_limit: 2G
user: "1000:1000"
environment:
- OPENCLAW_CONFIG_DIR=/home/node/.openclaw
volumes:
- ./data:/home/node/.openclaw
networks:
- traefik-public
logging:
driver: json-file
options:
max-size: "50m"
max-file: "5"
networks:
traefik-public:
external: true
COMPOSE
ok "docker-compose.yml written."
fi
# --- Always do these (fresh or restored) ---
write_traefik_dynamic_config "$CONFIG_NAME" "$DOMAIN" "http://openclaw:18789"
install_systemd_unit "$UNIT_NAME" "$BASE_DIR"
(cd "$BASE_DIR" && docker compose up -d)
wait_for_healthy "openclaw" "http://localhost:18789/healthz" 120
# --- Summary ---
echo ""
echo -e "${GREEN}══════════════════════════════════════════════${NC}"
echo -e "${GREEN} OpenClaw deployed successfully${NC}"
echo -e "${GREEN}══════════════════════════════════════════════${NC}"
echo -e " URL: https://${DOMAIN}"
echo -e " Data: ${BASE_DIR}/data/"
echo -e " Traefik: ${TRAEFIK_DYNAMIC_DIR}/${CONFIG_NAME}.yml"
echo -e " Systemd: systemctl status ${UNIT_NAME}"
echo -e " Logs: docker logs -f openclaw"
echo -e "${GREEN}══════════════════════════════════════════════${NC}"
echo ""

216
deploy-docker-paperclip.sh Executable file
View File

@@ -0,0 +1,216 @@
#!/usr/bin/env bash
# ==============================================================================
# Paperclip Deployment Script (Docker Compose + Traefik)
# ==============================================================================
# Builds and deploys Paperclip from source behind the shared Traefik reverse proxy.
# Source: https://github.com/paperclipai/paperclip
#
# Usage: deploy-docker-paperclip.sh
# deploy-docker-paperclip.sh --domain clip.us.an2.io
# deploy-docker-paperclip.sh --remove [--purge] [--yes]
# ==============================================================================
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "${SCRIPT_DIR}/common.sh"
# --- Parse arguments ---
parse_args "$@"
# --- Service config ---
SERVICE_NAME="paperclip"
BASE_DIR="/opt/${SERVICE_NAME}"
CONFIG_NAME="${SERVICE_NAME}"
UNIT_NAME="${SERVICE_NAME}-docker"
PAPERCLIP_REPO="${PAPERCLIP_REPO:-https://github.com/paperclipai/paperclip.git}"
# --- Handle --remove ---
if [[ "$ARG_REMOVE" == "1" ]]; then
require_root
do_remove "$SERVICE_NAME" "$CONFIG_NAME" "$UNIT_NAME" "$BASE_DIR"
exit 0
fi
# --- Domain resolution ---
if [[ -n "$ARG_DOMAIN" ]]; then
DOMAIN="$ARG_DOMAIN"
elif [[ -f "${BASE_DIR}/.env" ]] && grep -q '^DOMAIN=' "${BASE_DIR}/.env"; then
DOMAIN="$(grep '^DOMAIN=' "${BASE_DIR}/.env" | cut -d= -f2)"
else
DOMAIN="paperclip.an2.io"
fi
# --- Detect current state for banner ---
traefik_status="Not found — will deploy"
if docker ps --format '{{.Names}}' 2>/dev/null | grep -q '^traefik$'; then
traefik_status="Running"
elif [[ -f "${TRAEFIK_DIR}/docker-compose.yml" ]]; then
traefik_status="Stopped — will start"
fi
install_mode="Fresh install"
if [[ -f "${BASE_DIR}/.env" ]]; then
install_mode="Re-run (preserving data)"
fi
# --- Print deployment plan ---
echo ""
echo -e "${CYAN}══════════════════════════════════════════════${NC}"
echo -e "${CYAN} Deploying Paperclip (Docker)${NC}"
echo -e "${CYAN}══════════════════════════════════════════════${NC}"
echo -e " Domain: ${DOMAIN}"
echo -e " Data: ${BASE_DIR}"
echo -e " Traefik: ${traefik_status}"
echo -e " Mode: ${install_mode}"
echo -e "${CYAN}══════════════════════════════════════════════${NC}"
echo ""
# --- Check if already deployed and running ---
if [[ -f "${BASE_DIR}/docker-compose.yml" ]] && \
docker ps --format '{{.Names}}' 2>/dev/null | grep -q '^paperclip$'; then
echo -e "${GREEN} Paperclip is already running — nothing to do.${NC}"
echo ""
echo -e " Logs: docker logs -f paperclip"
echo -e " Backup: ${SCRIPT_DIR}/backup-paperclip.sh"
echo -e " Remove: $0 --remove [--purge]"
echo ""
exit 0
fi
# --- Shared infrastructure ---
require_root
detect_os
install_prerequisites
ensure_docker_network
ensure_traefik
configure_firewall
# --- Backup restore check ---
if check_and_restore_backup "$SERVICE_NAME"; then
saved_domain="$DOMAIN"
# shellcheck source=/dev/null
source "${BASE_DIR}/.env"
DOMAIN="$saved_domain"
else
# --- Fresh install ---
if [[ -f "${BASE_DIR}/.env" ]]; then
saved_domain="$DOMAIN"
# shellcheck source=/dev/null
source "${BASE_DIR}/.env"
DOMAIN="$saved_domain"
fi
mkdir -p "${BASE_DIR}/data" "${BASE_DIR}/postgres/data"
# Clone or update source
if [[ -d "${BASE_DIR}/repo/.git" ]]; then
info "Updating source..."
(cd "${BASE_DIR}/repo" && git pull)
else
info "Cloning Paperclip from ${PAPERCLIP_REPO}..."
git clone "$PAPERCLIP_REPO" "${BASE_DIR}/repo"
fi
# --- Generate .env (preserve existing passwords on re-run) ---
if [[ -z "${DB_PASS:-}" ]]; then
DB_PASS="$(generate_password)"
fi
if [[ -z "${BETTER_AUTH_SECRET:-}" ]]; then
BETTER_AUTH_SECRET="$(generate_password)"
fi
cat > "${BASE_DIR}/.env" <<DOTENV
# Generated by deploy-docker-paperclip.sh on $(date -Iseconds)
DOMAIN=${DOMAIN}
DB_PASS=${DB_PASS}
BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET}
DOTENV
chmod 600 "${BASE_DIR}/.env"
ok ".env written."
# --- Generate docker-compose.yml ---
cat > "${BASE_DIR}/docker-compose.yml" <<COMPOSE
services:
paperclip-postgres:
image: postgres:16-alpine
container_name: paperclip-postgres
restart: unless-stopped
security_opt:
- no-new-privileges:true
mem_limit: 1G
read_only: true
tmpfs:
- /tmp
- /var/run/postgresql
environment:
- POSTGRES_USER=paperclip
- POSTGRES_PASSWORD=${DB_PASS}
- POSTGRES_DB=paperclip
volumes:
- ./postgres/data:/var/lib/postgresql/data
networks:
- paperclip-internal
logging:
driver: json-file
options:
max-size: "50m"
max-file: "5"
paperclip:
build: ./repo
container_name: paperclip
restart: unless-stopped
depends_on:
- paperclip-postgres
security_opt:
- no-new-privileges:true
mem_limit: 4G
environment:
- PORT=3100
- HOST=0.0.0.0
- SERVE_UI=true
- DATABASE_URL=postgres://paperclip:${DB_PASS}@paperclip-postgres:5432/paperclip
- BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET}
volumes:
- ./data:/paperclip
networks:
- paperclip-internal
- traefik-public
logging:
driver: json-file
options:
max-size: "50m"
max-file: "5"
networks:
paperclip-internal:
driver: bridge
traefik-public:
external: true
COMPOSE
ok "docker-compose.yml written."
info "Building Paperclip image (this may take a few minutes)..."
(cd "$BASE_DIR" && docker compose build)
fi
# --- Always do these (fresh or restored) ---
write_traefik_dynamic_config "$CONFIG_NAME" "$DOMAIN" "http://paperclip:3100"
install_systemd_unit "$UNIT_NAME" "$BASE_DIR"
(cd "$BASE_DIR" && docker compose up -d)
wait_for_healthy "paperclip" "http://localhost:3100/api/health" 120
# --- Summary ---
echo ""
echo -e "${GREEN}══════════════════════════════════════════════${NC}"
echo -e "${GREEN} Paperclip deployed successfully${NC}"
echo -e "${GREEN}══════════════════════════════════════════════${NC}"
echo -e " URL: https://${DOMAIN}"
echo -e " Data: ${BASE_DIR}/data/"
echo -e " Traefik: ${TRAEFIK_DYNAMIC_DIR}/${CONFIG_NAME}.yml"
echo -e " Systemd: systemctl status ${UNIT_NAME}"
echo -e " Logs: docker logs -f paperclip"
echo -e "${GREEN}══════════════════════════════════════════════${NC}"
echo ""

140
deploy-docker-traefik.sh Executable file
View File

@@ -0,0 +1,140 @@
#!/usr/bin/env bash
# ==============================================================================
# Standalone Traefik Deployment
# ==============================================================================
# Deploys the shared Traefik reverse proxy at /opt/traefik/.
# Usually auto-bootstrapped by the first service deploy script — this script
# is only needed if you want to set up infrastructure ahead of time.
#
# Usage: deploy-docker-traefik.sh
# deploy-docker-traefik.sh --remove # Stop, keep certs + configs
# deploy-docker-traefik.sh --remove --purge # Delete everything
# ==============================================================================
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "${SCRIPT_DIR}/common.sh"
# --- Parse arguments ---
parse_args "$@"
SERVICE_NAME="traefik"
BASE_DIR="$TRAEFIK_DIR"
UNIT_NAME="traefik-docker"
# --- Handle --remove ---
if [[ "$ARG_REMOVE" == "1" ]]; then
require_root
echo ""
echo -e "${YELLOW}══════════════════════════════════════════════${NC}"
echo -e "${YELLOW} Removing Traefik${NC}"
echo -e "${YELLOW}══════════════════════════════════════════════${NC}"
if [[ "$ARG_PURGE" == "1" ]]; then
echo -e " Mode: ${RED}Purge (delete certs + configs)${NC}"
else
echo -e " Mode: Safe (keep acme.json + dynamic/)"
fi
echo -e " Data: ${BASE_DIR}"
echo -e " Systemd: ${UNIT_NAME}.service"
echo -e "${YELLOW}══════════════════════════════════════════════${NC}"
echo ""
warn "Removing Traefik will break ALL services that depend on it."
if [[ "$ARG_YES" != "1" ]]; then
read -rp "Continue? [y/N] " confirm
if [[ "${confirm,,}" != "y" ]]; then
info "Cancelled."
exit 0
fi
fi
# Stop traefik
if [[ -d "$BASE_DIR" ]] && [[ -f "${BASE_DIR}/docker-compose.yml" ]]; then
(cd "$BASE_DIR" && docker compose down) || true
ok "Traefik stopped."
fi
remove_systemd_unit "$UNIT_NAME"
if [[ "$ARG_PURGE" == "1" ]]; then
warn "This will delete ALL Traefik data including certificates and dynamic configs."
if [[ "$ARG_YES" != "1" ]]; then
read -rp "Permanently delete ${BASE_DIR}? [y/N] " confirm
if [[ "${confirm,,}" != "y" ]]; then
info "Purge cancelled."
exit 0
fi
fi
rm -rf "$BASE_DIR"
ok "Purged: $BASE_DIR"
else
rm -f "${BASE_DIR}/docker-compose.yml"
ok "Removed compose file. Preserved acme.json and dynamic/ configs."
fi
echo ""
echo -e "${GREEN}══════════════════════════════════════════════${NC}"
echo -e "${GREEN} Traefik removed${NC}"
echo -e "${GREEN}══════════════════════════════════════════════${NC}"
if [[ "$ARG_PURGE" != "1" ]]; then
echo -e " Certs kept: ${BASE_DIR}/acme.json"
echo -e " Configs: ${TRAEFIK_DYNAMIC_DIR}/"
fi
echo -e " To redeploy: $0"
echo -e "${GREEN}══════════════════════════════════════════════${NC}"
echo ""
exit 0
fi
# --- Detect current state for banner ---
traefik_status="Not deployed"
if docker ps --format '{{.Names}}' 2>/dev/null | grep -q '^traefik$'; then
traefik_status="Running"
elif [[ -f "${TRAEFIK_DIR}/docker-compose.yml" ]]; then
traefik_status="Stopped"
fi
# --- Print deployment plan ---
echo ""
echo -e "${CYAN}══════════════════════════════════════════════${NC}"
echo -e "${CYAN} Deploying Traefik${NC}"
echo -e "${CYAN}══════════════════════════════════════════════${NC}"
echo -e " Data: ${TRAEFIK_DIR}"
echo -e " Dynamic: ${TRAEFIK_DYNAMIC_DIR}/"
echo -e " ACME Email: ${ACME_EMAIL}"
echo -e " Status: ${traefik_status}"
echo -e "${CYAN}══════════════════════════════════════════════${NC}"
echo ""
# --- Check if already deployed and running ---
if docker ps --format '{{.Names}}' 2>/dev/null | grep -q '^traefik$'; then
echo -e "${GREEN} Traefik is already running — nothing to do.${NC}"
echo ""
echo -e " Logs: docker logs -f traefik"
echo -e " Backup: ${SCRIPT_DIR}/backup-traefik.sh"
echo -e " Remove: $0 --remove [--purge]"
echo ""
exit 0
fi
# --- Deploy ---
require_root
detect_os
install_prerequisites
ensure_docker_network
ensure_traefik
configure_firewall
# --- Summary ---
echo ""
echo -e "${GREEN}══════════════════════════════════════════════${NC}"
echo -e "${GREEN} Traefik deployed successfully${NC}"
echo -e "${GREEN}══════════════════════════════════════════════${NC}"
echo -e " Dynamic: ${TRAEFIK_DYNAMIC_DIR}/"
echo -e " Certs: ${TRAEFIK_DIR}/acme.json"
echo -e " Systemd: systemctl status ${UNIT_NAME}"
echo -e " Logs: docker logs -f traefik"
echo -e "${GREEN}══════════════════════════════════════════════${NC}"
echo ""

190
deploy-docker-web.sh Executable file
View File

@@ -0,0 +1,190 @@
#!/usr/bin/env bash
# ==============================================================================
# Static Web (nginx) Deployment Script — Multi-Instance
# ==============================================================================
# Deploys an nginx container serving static files behind Traefik.
# Each instance is identified by --dir. Multiple instances can coexist.
#
# Usage: deploy-docker-web.sh --domain web1.an2.io --dir acme
# deploy-docker-web.sh --domain web2.an2.io --dir acme-dev
# deploy-docker-web.sh --remove --dir acme [--purge] [--yes]
# ==============================================================================
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "${SCRIPT_DIR}/common.sh"
# --- Parse arguments ---
parse_args "$@"
# --- Validate --dir ---
[[ -n "$ARG_DIR" ]] || fatal "--dir is required. Example: $0 --domain web.an2.io --dir mysite"
if ! [[ "$ARG_DIR" =~ ^[a-zA-Z0-9][a-zA-Z0-9_-]*$ ]]; then
fatal "--dir value must be alphanumeric (hyphens/underscores ok, no spaces/slashes): ${ARG_DIR}"
fi
# --- Derive names from --dir ---
DIR_NAME="$ARG_DIR"
SERVICE_NAME="web-${DIR_NAME}"
BASE_DIR="/opt/${DIR_NAME}"
CONFIG_NAME="web-${DIR_NAME}"
UNIT_NAME="web-${DIR_NAME}-docker"
CONTAINER_NAME="web-${DIR_NAME}"
# --- Handle --remove ---
if [[ "$ARG_REMOVE" == "1" ]]; then
require_root
do_remove "$SERVICE_NAME" "$CONFIG_NAME" "$UNIT_NAME" "$BASE_DIR"
exit 0
fi
# --- Validate --domain (required for deploy) ---
if [[ -z "$ARG_DOMAIN" ]] && ! [[ -f "${BASE_DIR}/.env" ]]; then
fatal "--domain is required for deploy. Example: $0 --domain web.an2.io --dir ${DIR_NAME}"
fi
# --- Domain resolution ---
if [[ -n "$ARG_DOMAIN" ]]; then
DOMAIN="$ARG_DOMAIN"
elif [[ -f "${BASE_DIR}/.env" ]] && grep -q '^DOMAIN=' "${BASE_DIR}/.env"; then
DOMAIN="$(grep '^DOMAIN=' "${BASE_DIR}/.env" | cut -d= -f2)"
else
fatal "--domain is required for deploy."
fi
# --- Detect current state for banner ---
traefik_status="Not found — will deploy"
if docker ps --format '{{.Names}}' 2>/dev/null | grep -q '^traefik$'; then
traefik_status="Running"
elif [[ -f "${TRAEFIK_DIR}/docker-compose.yml" ]]; then
traefik_status="Stopped — will start"
fi
install_mode="Fresh install"
if [[ -f "${BASE_DIR}/.env" ]]; then
install_mode="Re-run (preserving data)"
fi
# --- Print deployment plan ---
echo ""
echo -e "${CYAN}══════════════════════════════════════════════${NC}"
echo -e "${CYAN} Deploying Web: ${DIR_NAME} (Docker/nginx)${NC}"
echo -e "${CYAN}══════════════════════════════════════════════${NC}"
echo -e " Domain: ${DOMAIN}"
echo -e " Data: ${BASE_DIR}/html/"
echo -e " Container: ${CONTAINER_NAME}"
echo -e " Traefik: ${traefik_status}"
echo -e " Mode: ${install_mode}"
echo -e "${CYAN}══════════════════════════════════════════════${NC}"
echo ""
# --- Check if already deployed and running ---
if [[ -f "${BASE_DIR}/docker-compose.yml" ]] && \
docker ps --format '{{.Names}}' 2>/dev/null | grep -q "^${CONTAINER_NAME}$"; then
echo -e "${GREEN} ${CONTAINER_NAME} is already running — nothing to do.${NC}"
echo ""
echo -e " Document root: ${BASE_DIR}/html/"
echo -e " Backup: ${SCRIPT_DIR}/backup-web.sh --dir ${DIR_NAME}"
echo -e " Remove: $0 --remove --dir ${DIR_NAME} [--purge]"
echo ""
exit 0
fi
# --- Shared infrastructure ---
require_root
detect_os
install_prerequisites
ensure_docker_network
ensure_traefik
configure_firewall
# --- Backup restore check ---
if check_and_restore_backup "$DIR_NAME"; then
saved_domain="$DOMAIN"
# shellcheck source=/dev/null
source "${BASE_DIR}/.env"
DOMAIN="$saved_domain"
else
# --- Fresh install ---
if [[ -f "${BASE_DIR}/.env" ]]; then
saved_domain="$DOMAIN"
# shellcheck source=/dev/null
source "${BASE_DIR}/.env"
DOMAIN="$saved_domain"
fi
mkdir -p "${BASE_DIR}/html"
# Default placeholder page
if [[ ! -f "${BASE_DIR}/html/index.html" ]]; then
cat > "${BASE_DIR}/html/index.html" <<HTML
<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><title>${DOMAIN}</title></head>
<body><h1>${DOMAIN}</h1><p>Replace this file at ${BASE_DIR}/html/index.html</p></body>
</html>
HTML
fi
# --- Generate .env ---
cat > "${BASE_DIR}/.env" <<DOTENV
# Generated by deploy-docker-web.sh on $(date -Iseconds)
DOMAIN=${DOMAIN}
DIR_NAME=${DIR_NAME}
DOTENV
chmod 600 "${BASE_DIR}/.env"
ok ".env written."
# --- Generate docker-compose.yml ---
cat > "${BASE_DIR}/docker-compose.yml" <<COMPOSE
services:
web:
image: nginx:alpine
container_name: ${CONTAINER_NAME}
restart: unless-stopped
security_opt:
- no-new-privileges:true
mem_limit: 512M
read_only: true
tmpfs:
- /var/cache/nginx
- /var/run
- /tmp
volumes:
- ./html:/usr/share/nginx/html:ro
networks:
- traefik-public
logging:
driver: json-file
options:
max-size: "50m"
max-file: "5"
networks:
traefik-public:
external: true
COMPOSE
ok "docker-compose.yml written."
fi
# --- Always do these (fresh or restored) ---
write_traefik_dynamic_config "$CONFIG_NAME" "$DOMAIN" "http://${CONTAINER_NAME}:80"
install_systemd_unit "$UNIT_NAME" "$BASE_DIR"
(cd "$BASE_DIR" && docker compose up -d)
wait_for_healthy "$CONTAINER_NAME" "http://localhost:80/" 60
# --- Summary ---
echo ""
echo -e "${GREEN}══════════════════════════════════════════════${NC}"
echo -e "${GREEN} Web (${DIR_NAME}) deployed successfully${NC}"
echo -e "${GREEN}══════════════════════════════════════════════${NC}"
echo -e " URL: https://${DOMAIN}"
echo -e " Doc root: ${BASE_DIR}/html/"
echo -e " Traefik: ${TRAEFIK_DYNAMIC_DIR}/${CONFIG_NAME}.yml"
echo -e " Systemd: systemctl status ${UNIT_NAME}"
echo -e " Logs: docker logs -f ${CONTAINER_NAME}"
echo -e "${GREEN}══════════════════════════════════════════════${NC}"
echo ""

243
deploy-local-hermes.sh Executable file
View File

@@ -0,0 +1,243 @@
#!/usr/bin/env bash
# ==============================================================================
# Hermes Agent Local (Bare-Metal) Deployment Script
# ==============================================================================
# Installs Hermes Agent directly on the host via uv + Python 3.11.
# Runs as a gateway connecting to Telegram/Discord/Slack/WhatsApp.
# No web UI — does NOT use Traefik.
#
# Source: https://github.com/NousResearch/hermes-agent
#
# Usage: deploy-local-hermes.sh
# deploy-local-hermes.sh --remove [--purge] [--yes]
# ==============================================================================
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "${SCRIPT_DIR}/common.sh"
# --- Parse arguments ---
parse_args "$@"
# --- Service config ---
SERVICE_NAME="hermes"
BASE_DIR="/opt/${SERVICE_NAME}"
UNIT_NAME="${SERVICE_NAME}"
HERMES_REPO="${HERMES_REPO:-https://github.com/NousResearch/hermes-agent.git}"
# --- Handle --remove ---
if [[ "$ARG_REMOVE" == "1" ]]; then
require_root
echo ""
echo -e "${YELLOW}══════════════════════════════════════════════${NC}"
echo -e "${YELLOW} Removing ${SERVICE_NAME} (local)${NC}"
echo -e "${YELLOW}══════════════════════════════════════════════${NC}"
if [[ "$ARG_PURGE" == "1" ]]; then
echo -e " Mode: ${RED}Purge (delete all data)${NC}"
else
echo -e " Mode: Safe (keep data)"
fi
echo -e " Data: ${BASE_DIR}"
echo -e " Systemd: ${UNIT_NAME}.service"
echo -e "${YELLOW}══════════════════════════════════════════════${NC}"
echo ""
if [[ "$ARG_PURGE" == "1" ]] && [[ -d "$BASE_DIR" ]]; then
backup_script="${SCRIPT_DIR}/backup-${SERVICE_NAME}.sh"
[[ -f "$backup_script" ]] && warn "Back up first? ${backup_script}"
echo ""
fi
unit_file="/etc/systemd/system/${UNIT_NAME}.service"
if [[ -f "$unit_file" ]]; then
systemctl stop "${UNIT_NAME}.service" > /dev/null 2>&1 || true
systemctl disable "${UNIT_NAME}.service" > /dev/null 2>&1 || true
rm -f "$unit_file"
systemctl daemon-reload
ok "Removed systemd unit: ${UNIT_NAME}.service"
fi
if [[ "$ARG_PURGE" == "1" ]]; then
echo ""
warn "This will permanently delete: $BASE_DIR"
if [[ "$ARG_YES" != "1" ]]; then
read -rp "Are you sure? [y/N] " confirm
if [[ "${confirm,,}" != "y" ]]; then
info "Purge cancelled."
exit 0
fi
fi
rm -rf "$BASE_DIR"
rm -rf "$HOME/.hermes" 2>/dev/null
ok "Purged: $BASE_DIR"
fi
echo ""
echo -e "${GREEN}══════════════════════════════════════════════${NC}"
echo -e "${GREEN} ${SERVICE_NAME} removed${NC}"
echo -e "${GREEN}══════════════════════════════════════════════${NC}"
if [[ "$ARG_PURGE" != "1" ]]; then
echo -e " Data preserved at ${BASE_DIR}"
echo -e " To purge: $0 --remove --purge"
fi
echo -e " To redeploy: $0"
echo -e "${GREEN}══════════════════════════════════════════════${NC}"
echo ""
exit 0
fi
# --- Detect current state for banner ---
install_mode="Fresh install"
if [[ -f "${BASE_DIR}/.env" ]]; then
install_mode="Re-run (preserving data)"
fi
service_status="Not running"
if systemctl is-active --quiet "${UNIT_NAME}.service" 2>/dev/null; then
service_status="Running"
fi
# --- Print deployment plan ---
echo ""
echo -e "${CYAN}══════════════════════════════════════════════${NC}"
echo -e "${CYAN} Deploying Hermes Agent (Local/Gateway)${NC}"
echo -e "${CYAN}══════════════════════════════════════════════${NC}"
echo -e " Data: ${BASE_DIR}/data/"
echo -e " Traefik: Not used (no web UI)"
echo -e " Service: ${service_status}"
echo -e " Mode: ${install_mode}"
echo -e "${CYAN}══════════════════════════════════════════════${NC}"
echo ""
# --- Check if already deployed and running ---
if [[ -f "${BASE_DIR}/.env" ]] && \
systemctl is-active --quiet "${UNIT_NAME}.service" 2>/dev/null; then
echo -e "${GREEN} Hermes is already running — nothing to do.${NC}"
echo ""
echo -e " Health: hermes doctor"
echo -e " Logs: journalctl -u ${UNIT_NAME}.service -f"
echo -e " Backup: ${SCRIPT_DIR}/backup-hermes.sh"
echo -e " Remove: $0 --remove [--purge]"
echo ""
exit 0
fi
# --- Infrastructure ---
require_root
detect_os
# --- Install prerequisites ---
info "Installing prerequisites..."
case "$DISTRO_FAMILY" in
debian)
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq
apt-get install -y -qq curl git openssl ca-certificates \
python3 python3-venv python3-pip > /dev/null 2>&1
;;
fedora)
dnf install -y -q curl git openssl ca-certificates \
python3 python3-pip > /dev/null 2>&1
;;
esac
ok "System prerequisites installed."
# Install uv (Python package manager)
if ! command -v uv &>/dev/null; then
info "Installing uv..."
curl -LsSf https://astral.sh/uv/install.sh | sh > /dev/null 2>&1
export PATH="$HOME/.local/bin:$PATH"
fi
ok "uv $(uv --version 2>/dev/null || echo 'installed')."
# --- Backup restore check ---
if check_and_restore_backup "$SERVICE_NAME"; then
ok "Backup restored."
else
# --- Fresh install ---
if [[ -d "${BASE_DIR}/.git" ]]; then
info "Updating Hermes source..."
(cd "$BASE_DIR" && git pull)
else
info "Cloning Hermes from ${HERMES_REPO}..."
git clone --recurse-submodules "$HERMES_REPO" "$BASE_DIR"
fi
info "Creating Python venv and installing dependencies..."
(cd "$BASE_DIR" && uv venv venv --python 3.11 2>/dev/null || uv venv venv)
export VIRTUAL_ENV="${BASE_DIR}/venv"
(cd "$BASE_DIR" && uv pip install -e ".[all]" 2>/dev/null) || \
(cd "$BASE_DIR" && "${BASE_DIR}/venv/bin/pip" install -e ".[all]")
ok "Hermes installed."
# Symlink binary
mkdir -p /usr/local/bin
ln -sf "${BASE_DIR}/venv/bin/hermes" /usr/local/bin/hermes
# Data directory — redirect ~/.hermes here
mkdir -p "${BASE_DIR}/data"
rm -rf "$HOME/.hermes" 2>/dev/null
ln -sfn "${BASE_DIR}/data" "$HOME/.hermes"
# Create data subdirs if fresh
if [[ ! -d "${BASE_DIR}/data/sessions" ]]; then
mkdir -p "${BASE_DIR}/data"/{cron,sessions,logs,memories,skills,pairing,hooks,image_cache,audio_cache}
fi
if [[ ! -f "${BASE_DIR}/.env" ]]; then
cat > "${BASE_DIR}/.env" <<DOTENV
# Generated by deploy-local-hermes.sh on $(date -Iseconds)
DOTENV
chmod 600 "${BASE_DIR}/.env"
fi
fi
# --- Install systemd unit ---
cat > "/etc/systemd/system/${UNIT_NAME}.service" <<EOF
[Unit]
Description=Hermes Agent Gateway
After=network.target
[Service]
Type=simple
WorkingDirectory=${BASE_DIR}
Environment=VIRTUAL_ENV=${BASE_DIR}/venv
Environment=PATH=${BASE_DIR}/venv/bin:/usr/local/bin:/usr/bin:/bin
ExecStart=${BASE_DIR}/venv/bin/hermes gateway run
Restart=on-failure
RestartSec=10
TimeoutStartSec=60
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable "${UNIT_NAME}.service" > /dev/null 2>&1
systemctl restart "${UNIT_NAME}.service"
ok "Systemd unit installed and started: ${UNIT_NAME}.service"
# --- Health check ---
sleep 3
if hermes doctor > /dev/null 2>&1; then
ok "Hermes is healthy."
else
warn "hermes doctor failed — may need initial setup (API keys)."
fi
# --- Summary ---
echo ""
echo -e "${GREEN}══════════════════════════════════════════════${NC}"
echo -e "${GREEN} Hermes Agent deployed successfully (local)${NC}"
echo -e "${GREEN}══════════════════════════════════════════════${NC}"
echo -e " Data: ${BASE_DIR}/data/"
echo -e " Systemd: systemctl status ${UNIT_NAME}"
echo -e " Logs: journalctl -u ${UNIT_NAME}.service -f"
echo ""
echo -e " ${YELLOW}First run? Set up API keys:${NC}"
echo -e " hermes setup"
echo -e " Then: systemctl restart ${UNIT_NAME}"
echo -e "${GREEN}══════════════════════════════════════════════${NC}"
echo ""

446
deploy-local-paperclip.sh Executable file
View File

@@ -0,0 +1,446 @@
#!/usr/bin/env bash
# ==============================================================================
# Paperclip Local (Bare-Metal) Deployment Script
# ==============================================================================
# Installs Paperclip directly on the host with Node.js + pnpm.
# Uses embedded PostgreSQL — no external database needed.
# Does NOT auto-bootstrap Traefik (prints instructions if needed).
#
# Source: https://github.com/paperclipai/paperclip
#
# Usage: deploy-local-paperclip.sh
# deploy-local-paperclip.sh --domain clip.us.an2.io
# deploy-local-paperclip.sh --remove [--purge] [--yes]
# ==============================================================================
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "${SCRIPT_DIR}/common.sh"
# --- Parse arguments ---
parse_args "$@"
# --- Service config ---
SERVICE_NAME="paperclip"
BASE_DIR="/opt/${SERVICE_NAME}"
CONFIG_NAME="${SERVICE_NAME}"
UNIT_NAME="${SERVICE_NAME}"
# --- Handle --remove ---
if [[ "$ARG_REMOVE" == "1" ]]; then
require_root
echo ""
echo -e "${YELLOW}══════════════════════════════════════════════${NC}"
echo -e "${YELLOW} Removing ${SERVICE_NAME} (local)${NC}"
echo -e "${YELLOW}══════════════════════════════════════════════${NC}"
if [[ "$ARG_PURGE" == "1" ]]; then
echo -e " Mode: ${RED}Purge (delete all data)${NC}"
else
echo -e " Mode: Safe (keep data)"
fi
echo -e " Data: ${BASE_DIR}"
echo -e " Traefik: ${TRAEFIK_DYNAMIC_DIR}/${CONFIG_NAME}.yml"
echo -e " Systemd: ${UNIT_NAME}.service"
echo -e "${YELLOW}══════════════════════════════════════════════${NC}"
echo ""
if [[ "$ARG_PURGE" == "1" ]] && [[ -d "$BASE_DIR" ]]; then
backup_script="${SCRIPT_DIR}/backup-${SERVICE_NAME}.sh"
if [[ -f "$backup_script" ]]; then
warn "Back up first? ${backup_script}"
fi
echo ""
fi
# Stop and remove systemd unit
unit_file="/etc/systemd/system/${UNIT_NAME}.service"
if [[ -f "$unit_file" ]]; then
systemctl stop "${UNIT_NAME}.service" > /dev/null 2>&1 || true
systemctl disable "${UNIT_NAME}.service" > /dev/null 2>&1 || true
rm -f "$unit_file"
systemctl daemon-reload
ok "Removed systemd unit: ${UNIT_NAME}.service"
fi
# Remove traefik config if it exists (user may have set it up manually)
remove_traefik_dynamic_config "$SERVICE_NAME"
if [[ "$ARG_PURGE" == "1" ]]; then
echo ""
warn "This will permanently delete: $BASE_DIR"
if [[ "$ARG_YES" != "1" ]]; then
read -rp "Are you sure? [y/N] " confirm
if [[ "${confirm,,}" != "y" ]]; then
info "Purge cancelled."
exit 0
fi
fi
rm -rf "$BASE_DIR"
ok "Purged: $BASE_DIR"
fi
echo ""
echo -e "${GREEN}══════════════════════════════════════════════${NC}"
echo -e "${GREEN} ${SERVICE_NAME} removed${NC}"
echo -e "${GREEN}══════════════════════════════════════════════${NC}"
if [[ "$ARG_PURGE" != "1" ]]; then
echo -e " Data preserved at ${BASE_DIR}"
echo -e " To purge: $0 --remove --purge"
fi
echo -e " To redeploy: $0"
echo -e "${GREEN}══════════════════════════════════════════════${NC}"
echo ""
exit 0
fi
# --- Domain resolution ---
if [[ -n "$ARG_DOMAIN" ]]; then
DOMAIN="$ARG_DOMAIN"
elif [[ -f "${BASE_DIR}/.env" ]] && grep -q '^DOMAIN=' "${BASE_DIR}/.env"; then
DOMAIN="$(grep '^DOMAIN=' "${BASE_DIR}/.env" | cut -d= -f2)"
else
DOMAIN="paperclip.an2.io"
fi
# --- Detect current state for banner ---
traefik_status="Not found — will deploy"
if docker ps --format '{{.Names}}' 2>/dev/null | grep -q '^traefik$'; then
traefik_status="Running"
elif [[ -f "${TRAEFIK_DIR}/docker-compose.yml" ]]; then
traefik_status="Stopped — will start"
fi
install_mode="Fresh install"
if [[ -f "${BASE_DIR}/.env" ]]; then
install_mode="Re-run (preserving data)"
fi
service_status="Not running"
if systemctl is-active --quiet "${UNIT_NAME}.service" 2>/dev/null; then
service_status="Running"
fi
# --- Print deployment plan ---
echo ""
echo -e "${CYAN}══════════════════════════════════════════════${NC}"
echo -e "${CYAN} Deploying Paperclip (Local)${NC}"
echo -e "${CYAN}══════════════════════════════════════════════${NC}"
echo -e " Domain: ${DOMAIN}"
echo -e " Data: ${BASE_DIR}"
echo -e " Service: ${service_status}"
echo -e " Traefik: ${traefik_status}"
echo -e " Mode: ${install_mode}"
echo -e "${CYAN}══════════════════════════════════════════════${NC}"
echo ""
# --- Check if already deployed and running ---
if [[ -f "${BASE_DIR}/.env" ]] && \
systemctl is-active --quiet "${UNIT_NAME}.service" 2>/dev/null; then
echo -e "${GREEN} Paperclip is already running — nothing to do.${NC}"
echo ""
echo -e " Health: curl http://localhost:3100/api/health"
echo -e " Logs: journalctl -u ${UNIT_NAME}.service -f"
echo -e " Backup: ${SCRIPT_DIR}/backup-paperclip.sh"
echo -e " Remove: $0 --remove [--purge]"
echo ""
exit 0
fi
# --- Shared infrastructure ---
require_root
detect_os
install_prerequisites
ensure_docker_network
ensure_traefik
configure_firewall
# --- Install Node.js + pnpm ---
install_nodejs() {
if command -v node &>/dev/null; then
local node_major
node_major="$(node --version | sed 's/^v//' | cut -d. -f1)"
if (( node_major >= 20 )); then
ok "Node.js $(node --version) already installed."
return 0
fi
warn "Node.js $(node --version) is too old (need 20+). Upgrading..."
fi
info "Installing Node.js 20..."
case "$DISTRO_FAMILY" in
debian)
curl -fsSL https://deb.nodesource.com/setup_20.x | bash - > /dev/null 2>&1
apt-get install -y -qq nodejs > /dev/null 2>&1
;;
fedora)
curl -fsSL https://rpm.nodesource.com/setup_20.x | bash - > /dev/null 2>&1
dnf install -y -q nodejs > /dev/null 2>&1
;;
esac
ok "Node.js $(node --version) installed."
}
install_pnpm() {
if command -v pnpm &>/dev/null; then
local pnpm_major
pnpm_major="$(pnpm --version | cut -d. -f1)"
if (( pnpm_major >= 9 )); then
ok "pnpm $(pnpm --version) already installed."
return 0
fi
fi
info "Installing pnpm 9+..."
corepack enable 2>/dev/null || npm install -g corepack
corepack prepare pnpm@latest --activate 2>/dev/null || npm install -g pnpm@latest
ok "pnpm $(pnpm --version) installed."
}
# Install base prerequisites (curl, openssl, etc.)
case "$DISTRO_FAMILY" in
debian)
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq
apt-get install -y -qq curl openssl jq git ca-certificates > /dev/null 2>&1
;;
fedora)
dnf install -y -q curl openssl jq git ca-certificates > /dev/null 2>&1
;;
esac
install_nodejs
install_pnpm
# --- Ensure system-wide node exists ---
# nvm installs node under /root/ which other users cannot access.
# The paperclip service user needs a system-wide node binary.
SYS_NODE=""
for _p in /usr/bin/node /usr/local/bin/node; do
[[ -x "$_p" ]] && SYS_NODE="$_p" && break
done
if [[ -z "$SYS_NODE" ]]; then
warn "Node.js only available via nvm (under /root/) — installing system-wide..."
case "$DISTRO_FAMILY" in
debian)
curl -fsSL https://deb.nodesource.com/setup_22.x | bash - > /dev/null 2>&1
apt-get install -y -qq nodejs > /dev/null 2>&1
;;
fedora)
curl -fsSL https://rpm.nodesource.com/setup_22.x | bash - > /dev/null 2>&1
dnf install -y -q nodejs > /dev/null 2>&1
;;
esac
for _p in /usr/bin/node /usr/local/bin/node; do
[[ -x "$_p" ]] && SYS_NODE="$_p" && break
done
[[ -x "$SYS_NODE" ]] || fatal "Failed to install system-wide Node.js"
ok "System-wide Node.js installed: ${SYS_NODE}"
fi
# --- Create service user (embedded Postgres refuses to run as root) ---
if ! id -u paperclip &>/dev/null; then
useradd -r -s /usr/sbin/nologin -d "$BASE_DIR" paperclip
ok "Created system user: paperclip"
fi
# --- Backup restore check ---
if check_and_restore_backup "$SERVICE_NAME"; then
saved_domain="$DOMAIN"
# shellcheck source=/dev/null
source "${BASE_DIR}/.env"
DOMAIN="$saved_domain"
ok "Backup restored. Skipping fresh install."
else
# --- Fresh install ---
if [[ -f "${BASE_DIR}/.env" ]]; then
# Re-run: update dependencies only
saved_domain="$DOMAIN"
# shellcheck source=/dev/null
source "${BASE_DIR}/.env"
DOMAIN="$saved_domain"
info "Existing installation found. Updating dependencies..."
(cd "$BASE_DIR" && pnpm install --frozen-lockfile 2>/dev/null || pnpm install)
info "Rebuilding project..."
(cd "$BASE_DIR" && pnpm build)
else
# Fresh clone
PAPERCLIP_REPO="${PAPERCLIP_REPO:-https://github.com/paperclipai/paperclip.git}"
info "Cloning Paperclip from ${PAPERCLIP_REPO}..."
if [[ -d "${BASE_DIR}/.git" ]]; then
(cd "$BASE_DIR" && git pull)
else
git clone "$PAPERCLIP_REPO" "$BASE_DIR"
fi
info "Installing dependencies..."
(cd "$BASE_DIR" && pnpm install)
info "Building project (this may take a minute)..."
(cd "$BASE_DIR" && pnpm build)
mkdir -p "${BASE_DIR}/data"
# --- Generate .env ---
# authenticated mode + HOST=0.0.0.0 so Traefik (in Docker) can reach the service
BETTER_AUTH_SECRET="$(generate_password)"
cat > "${BASE_DIR}/.env" <<DOTENV
# Generated by deploy-local-paperclip.sh on $(date -Iseconds)
DOMAIN=${DOMAIN}
PAPERCLIP_HOME=${BASE_DIR}/data
HOST=0.0.0.0
PAPERCLIP_DEPLOYMENT_MODE=authenticated
BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET}
DOTENV
chmod 600 "${BASE_DIR}/.env"
ok ".env written."
fi
fi
# --- Set ownership (must happen before onboarding and service start) ---
chown -R paperclip:paperclip "$BASE_DIR"
ok "Ownership set to paperclip:paperclip"
# --- Run onboarding as paperclip user (creates config for non-interactive use) ---
# Use system node directly — pnpm may be under /root/.nvm/ and inaccessible
# "pnpm paperclipai X" is equivalent to: node cli/node_modules/tsx/dist/cli.mjs cli/src/index.ts X
PAPERCLIP_CMD="${SYS_NODE} cli/node_modules/tsx/dist/cli.mjs cli/src/index.ts"
if [[ ! -f "${BASE_DIR}/data/instances/default/config.json" ]]; then
info "Running initial onboarding..."
# onboard --yes creates config then starts the server (blocking).
# We only need the config — systemd manages the service.
# Run in background, wait for config, then kill it.
sudo -u paperclip bash -c "cd '${BASE_DIR}' && PAPERCLIP_HOME='${BASE_DIR}/data' ${PAPERCLIP_CMD} onboard --yes" &
_onboard_pid=$!
_waited=0
while (( _waited < 120 )); do
if [[ -f "${BASE_DIR}/data/instances/default/config.json" ]]; then
ok "Onboarding complete — config created."
break
fi
sleep 2
(( _waited += 2 ))
done
# Kill onboard + any server/postgres it spawned
kill "$_onboard_pid" 2>/dev/null; wait "$_onboard_pid" 2>/dev/null || true
pkill -u paperclip -f "cli/src/index" 2>/dev/null || true
sleep 2
# Switch config to authenticated mode (onboard defaults to local_trusted)
# Key is nested: server.deploymentMode (not top-level)
PAPERCLIP_CONFIG="${BASE_DIR}/data/instances/default/config.json"
if [[ -f "$PAPERCLIP_CONFIG" ]] && command -v jq &>/dev/null; then
jq '.server.deploymentMode = "authenticated"' "$PAPERCLIP_CONFIG" > "${PAPERCLIP_CONFIG}.tmp" && \
mv "${PAPERCLIP_CONFIG}.tmp" "$PAPERCLIP_CONFIG"
chown paperclip:paperclip "$PAPERCLIP_CONFIG"
ok "Config set to authenticated mode."
fi
fi
# --- Register domain as allowed hostname ---
info "Registering allowed hostname: ${DOMAIN}"
sudo -u paperclip bash -c "cd '${BASE_DIR}' && PAPERCLIP_HOME='${BASE_DIR}/data' ${PAPERCLIP_CMD} allowed-hostname '${DOMAIN}'" || \
warn "Could not register hostname — you may need to run: pnpm paperclipai allowed-hostname ${DOMAIN}"
# --- Create paperclipai symlink ---
cat > /usr/local/bin/paperclipai <<WRAPPER
#!/usr/bin/env bash
cd "${BASE_DIR}" && PAPERCLIP_HOME="${BASE_DIR}/data" exec ${PAPERCLIP_CMD} "\$@"
WRAPPER
chmod +x /usr/local/bin/paperclipai
ok "Command available: paperclipai"
# --- Bootstrap admin (authenticated mode requires it) ---
info "Bootstrapping admin invite..."
INVITE_OUTPUT="$(sudo -u paperclip bash -c "cd '${BASE_DIR}' && PAPERCLIP_HOME='${BASE_DIR}/data' ${PAPERCLIP_CMD} auth bootstrap-ceo --data-dir '${BASE_DIR}/data' --base-url 'https://${DOMAIN}'" 2>&1)" || true
# --- Install systemd unit (process, not Docker) ---
cat > "/etc/systemd/system/${UNIT_NAME}.service" <<EOF
[Unit]
Description=Paperclip Service
After=network.target
[Service]
Type=simple
User=paperclip
Group=paperclip
WorkingDirectory=${BASE_DIR}
EnvironmentFile=${BASE_DIR}/.env
ExecStart=${PAPERCLIP_CMD} run
Restart=on-failure
RestartSec=10
TimeoutStartSec=120
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable "${UNIT_NAME}.service" > /dev/null 2>&1
systemctl restart "${UNIT_NAME}.service"
ok "Systemd unit installed and started: ${UNIT_NAME}.service"
# --- Ensure Traefik can reach host services ---
# Traefik runs in Docker; it needs host.docker.internal to reach bare-metal services.
# New deployments get this via ensure_traefik. Patch existing ones if missing.
if ! grep -q "host.docker.internal" "${TRAEFIK_DIR}/docker-compose.yml" 2>/dev/null; then
info "Updating Traefik with host.docker.internal mapping..."
cat > "${TRAEFIK_DIR}/docker-compose.override.yml" <<'OVERRIDE'
services:
traefik:
extra_hosts:
- "host.docker.internal:host-gateway"
OVERRIDE
(cd "$TRAEFIK_DIR" && docker compose up -d)
ok "Traefik updated."
fi
# --- Write Traefik dynamic config ---
write_traefik_dynamic_config "$CONFIG_NAME" "$DOMAIN" "http://host.docker.internal:3100"
# --- Wait for health ---
info "Waiting for Paperclip to become healthy (up to 90s)..."
healthy=0
elapsed=0
while (( elapsed < 90 )); do
if curl -sf http://localhost:3100/api/health > /dev/null 2>&1; then
ok "Paperclip is healthy."
healthy=1
break
fi
sleep 5
(( elapsed += 5 ))
done
# --- Summary ---
echo ""
if (( healthy )); then
echo -e "${GREEN}══════════════════════════════════════════════${NC}"
echo -e "${GREEN} Paperclip deployed successfully (local)${NC}"
echo -e "${GREEN}══════════════════════════════════════════════${NC}"
else
echo -e "${YELLOW}══════════════════════════════════════════════${NC}"
echo -e "${YELLOW} Paperclip installed but not yet healthy${NC}"
echo -e "${YELLOW}══════════════════════════════════════════════${NC}"
fi
echo -e " URL: https://${DOMAIN}"
echo -e " Local: http://localhost:3100"
echo -e " Data: ${BASE_DIR}/data/"
echo -e " Traefik: ${TRAEFIK_DYNAMIC_DIR}/${CONFIG_NAME}.yml"
echo -e " Systemd: systemctl status ${UNIT_NAME}"
echo -e " Logs: journalctl -u ${UNIT_NAME}.service -f"
# Show invite URL if bootstrap-ceo produced one
INVITE_URL="$(echo "$INVITE_OUTPUT" | grep -oE 'https?://[^ ]+' | head -1)" || true
if [[ -n "$INVITE_URL" ]]; then
echo ""
echo -e " ${CYAN}Admin invite:${NC} ${INVITE_URL}"
fi
if (( healthy )); then
echo -e "${GREEN}══════════════════════════════════════════════${NC}"
else
echo -e "${YELLOW}══════════════════════════════════════════════${NC}"
fi
echo ""

View File

@@ -1,42 +0,0 @@
# OpenClaw Gitea bootstrap
=======================
Created user
- username: openclaw
- fullname: OpenClaw
- email: openclaw@git.an2.io
SSH key
- private key: /home/user/.ssh/id_gitea_openclaw
- public key: /home/user/.ssh/id_gitea_openclaw.pub
- Gitea key title: openclaw@xeon
SSH config
- host alias: gitea
- hostname: git.an2.io
- port: 222
- user: git
- identity: /home/user/.ssh/id_gitea_openclaw
Verified
- ssh -T gitea
- result: authenticated successfully as openclaw
Access token
- created for user: openclaw
- token name: openclaw-bootstrap
- scopes: all
- token value was generated during bootstrap and should be stored securely if you want API repo management later
Recommended usage
- clone/pull/push via SSH
- repo create/delete via Gitea API token
Example remote forms
- ssh://git@git.an2.io:222/openclaw/repo.git
- gitea:openclaw/repo.git (via SSH config alias if your git tooling uses it explicitly)
Suggested next steps
1. Store the generated Gitea token somewhere safe for future API use
2. Create a first test repo under openclaw
3. Verify clone, commit, and push with the SSH identity

View File

@@ -1,98 +0,0 @@
# OpenClaw + Mattermost notes
===========================
Current working state
- OpenClaw gateway is reachable on port 18789
- Mattermost channel connects as @claw
- Native slash commands are working
- Slash commands were registered successfully for 1 team
- Inline buttons are enabled in channel config
- Button callback handling is working
What was wrong
1. A manual Mattermost plugin install was present even though Mattermost is bundled in current OpenClaw.
2. The bot account was not a member of any Mattermost team.
3. Because the bot had zero teams, OpenClaw had nowhere to register native slash commands.
Observed failure mode
- Logs showed:
- mattermost: registered slash command callback at /api/channels/mattermost/command
- mattermost: connected as @claw
- mattermost: native slash commands enabled but no commands could be registered; keeping slash callbacks inactive
Actual root cause
- Mattermost API check showed the bot user belonged to zero teams.
- Native slash command registration is team-scoped, so no team membership means no commands can be created.
What was cleaned up
- Moved manual override plugin out of the active extensions path:
- /home/user/.openclaw/extensions/mattermost
- -> /home/user/.openclaw/extensions/mattermost.disabled-20260410-230312
- Removed stale plugin install metadata from /home/user/.openclaw/openclaw.json:
- plugins.installs.mattermost
- Removed stale disabled bundled plugin config warning:
- plugins.entries.huggingface
Mattermost config in use
- baseUrl: https://mm.an2.io
- commands.native: true
- commands.nativeSkills: true
- callbackPath: /api/channels/mattermost/command
- callbackUrl: http://159.69.76.190:18789/api/channels/mattermost/command
- interactions.allowedSourceIps: ["172.20.0.3", "172.20.0.0/16", "127.0.0.1", "::1"]
What fixed it
- Added the @claw bot to a Mattermost team
- After reconnect/restart, OpenClaw successfully registered slash commands
Successful log indicators
- mattermost: connected as @claw
- mattermost: registered command /oc_status
- mattermost: registered command /oc_model
- mattermost: registered command /oc_models
- mattermost: slash commands activated for account default (20 commands)
- mattermost: slash commands registered (20 commands across 1 teams, callback=http://159.69.76.190:18789/api/channels/mattermost/command)
Important reminder
- The bot must belong to at least one team for native slash command registration to work.
- The bot should also be added to the channels where you want it to interact.
- If native slash commands fail again, first check team membership before debugging callback URLs.
Useful checks
- curl -sS -H 'Authorization: Bearer <BOT_TOKEN>' https://mm.an2.io/api/v4/users/me
- curl -sS -H 'Authorization: Bearer <BOT_TOKEN>' https://mm.an2.io/api/v4/users/<BOT_USER_ID>/teams
- grep -Ei 'mattermost|slash|callback' /tmp/openclaw-gateway-run.log
If slash commands break again
1. Confirm the bot is still in a team
2. Confirm callbackUrl is reachable from the Mattermost server
3. Check for old leftover manually installed overrides in ~/.openclaw/extensions/
4. Restart gateway and re-read Mattermost startup logs
Button callback note
- Native slash commands now work.
- Button callback test initially failed with: mattermost interaction: rejected callback source remote=172.20.0.3
- Updated channels.mattermost.interactions.allowedSourceIps to include:
- 172.20.0.3
- 172.20.0.0/16
- 127.0.0.1
- ::1
- After the allowlist update, button callbacks worked end-to-end.
Elevated exec from Mattermost
- Added tools.elevated.enabled = true
- Added tools.elevated.allowFrom.mattermost = ["63rzn4hbijnrmjbomxxugntg9h"]
- This allows elevated exec only from the specified Mattermost source/account instead of opening it broadly.
- Gateway restart is required for this change to take full effect.
Provider routing note for elevated exec
- Elevated exec checks for this session path were evaluated under provider=webchat, not provider=mattermost.
- Because of that, tools.elevated.allowFrom needed both:
- mattermost: ["63rzn4hbijnrmjbomxxugntg9h"]
- webchat: ["63rzn4hbijnrmjbomxxugntg9h"]
- Gateway restart is required after changing these allowFrom rules.
Elevated exec verification
- After adding both provider allowlists and restarting the gateway, elevated exec from the Mattermost session path worked.
- Confirmed in-session test: sudo -n true -> SUDO_OK
- This verified that elevated exec was allowed for the effective provider path used by that session.

View File

@@ -1,46 +0,0 @@
# OpenClaw behind Traefik
=======================
What I changed
- Changed OpenClaw gateway bind in /home/user/.openclaw/openclaw.json from loopback to lan
- Added Traefik dynamic config at /opt/traefik/dynamic/openclaw.yml
- Target upstream is http://host.docker.internal:18789
- Added gateway.controlUi.allowedOrigins entry for https://oc.an2.io
Why
- Traefik runs in Docker.
- The Traefik container already has host.docker.internal mapped to the Docker host gateway.
- OpenClaw was only listening on 127.0.0.1:18789, which the container could not reach.
- Binding OpenClaw to lan makes it listen on the host network so Traefik can reach it through the host gateway.
- The Control UI loaded through Traefik uses browser origin https://oc.an2.io, so that exact origin must be listed in gateway.controlUi.allowedOrigins.
Traefik config summary
- Router name: openclaw
- Entry point: websecure
- TLS cert resolver: letsencrypt
- Host rule: oc.an2.io
- Service upstream: http://host.docker.internal:18789
- Dynamic file: /opt/traefik/dynamic/openclaw.yml
OpenClaw config summary
- File: /home/user/.openclaw/openclaw.json
- gateway.bind: lan
- gateway.controlUi.allowedOrigins includes:
- http://localhost:18789
- http://127.0.0.1:18789
- https://oc.an2.io
Notes
- https://localhost:18789 is not the correct public URL because OpenClaw on port 18789 speaks plain HTTP.
- TLS is terminated by Traefik on port 443 and then proxied to http://host.docker.internal:18789.
- If the browser shows "origin not allowed", check gateway.controlUi.allowedOrigins first.
Useful checks
- ss -ltnp | grep 18789
- curl http://localhost:18789/
- curl -k https://oc.an2.io/
- sudo docker logs --tail 100 traefik
If needed
- Restart OpenClaw gateway after config changes
- Traefik dynamic config should hot-reload automatically, no Traefik restart is usually needed