#!/usr/bin/env bash # ============================================================================= # Local AI Stack — Ubuntu 24.04 # Services: Ollama · Open WebUI · RAG · MCP · ChromaDB · SearXNG # Kiwix · Gitea · InvokeAI · Portainer # # Usage: # ./laptop_full_setup.sh — interactive setup (answer questions, leave overnight) # ./laptop_full_setup.sh --force — overwrite existing config files too # # Asks all questions upfront, then runs everything unattended. # GPU: Ollama auto-detects VRAM — works with any NVIDIA GPU # ============================================================================= set -euo pipefail # ── colours ─────────────────────────────────────────────────────────────────── RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m' CYAN='\033[0;36m'; BOLD='\033[1m'; NC='\033[0m' info() { echo -e "${CYAN}[..]${NC} $*"; } ok() { echo -e "${GREEN}[OK]${NC} $*"; } warn() { echo -e "${YELLOW}[!!]${NC} $*"; } die() { echo -e "${RED}[XX]${NC} $*" >&2; exit 1; } section() { echo -e "\n${BOLD}━━━ $* ━━━${NC}"; } # ── args ────────────────────────────────────────────────────────────────────── FORCE=false for arg in "$@"; do [[ "$arg" == "--force" ]] && FORCE=true; done # ── config ──────────────────────────────────────────────────────────────────── BASE="$HOME/docker/ai-stack" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" LOCAL_IP=$(ip route get 1.1.1.1 2>/dev/null | grep -oP 'src \K\S+' \ || hostname -I | awk '{print $1}') [[ -z "$LOCAL_IP" ]] && read -rp "Enter your LAN IP: " LOCAL_IP # Models (defaults, may be adjusted below based on VRAM) EMBED_MODEL="nomic-embed-text" CHAT_MODEL="qwen2.5:14b" CODE_MODEL="qwen2.5-coder:7b" FAST_MODEL="qwen2.5:7b" # ── detect GPU ──────────────────────────────────────────────────────────────── VRAM_GB=$(nvidia-smi --query-gpu=memory.total --format=csv,noheader,nounits 2>/dev/null \ | head -1 | awk '{printf "%d", $1/1024}' 2>/dev/null || echo "0") GPU_NAME=$(nvidia-smi --query-gpu=name --format=csv,noheader 2>/dev/null | head -1 || echo "None") if [[ "$VRAM_GB" -ge 14 ]]; then CHAT_MODEL="qwen2.5:14b"; CODE_MODEL="qwen2.5-coder:14b" GPU_TIER="16GB VRAM — 14B models" elif [[ "$VRAM_GB" -ge 8 ]]; then CHAT_MODEL="qwen2.5:14b"; CODE_MODEL="qwen2.5-coder:7b" GPU_TIER="8GB VRAM — 14B chat, 7B code" elif [[ "$VRAM_GB" -ge 4 ]]; then CHAT_MODEL="qwen2.5:7b"; CODE_MODEL="qwen2.5-coder:7b" GPU_TIER="6GB VRAM — 7B models" elif [[ "$VRAM_GB" -gt 0 ]]; then CHAT_MODEL="qwen2.5:7b"; CODE_MODEL="qwen2.5-coder:7b" GPU_TIER="${VRAM_GB}GB VRAM — 7B models" else CHAT_MODEL="qwen2.5:7b"; CODE_MODEL="qwen2.5-coder:7b" GPU_TIER="CPU only — 7B models (slow)" fi # ── new vs update ───────────────────────────────────────────────────────────── IS_UPDATE=false [[ -f "$BASE/docker-compose.yml" ]] && IS_UPDATE=true # ============================================================================= # QUESTIONS UPFRONT — answer everything, then walk away # ============================================================================= section "Local AI Stack — Setup Wizard" echo "" echo -e " ${BOLD}Answer the questions below, then leave it overnight.${NC}" echo -e " Everything will install and download while you sleep." echo "" info "Machine : $(hostname)" info "LAN IP : $LOCAL_IP" info "GPU : ${GPU_NAME} (${VRAM_GB}GB VRAM)" $IS_UPDATE && warn "Existing install found. Config files kept unless --force is passed." # ── Q1: Components ──────────────────────────────────────────────────────────── echo "" echo -e " ${BOLD}[1/5] What do you want to install?${NC}" echo "" echo " 1) Full stack — AI stack + Kiwix offline library" echo " 2) AI stack — Ollama · WebUI · RAG · MCP · ChromaDB · SearXNG · Gitea · InvokeAI · Portainer" echo " 3) Kiwix only — Offline Wikipedia / docs (runs standalone)" echo "" read -rp " Choice [1]: " MENU_CHOICE MENU_CHOICE="${MENU_CHOICE:-1}" case "$MENU_CHOICE" in 1) INSTALL_AI=true; INSTALL_KIWIX=true ;; 2) INSTALL_AI=true; INSTALL_KIWIX=false ;; 3) INSTALL_AI=false; INSTALL_KIWIX=true ;; *) die "Invalid choice '$MENU_CHOICE' — run again and choose 1, 2, or 3" ;; esac # ── Q2: Storage for Ollama models ───────────────────────────────────────────── OLLAMA_STORAGE="volume" # "volume" = Docker named volume, else a host path OLLAMA_HOST_PATH="" if $INSTALL_AI; then echo "" echo -e " ${BOLD}[2/5] Where should Ollama models be stored?${NC}" echo " (Models are large — 5-50GB each. A fast SSD or large HDD is ideal.)" echo "" echo " Available mount points with >20GB free:" df -h --output=target,avail,fstype 2>/dev/null \ | awk 'NR>1 && $2~/[0-9]/ { val=$2; unit=substr(val,length(val)); num=substr(val,1,length(val)-1)+0; if ((unit=="G" && num>=20) || unit=="T") print " " $0 }' | head -10 echo "" echo " 1) Docker volume (default — stored in /var/lib/docker/volumes/)" echo " 2) Custom path (e.g. /mnt/ssd/ollama or /data/ollama)" echo "" read -rp " Choice [1]: " STORAGE_CHOICE STORAGE_CHOICE="${STORAGE_CHOICE:-1}" if [[ "$STORAGE_CHOICE" == "2" ]]; then read -rp " Enter full path for Ollama models: " OLLAMA_HOST_PATH OLLAMA_HOST_PATH="${OLLAMA_HOST_PATH%/}" # strip trailing slash [[ -z "$OLLAMA_HOST_PATH" ]] && die "No path entered." OLLAMA_STORAGE="bind" fi fi # ── Q3: Storage for Kiwix ZIMs ──────────────────────────────────────────────── KIWIX_DIR="$BASE/kiwix" # default if $INSTALL_KIWIX; then echo "" echo -e " ${BOLD}[3/5] Where should Kiwix ZIM files be stored?${NC}" echo " (ZIMs are large — Wikipedia alone is ~46GB. Total collection ~130GB.)" echo "" echo " Available mount points with >50GB free:" df -h --output=target,avail,fstype 2>/dev/null \ | awk 'NR>1 && $2~/[0-9]/ { val=$2; unit=substr(val,length(val)); num=substr(val,1,length(val)-1)+0; if ((unit=="G" && num>=50) || unit=="T") print " " $0 }' | head -10 echo "" echo " Default: $KIWIX_DIR" read -rp " Press Enter to use default, or type a different path: " KIWIX_INPUT if [[ -n "$KIWIX_INPUT" ]]; then KIWIX_DIR="${KIWIX_INPUT%/}" fi # ── Q4: Download ZIMs now? ───────────────────────────────────────────────── echo "" echo -e " ${BOLD}[4/5] Download ZIM files? (offline Wikipedia, Stack Overflow, etc.)${NC}" echo " Downloads run in the background — safe to start now and leave overnight." echo " Total: ~130GB (Wikipedia 46GB, Project Gutenberg 60GB, others smaller)" echo "" echo " 1) Yes — download all ZIMs overnight (~130GB)" echo " 2) Select — choose which ZIMs to download" echo " 3) No — skip for now (run ./kiwix_download.sh later)" echo "" read -rp " Choice [3]: " ZIM_CHOICE ZIM_CHOICE="${ZIM_CHOICE:-3}" else ZIM_CHOICE="3" fi # ── Q4b: Firewall (LAN subnet) ──────────────────────────────────────────────── LAN_SUBNET="192.168.1.0/24" if command -v ufw &>/dev/null && { [[ ! -f "$BASE/.ufw-done" ]] || $FORCE; }; then echo "" # Auto-detect likely subnet from current IP AUTO_SUBNET=$(echo "$LOCAL_IP" | awk -F. '{print $1"."$2"."$3".0/24"}') echo -e " ${BOLD}[4b] Firewall — allow LAN access to services${NC}" read -rp " LAN subnet [${AUTO_SUBNET}]: " LAN_INPUT LAN_SUBNET="${LAN_INPUT:-$AUTO_SUBNET}" [[ "$LAN_SUBNET" =~ /[0-9]+$ ]] || LAN_SUBNET="${LAN_SUBNET}/24" fi # ── Q5: Pull Ollama models now? ─────────────────────────────────────────────── PULL_MODELS=false PULL_DEEPSEEK=false if $INSTALL_AI; then echo "" echo -e " ${BOLD}[5/5] Download AI models now?${NC}" echo " GPU detected: ${GPU_NAME} (${VRAM_GB}GB VRAM) → recommended tier: ${GPU_TIER}" echo "" echo " Models that will be downloaded:" echo " nomic-embed-text (~300MB) — RAG embeddings (required)" echo " ${FAST_MODEL} (~5GB) — fast chat" echo " ${CHAT_MODEL} (~9GB) — smart chat" echo " ${CODE_MODEL} (~5GB) — code assistant" echo "" read -rp " Download models now? (~20GB total) [Y/n]: " DO_PULL if [[ "${DO_PULL,,}" != "n" ]]; then PULL_MODELS=true read -rp " Also download DeepSeek-R1:14b for reasoning? (~9GB extra) [y/N]: " DO_DS [[ "${DO_DS,,}" == "y" ]] && PULL_DEEPSEEK=true fi fi # ── Summary ─────────────────────────────────────────────────────────────────── echo "" echo -e "${BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" echo -e "${BOLD} Setup plan — starting now:${NC}" echo -e "${BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" echo "" $INSTALL_AI && echo " ✓ AI stack (Ollama · WebUI · RAG · MCP · Gitea · InvokeAI · Portainer)" $INSTALL_KIWIX && echo " ✓ Kiwix offline library" if $INSTALL_AI; then if [[ "$OLLAMA_STORAGE" == "bind" ]]; then echo " ✓ Ollama models → $OLLAMA_HOST_PATH" else echo " ✓ Ollama models → Docker volume (default)" fi fi $INSTALL_KIWIX && echo " ✓ Kiwix ZIMs → $KIWIX_DIR" $PULL_MODELS && echo " ✓ Pull models : embed + ${FAST_MODEL} + ${CHAT_MODEL} + ${CODE_MODEL}${PULL_DEEPSEEK:+ + deepseek-r1:14b}" [[ "$ZIM_CHOICE" == "1" ]] && echo " ✓ Download all ZIMs in background (~130GB)" [[ "$ZIM_CHOICE" == "2" ]] && echo " ✓ Select ZIMs to download (prompted after stack starts)" echo "" read -rp " Proceed? [Y/n]: " CONFIRM [[ "${CONFIRM,,}" == "n" ]] && echo "Aborted." && exit 0 echo "" # ── helper: write only if missing (or --force) ──────────────────────────────── # Usage: write_if_new /path/to/file << 'EOF' ... EOF write_if_new() { local dest="$1" local content; content=$(cat) if [[ ! -f "$dest" ]] || $FORCE; then printf '%s\n' "$content" > "$dest" ok "Wrote $(basename "$dest")" else info "Kept $(basename "$dest") (--force to overwrite)" fi } # ============================================================================= section "Prerequisites" # ============================================================================= if ! $IS_UPDATE; then # Docker if ! command -v docker &>/dev/null; then info "Installing Docker..." curl -fsSL https://get.docker.com | sh sudo usermod -aG docker "$USER" ok "Docker installed — log out/in or run: newgrp docker" else ok "Docker: $(docker --version)" fi # NVIDIA Container Toolkit if command -v nvidia-smi &>/dev/null && \ ! dpkg -l 2>/dev/null | grep -q nvidia-container-toolkit; then info "Installing NVIDIA Container Toolkit..." curl -fsSL https://nvidia.github.io/libnvidia-container/gpgkey \ | sudo gpg --dearmor --yes \ -o /usr/share/keyrings/nvidia-container-toolkit-keyring.gpg curl -fsSL https://nvidia.github.io/libnvidia-container/stable/deb/nvidia-container-toolkit.list \ | sed 's#deb https://#deb [signed-by=/usr/share/keyrings/nvidia-container-toolkit-keyring.gpg] https://#g' \ | sudo tee /etc/apt/sources.list.d/nvidia-container-toolkit.list sudo apt-get update -qq sudo apt-get install -y nvidia-container-toolkit sudo nvidia-ctk runtime configure --runtime=docker sudo systemctl restart docker ok "NVIDIA Container Toolkit installed" elif command -v nvidia-smi &>/dev/null; then ok "NVIDIA Container Toolkit already present" else warn "No NVIDIA GPU detected — CPU-only (Ollama will use CPU)" fi # ripgrep (used by MCP search_code tool) if ! command -v rg &>/dev/null; then sudo apt-get install -y ripgrep ok "ripgrep installed" fi else ok "Skipping prereq checks (update mode)" fi # ============================================================================= section "Directories" # ============================================================================= for d in papers repos workspace index searxng invokeai-data invokeai-outputs \ gitea portainer-data logs; do mkdir -p "$BASE/$d" done # Kiwix ZIM dir — may be on a different drive mkdir -p "$KIWIX_DIR" # Ollama bind-mount dir (if using custom path) [[ "$OLLAMA_STORAGE" == "bind" ]] && mkdir -p "$OLLAMA_HOST_PATH" ok "Directories ready" # ============================================================================= if $INSTALL_AI; then section "Server Files" # ============================================================================= # Copy Python servers from repo (always update — they are version-controlled) cp "$SCRIPT_DIR/server.py" "$BASE/server.py" && ok "server.py" cp "$SCRIPT_DIR/mcp_server.py" "$BASE/mcp_server.py" && ok "mcp_server.py" # RAG dependencies cat > "$BASE/requirements.txt" << 'REQ' fastapi uvicorn[standard] httpx pydantic chromadb pypdf watchdog python-multipart REQ ok "requirements.txt" # MCP server dependencies cat > "$BASE/mcp_requirements.txt" << 'REQ' mcp[cli] fastapi uvicorn[standard] httpx REQ ok "mcp_requirements.txt" fi # INSTALL_AI # ============================================================================= if $INSTALL_AI; then section "SearXNG Config" # ============================================================================= write_if_new "$BASE/searxng/settings.yml" << SEARXNG use_default_settings: true general: instance_name: "Local Search" server: secret_key: "$(openssl rand -hex 32)" limiter: false search: safe_search: 0 default_lang: "en" formats: [html, json] SEARXNG fi # INSTALL_AI # ============================================================================= section ".env File" # ============================================================================= # Never overwrite — this is where users store their tokens if [[ ! -f "$BASE/.env" ]]; then cat > "$BASE/.env" << ENV # Local AI Stack — API Tokens # Edit this file, then restart: bash $BASE/start.sh # Gitea — generate at http://$LOCAL_IP:3001/user/settings/applications GITEA_TOKEN=your-gitea-token-here # GitHub — optional, for GitHub API access via MCP GITHUB_TOKEN=your-github-token-here ENV ok "Created .env — add your tokens before using MCP Gitea/GitHub tools" else info "Kept .env (never overwritten)" fi # ============================================================================= section "Docker Compose" # ============================================================================= # Build ollama volume line based on storage choice if [[ "$OLLAMA_STORAGE" == "bind" ]]; then OLLAMA_VOLUME_LINE=" - ${OLLAMA_HOST_PATH}:/root/.ollama" OLLAMA_VOLUMES_DECL="" else OLLAMA_VOLUME_LINE=" - ollama-models:/root/.ollama" OLLAMA_VOLUMES_DECL=" ollama-models:" fi # Always written — it's the stack definition and safe to update cat > "$BASE/docker-compose.yml" << COMPOSE # Local AI Stack — generated $(date '+%Y-%m-%d') # Edit \$BASE/.env to add API tokens. # GPU: OLLAMA_NUM_GPU=999 means "use all available VRAM" — auto-adapts to any GPU. services: # ── Ollama — LLM inference ────────────────────────────────────────────────── ollama: image: ollama/ollama:latest container_name: ollama restart: unless-stopped ports: - "0.0.0.0:11434:11434" volumes: ${OLLAMA_VOLUME_LINE} environment: - OLLAMA_NUM_GPU=999 # use all available VRAM (auto-detects GPU size) - OLLAMA_NUM_CTX=8192 # lower to 4096 if you hit OOM - OLLAMA_KEEP_ALIVE=24h - OLLAMA_MAX_LOADED_MODELS=1 deploy: resources: reservations: devices: - driver: nvidia count: all capabilities: [gpu] healthcheck: test: ["CMD", "ollama", "list"] interval: 30s timeout: 10s retries: 5 # ── Open WebUI — Chat interface ───────────────────────────────────────────── open-webui: image: ghcr.io/open-webui/open-webui:main container_name: open-webui restart: unless-stopped ports: - "0.0.0.0:3000:8080" volumes: - open-webui-data:/app/backend/data environment: - OLLAMA_BASE_URL=http://ollama:11434 - OPENAI_API_BASE_URL=http://rag-server:8001/v1 - OPENAI_API_KEY=local-rag-key - ENABLE_OPENAI_API=true - ENABLE_RAG_WEB_SEARCH=true - RAG_WEB_SEARCH_ENGINE=searxng - SEARXNG_QUERY_URL=http://searxng:8080/search?q=&format=json - WEBUI_AUTH=false depends_on: ollama: condition: service_healthy # ── ChromaDB — Vector store ───────────────────────────────────────────────── chromadb: image: chromadb/chroma:latest container_name: chromadb restart: unless-stopped ports: - "0.0.0.0:8000:8000" volumes: - $BASE/index:/chroma/chroma environment: - IS_PERSISTENT=TRUE - ANONYMIZED_TELEMETRY=FALSE # ── RAG Server — code-aware retrieval ────────────────────────────────────── rag-server: image: python:3.11-slim container_name: rag-server restart: unless-stopped ports: - "0.0.0.0:8001:8001" volumes: - $BASE/papers:/papers - $BASE/repos:/repos - $BASE/index:/index - $BASE/server.py:/app/server.py - $BASE/requirements.txt:/app/requirements.txt working_dir: /app environment: - OLLAMA_URL=http://ollama:11434 - CHROMA_URL=http://chromadb:8000 - EMBED_MODEL=nomic-embed-text - CHAT_MODEL=qwen2.5:14b - PAPERS_DIR=/papers - REPOS_DIR=/repos command: > bash -c "apt-get update -qq && apt-get install -y --no-install-recommends git && pip install --no-cache-dir -r requirements.txt && uvicorn server:app --host 0.0.0.0 --port 8001" depends_on: chromadb: condition: service_started ollama: condition: service_healthy # ── MCP Server — Claude Code-equivalent tools ─────────────────────────────── # Connect via: http://$LOCAL_IP:8002/sse # Add to Claude Code: claude mcp add local http://$LOCAL_IP:8002/sse mcp-server: image: python:3.11-slim container_name: mcp-server restart: unless-stopped ports: - "0.0.0.0:8002:8002" volumes: - $BASE/workspace:/workspace - $BASE/repos:/repos - $BASE/mcp_server.py:/app/mcp_server.py - $BASE/mcp_requirements.txt:/app/mcp_requirements.txt working_dir: /app env_file: $BASE/.env environment: - WORKSPACE_DIR=/workspace - REPOS_DIR=/repos - GITEA_URL=http://gitea:3000 - RAG_URL=http://rag-server:8001 command: > bash -c "apt-get update -qq && apt-get install -y --no-install-recommends git ripgrep && pip install --no-cache-dir -r mcp_requirements.txt && python mcp_server.py" depends_on: - rag-server # ── SearXNG — Private web search ─────────────────────────────────────────── searxng: image: searxng/searxng:latest container_name: searxng restart: unless-stopped ports: - "0.0.0.0:8888:8080" volumes: - $BASE/searxng:/etc/searxng cap_drop: [ALL] cap_add: [CHOWN, SETGID, SETUID] # ── Kiwix — Offline Wikipedia/docs ───────────────────────────────────────── kiwix: image: ghcr.io/kiwix/kiwix-serve:latest container_name: kiwix restart: unless-stopped ports: - "0.0.0.0:8181:8080" volumes: - $KIWIX_DIR:/data command: "*.zim" # ── Gitea — Self-hosted Git ───────────────────────────────────────────────── gitea: image: gitea/gitea:latest container_name: gitea restart: unless-stopped ports: - "0.0.0.0:3001:3000" - "0.0.0.0:2222:22" volumes: - $BASE/gitea:/data - /etc/timezone:/etc/timezone:ro - /etc/localtime:/etc/localtime:ro environment: - USER_UID=1000 - USER_GID=1000 - GITEA__database__DB_TYPE=sqlite3 - GITEA__database__PATH=/data/gitea/gitea.db - GITEA__webhook__ALLOWED_HOST_LIST=rag-server,mcp-server # ── InvokeAI — Image generation ──────────────────────────────────────────── invokeai: image: ghcr.io/invoke-ai/invokeai:latest container_name: invokeai restart: unless-stopped ports: - "0.0.0.0:9090:9090" volumes: - invokeai-models:/invokeai/models - $BASE/invokeai-outputs:/invokeai/outputs - $BASE/invokeai-data:/invokeai/databases environment: - INVOKEAI_HOST=0.0.0.0 - INVOKEAI_PORT=9090 - INVOKEAI_PRECISION=float16 deploy: resources: reservations: devices: - driver: nvidia count: all capabilities: [gpu] # ── Portainer — Docker management UI ─────────────────────────────────────── portainer: image: portainer/portainer-ce:latest container_name: portainer restart: unless-stopped ports: - "0.0.0.0:9000:9000" - "0.0.0.0:9443:9443" volumes: - /var/run/docker.sock:/var/run/docker.sock - $BASE/portainer-data:/data volumes: ${OLLAMA_VOLUMES_DECL} open-webui-data: invokeai-models: COMPOSE ok "docker-compose.yml written" # ============================================================================= section "Firewall (UFW)" # ============================================================================= if command -v ufw &>/dev/null; then if [[ ! -f "$BASE/.ufw-done" ]] || $FORCE; then for port_comment in \ "3000:Open WebUI" "11434:Ollama" "8001:RAG Server" \ "8002:MCP Server" "8000:ChromaDB" "8888:SearXNG" \ "8181:Kiwix" "3001:Gitea" "2222:Gitea SSH" \ "9090:InvokeAI" "9000:Portainer" "9443:Portainer S"; do port="${port_comment%%:*}" comment="${port_comment##*:}" sudo ufw allow from "$LAN_SUBNET" to any port "$port" proto tcp \ comment "$comment" > /dev/null done sudo ufw reload > /dev/null ok "UFW rules set for $LAN_SUBNET" touch "$BASE/.ufw-done" else info "UFW rules already set (--force to redo)" fi else warn "ufw not found — skipping firewall config" fi # ============================================================================= section "Helper Scripts" # ============================================================================= cat > "$BASE/start.sh" << STARTSH #!/bin/bash cd "$BASE" echo "Pulling latest images..." docker compose pull --quiet docker compose up -d echo "" echo " Open WebUI → http://$LOCAL_IP:3000" echo " InvokeAI → http://$LOCAL_IP:9090" echo " SearXNG → http://$LOCAL_IP:8888" echo " Kiwix → http://$LOCAL_IP:8181" echo " Gitea → http://$LOCAL_IP:3001" echo " RAG Health → http://$LOCAL_IP:8001/health" echo " MCP SSE → http://$LOCAL_IP:8002/sse" echo " Portainer → https://$LOCAL_IP:9443" echo "" echo " Workspace → $BASE/workspace/" echo " Drop PDFs → $BASE/papers/" echo " Images out → $BASE/invokeai-outputs/" echo "" echo " Claude Code MCP:" echo " claude mcp add local http://$LOCAL_IP:8002/sse" STARTSH chmod +x "$BASE/start.sh" ok "start.sh" cat > "$BASE/stop.sh" << STOPSH #!/bin/bash cd "$BASE" docker compose down echo "All services stopped." STOPSH chmod +x "$BASE/stop.sh" ok "stop.sh" cat > "$BASE/status.sh" << 'STATUSSH' #!/bin/bash echo "=== GPU ===" nvidia-smi --query-gpu=name,temperature.gpu,utilization.gpu,memory.used,memory.total \ --format=csv,noheader 2>/dev/null || echo "(no GPU)" echo "" echo "=== Containers ===" docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" echo "" echo "=== Ollama models ===" docker exec ollama ollama ps 2>/dev/null || echo "(not running)" echo "" echo "=== RAG ===" curl -s http://localhost:8001/health | python3 -m json.tool 2>/dev/null \ || echo "(not running)" echo "" echo "=== MCP ===" curl -s http://localhost:8002/health 2>/dev/null || echo "(not running)" echo "" echo "=== Disk ===" du -sh ~/docker/ai-stack/*/ 2>/dev/null STATUSSH chmod +x "$BASE/status.sh" ok "status.sh" cat > "$BASE/pull-models.sh" << PULLSH #!/bin/bash echo "Waiting for Ollama..." until docker exec ollama ollama list &>/dev/null; do sleep 3; done echo "Ollama ready." echo "" echo "Pulling embed model (needed for RAG)..." docker exec ollama ollama pull $EMBED_MODEL echo "Pulling fast chat model (~5GB)..." docker exec ollama ollama pull $FAST_MODEL echo "Pulling smart chat model (~9GB)..." docker exec ollama ollama pull $CHAT_MODEL echo "Pulling code model (~5GB)..." docker exec ollama ollama pull $CODE_MODEL echo "" echo "Done. Models:" docker exec ollama ollama list PULLSH chmod +x "$BASE/pull-models.sh" ok "pull-models.sh" write_if_new "$BASE/Caddyfile.example" << CADDY # Caddy2 reverse proxy — copy to your proxy machine # Replace yourdomain.com with your actual domain webui.yourdomain.com { reverse_proxy $(hostname).local:3000 } invokeai.yourdomain.com { reverse_proxy $(hostname).local:9090 } search.yourdomain.com { reverse_proxy $(hostname).local:8888 } git.yourdomain.com { reverse_proxy $(hostname).local:3001 } kiwix.yourdomain.com { reverse_proxy $(hostname).local:8181 } rag.yourdomain.com { reverse_proxy $(hostname).local:8001 } mcp.yourdomain.com { reverse_proxy $(hostname).local:8002 } portainer.yourdomain.com { reverse_proxy https://$(hostname).local:9443 { transport http { tls_insecure_skip_verify } } } CADDY # ============================================================================= section "Systemd Auto-Start" # ============================================================================= sudo tee /etc/systemd/system/local-ai.service > /dev/null << SYSD [Unit] Description=Local AI Stack After=docker.service network-online.target Requires=docker.service [Service] Type=oneshot RemainAfterExit=yes User=$USER WorkingDirectory=$BASE ExecStart=/bin/bash $BASE/start.sh ExecStop=/bin/bash $BASE/stop.sh TimeoutStartSec=300 [Install] WantedBy=multi-user.target SYSD sudo systemctl daemon-reload sudo systemctl enable local-ai.service ok "Systemd service enabled (local-ai.service)" # ============================================================================= section "Starting Stack" # ============================================================================= cd "$BASE" info "Pulling images (this takes a few minutes on first run)..." COMPOSE_SERVICES="" $INSTALL_AI && COMPOSE_SERVICES+=" ollama open-webui chromadb rag-server mcp-server searxng gitea invokeai portainer" $INSTALL_KIWIX && COMPOSE_SERVICES+=" kiwix" # shellcheck disable=SC2086 docker compose pull --quiet $COMPOSE_SERVICES # shellcheck disable=SC2086 docker compose up -d $COMPOSE_SERVICES ok "Stack started" # ── Pull Ollama models (answer was captured upfront) ───────────────────────── if $INSTALL_AI && $PULL_MODELS; then section "Pulling Ollama Models" info "Waiting for Ollama to be ready..." until docker exec ollama ollama list &>/dev/null; do sleep 3; done ok "Ollama ready." info "Pulling embed model (required for RAG)..." docker exec ollama ollama pull "$EMBED_MODEL" info "Pulling fast chat model..." docker exec ollama ollama pull "$FAST_MODEL" info "Pulling smart chat model..." docker exec ollama ollama pull "$CHAT_MODEL" info "Pulling code model..." docker exec ollama ollama pull "$CODE_MODEL" if $PULL_DEEPSEEK; then info "Pulling DeepSeek-R1:14b (reasoning)..." docker exec ollama ollama pull deepseek-r1:14b fi ok "All models pulled." echo "" docker exec ollama ollama list elif $INSTALL_AI; then info "Skipping model pull — run later: bash $BASE/pull-models.sh" fi # ── Start ZIM downloads (answer was captured upfront) ──────────────────────── if $INSTALL_KIWIX && [[ "$ZIM_CHOICE" != "3" ]]; then section "Starting ZIM Downloads" LOG="$BASE/logs/kiwix-download.log" mkdir -p "$(dirname "$LOG")" info "Checking mirrors for latest ZIM filenames..." MIRROR="https://ftp.fau.de/kiwix/zim" MIRROR2="https://download.kiwix.org/zim" latest_zim() { local base_url="$1" pattern="$2" curl -s "$base_url/" | grep -oP "${pattern}_[0-9-]+\.zim" | sort -u | tail -1 } dl_zim() { local cat="$1" file="$2" desc="$3" mirror="${4:-$MIRROR}" local dest="$KIWIX_DIR/$file" if [[ -z "$file" ]]; then warn "Could not find $desc — skipping"; return; fi if [[ -f "$dest" ]]; then ok "$desc already downloaded"; return; fi info "Starting: $desc" nohup wget -c "$mirror/$cat/$file" -O "$dest" >> "$LOG" 2>&1 & echo $! >> "$KIWIX_DIR/.download_pids" ok "Download started (PID $!) — $desc" } WIKI=$(latest_zim "$MIRROR/wikipedia" "wikipedia_en_all_nopic") SO=$(latest_zim "$MIRROR/stack_exchange" "stackoverflow.com_en_all") ARCH=$(latest_zim "$MIRROR/other" "archlinux_en_all_maxi") WIKT=$(latest_zim "$MIRROR/wiktionary" "wiktionary_en_all_nopic") WIKB=$(latest_zim "$MIRROR/wikibooks" "wikibooks_en_all_nopic") WIKS=$(latest_zim "$MIRROR/wikisource" "wikisource_en_all_nopic") WIKV=$(latest_zim "$MIRROR/wikivoyage" "wikivoyage_en_all_nopic") WIKUNI=$(latest_zim "$MIRROR/wikiversity" "wikiversity_en_all_nopic") WIKNEWS=$(latest_zim "$MIRROR/wikinews" "wikinews_en_all_nopic") WIKQ=$(latest_zim "$MIRROR/wikiquote" "wikiquote_en_all_nopic") VIKIDIA=$(latest_zim "$MIRROR/vikidia" "vikidia_en_all_nopic") TED=$(latest_zim "$MIRROR/ted" "ted_mul_youth") PHET=$(latest_zim "$MIRROR/phet" "phet_en_all") DEVDOCS=$(latest_zim "$MIRROR/devdocs" "devdocs_en_zig") FCC=$(latest_zim "$MIRROR/freecodecamp" "freecodecamp_en_all") IFIX=$(latest_zim "$MIRROR/ifixit" "ifixit_en_all") LIBRE=$(latest_zim "$MIRROR/libretexts" "libretexts.org_en_workforce") GUT=$(latest_zim "$MIRROR2/gutenberg" "gutenberg_en_all") if [[ "$ZIM_CHOICE" == "2" ]]; then # Let user select which ZIMs echo "" echo " Select ZIMs to download (enter numbers separated by spaces):" echo " 1) Wikipedia ~46GB ${WIKI:-NOT FOUND}" echo " 2) Stack Overflow ~3GB ${SO:-NOT FOUND}" echo " 3) Arch Linux Wiki ~30MB ${ARCH:-NOT FOUND}" echo " 4) Wiktionary ~2GB ${WIKT:-NOT FOUND}" echo " 5) Wikibooks ~500MB ${WIKB:-NOT FOUND}" echo " 6) Wikisource ~4GB ${WIKS:-NOT FOUND}" echo " 7) Wikivoyage ~200MB ${WIKV:-NOT FOUND}" echo " 8) Wikiversity ~500MB ${WIKUNI:-NOT FOUND}" echo " 9) WikiNews ~300MB ${WIKNEWS:-NOT FOUND}" echo " 10) Wikiquote ~300MB ${WIKQ:-NOT FOUND}" echo " 11) Vikidia (kids K-8) ~66MB ${VIKIDIA:-NOT FOUND}" echo " 12) TED Talks ~5GB ${TED:-NOT FOUND}" echo " 13) PhET Simulations ~500MB ${PHET:-NOT FOUND}" echo " 14) DevDocs ~1GB ${DEVDOCS:-NOT FOUND}" echo " 15) FreeCodeCamp ~small ${FCC:-NOT FOUND}" echo " 16) iFixit ~2GB ${IFIX:-NOT FOUND}" echo " 17) LibreTexts ~varies ${LIBRE:-NOT FOUND}" echo " 18) Project Gutenberg ~60GB ${GUT:-NOT FOUND}" echo "" read -rp " Your choices (e.g. 1 2 3): " ZIM_PICKS rm -f "$KIWIX_DIR/.download_pids" for n in $ZIM_PICKS; do case "$n" in 1) dl_zim "wikipedia" "$WIKI" "Wikipedia" ;; 2) dl_zim "stack_exchange" "$SO" "Stack Overflow" ;; 3) dl_zim "other" "$ARCH" "Arch Linux Wiki" ;; 4) dl_zim "wiktionary" "$WIKT" "Wiktionary" ;; 5) dl_zim "wikibooks" "$WIKB" "Wikibooks" ;; 6) dl_zim "wikisource" "$WIKS" "Wikisource" ;; 7) dl_zim "wikivoyage" "$WIKV" "Wikivoyage" ;; 8) dl_zim "wikiversity" "$WIKUNI" "Wikiversity" ;; 9) dl_zim "wikinews" "$WIKNEWS" "WikiNews" ;; 10) dl_zim "wikiquote" "$WIKQ" "Wikiquote" ;; 11) dl_zim "vikidia" "$VIKIDIA" "Vikidia (kids K-8)" ;; 12) dl_zim "ted" "$TED" "TED Talks" ;; 13) dl_zim "phet" "$PHET" "PhET Simulations" ;; 14) dl_zim "devdocs" "$DEVDOCS" "DevDocs" ;; 15) dl_zim "freecodecamp" "$FCC" "FreeCodeCamp" ;; 16) dl_zim "ifixit" "$IFIX" "iFixit" ;; 17) dl_zim "libretexts" "$LIBRE" "LibreTexts" ;; 18) dl_zim "gutenberg" "$GUT" "Project Gutenberg" "$MIRROR2" ;; esac done else # Download all rm -f "$KIWIX_DIR/.download_pids" dl_zim "wikipedia" "$WIKI" "Wikipedia" dl_zim "stack_exchange" "$SO" "Stack Overflow" dl_zim "other" "$ARCH" "Arch Linux Wiki" dl_zim "wiktionary" "$WIKT" "Wiktionary" dl_zim "wikibooks" "$WIKB" "Wikibooks" dl_zim "wikisource" "$WIKS" "Wikisource" dl_zim "wikivoyage" "$WIKV" "Wikivoyage" dl_zim "wikiversity" "$WIKUNI" "Wikiversity" dl_zim "wikinews" "$WIKNEWS" "WikiNews" dl_zim "wikiquote" "$WIKQ" "Wikiquote" dl_zim "vikidia" "$VIKIDIA" "Vikidia (kids K-8)" dl_zim "ted" "$TED" "TED Talks" dl_zim "phet" "$PHET" "PhET Simulations" dl_zim "devdocs" "$DEVDOCS" "DevDocs" dl_zim "freecodecamp" "$FCC" "FreeCodeCamp" dl_zim "ifixit" "$IFIX" "iFixit" dl_zim "libretexts" "$LIBRE" "LibreTexts" dl_zim "gutenberg" "$GUT" "Project Gutenberg" "$MIRROR2" fi ok "ZIM downloads running in background — monitor: tail -f $LOG" fi # ============================================================================= echo "" echo -e "${GREEN}${BOLD}━━━ Done! ━━━${NC}" echo "" if $INSTALL_AI; then echo -e " ${CYAN}Open WebUI${NC} → http://$LOCAL_IP:3000" echo -e " ${CYAN}InvokeAI${NC} → http://$LOCAL_IP:9090" echo -e " ${CYAN}SearXNG${NC} → http://$LOCAL_IP:8888" echo -e " ${CYAN}Gitea${NC} → http://$LOCAL_IP:3001" echo -e " ${CYAN}RAG Health${NC} → http://$LOCAL_IP:8001/health" echo -e " ${CYAN}MCP SSE${NC} → http://$LOCAL_IP:8002/sse" echo -e " ${CYAN}Portainer${NC} → https://$LOCAL_IP:9443" fi if $INSTALL_KIWIX; then echo -e " ${CYAN}Kiwix${NC} → http://$LOCAL_IP:8181" fi echo "" if $INSTALL_AI; then echo -e " ${YELLOW}Add MCP to Claude Code:${NC}" echo " claude mcp add local http://$LOCAL_IP:8002/sse" echo "" echo -e " ${YELLOW}Add API tokens to:${NC} $BASE/.env" echo -e " ${YELLOW}Drop PDFs into:${NC} $BASE/papers/" echo -e " ${YELLOW}Your workspace:${NC} $BASE/workspace/" fi if $INSTALL_KIWIX && [[ "$ZIM_CHOICE" == "3" ]]; then echo -e " ${YELLOW}ZIM downloads:${NC} ./kiwix_download.sh (not started)" elif $INSTALL_KIWIX; then echo -e " ${YELLOW}ZIM progress:${NC} tail -f $BASE/logs/kiwix-download.log" echo -e " ${YELLOW}ZIM location:${NC} $KIWIX_DIR/" fi echo "" $IS_UPDATE && echo -e " ${GREEN}Update complete.${NC}" \ || echo -e " ${GREEN}Fresh install complete.${NC}" echo ""