Files
local-ai/laptop_full_setup.sh
T
Claude 92f5d886c7 Add leave-overnight wizard to laptop_full_setup.sh
Gather all setup choices upfront so users can answer questions once and
walk away. Changes:
- Setup wizard asks all questions before any installation begins:
  1. Component selection (AI stack / Kiwix / both)
  2. Ollama model storage (Docker volume or custom bind-mount path)
  3. Kiwix ZIM storage location (detect drives with enough free space)
  4. Firewall LAN subnet (auto-detected from current IP)
  5. Model downloads (confirm GPU-matched models, optional DeepSeek-R1)
  6. ZIM downloads (all / select / skip)
- Shows a summary and asks for confirmation before doing anything
- Ollama supports bind-mount to custom path (e.g. /mnt/ssd/ollama)
- Kiwix ZIM dir is configurable (separate large drive)
- Model pull runs unattended at end using upfront answer
- ZIM downloads integrated inline (no need to run kiwix_download.sh
  separately) — background wget with progress log

https://claude.ai/code/session_012gDnantBmFTWZGCiKyjazx
2026-03-20 22:01:27 +00:00

959 lines
38 KiB
Bash
Executable File

#!/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=<query>&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 ""