{"slug": "self-hosting-an-ai-assistant-on-casaos-a-step-by-step-openmuse-install-guide", "title": "Self-Hosting an AI Assistant on CasaOS: A Step-by-Step OpenMuse Install Guide (Pitfalls Included)", "summary": "A developer published a step-by-step guide for self-hosting OpenMuse, an AI chat assistant that can also manage a CasaOS home server, using SSH and Docker Compose. The guide deploys the casaos-manager-v3 branch across three containers (api, web, and a loopback-only browser-worker) and documents pitfalls including CasaOS's xterm.js web terminal mangling keystrokes, stale processes holding port 8787, and CasaOS's Custom Install button passing multi-flag commands as a single argument.", "body_md": "*How to deploy [OpenMuse](https://github.com/CopilotKit/openmuse) — a self-hosted AI chat assistant that can also manage your CasaOS server — using SSH and Docker Compose. Every step below exists because we did it the wrong way first.*\n\nA home server running [CasaOS](https://www.casaos.io/) (this guide was tested on v0.4.9) gets a conversational AI assistant living on the same box — one that doesn't just chat, but can list your apps, read logs, and start/stop containers with your approval. Three containers do the work:\n\n| Container | What it is | Port |\n\n|---|---|\n\n| `api` | The Node API server + agent brain | 8787 |\n\n| `web` | The chat UI (static site on nginx) | 8081 |\n\n| `browser-worker` | Sandboxed browser for web tasks | 8790 (loopback only) |\n\nOpenMuse ships no official Dockerfiles and no CasaOS guide, so this post provides both. We'll deploy the [`casaos-manager-v3`](https://github.com/Magrebi/openmuse) branch, which adds a CasaOS control layer, a document library, and OCR on top of upstream OpenMuse.\n\n`npx copilotkit@latest login`, then `npx copilotkit@latest project select`)\nThe CasaOS dashboard has a built-in terminal, and it will betray you: its xterm.js input silently mangles keystrokes mid-session (typed text arrives as `-` characters, commands never execute, and retrying doesn't fix it). **Do all of the following over SSH from your own machine.** If you must use the web terminal, paste whole command blocks — never type long commands by hand.\n\n```\nssh <user>@<server-lan-ip>\n```\n\nEverything below runs on the server.\n\nA previous install attempt (or a half-dead `tsx` dev server) can leave a stale process holding port 8787. `docker compose down` does **not** kill host processes or containers from a *different* compose project, so check before you start:\n\n```\nss -tlnp | grep -E '8787|8081|8790'\ndocker ps -a --format '{{.Names}} {{.Status}}' | grep -i -E 'openmuse|worker'\n```\n\nIf anything shows up, stop/remove it now. Every \"address already in use\" mystery we've seen traced back to this step being skipped.\n\nWe'll keep everything under `/DATA/AppData/openmuse` — the conventional CasaOS app-data location, so backups and future you know where to look.\n\nThe Dockerfiles below expect the OpenMuse source tree in a `repo/` subdirectory, so clone it there:\n\n```\nsudo mkdir -p /DATA/AppData/openmuse\nsudo chown $USER:$USER /DATA/AppData/openmuse\ncd /DATA/AppData/openmuse\ngit clone --branch casaos-manager-v3 https://github.com/Magrebi/openmuse.git repo\nmkdir -p data\n```\n\n**Why not CasaOS's \"Custom Install\" button?** CasaOS passes the custom-install `command` field to the container as **one single argument**. A multi-flag server command (e.g. `llama-server -m model.gguf --port 8080`) crashes with `error: invalid argument`. This stack needs real Compose orchestration anyway — SSH + `docker compose` is the reliable path.\n\nYour tree should look like this:\n\n```\n/DATA/AppData/openmuse/\n├── docker-compose.yaml   # you write this (Step 3)\n├── Dockerfile.api        # you write this (Step 3)\n├── Dockerfile.web        # you write this (Step 3)\n├── repo/                 # the OpenMuse source (cloned above)\n├── .env                  # you write this (Step 4)\n└── data/                 # app database + library files (created at runtime)\n```\n\n`docker-compose.yaml`\n\n```\nname: openmuse\nservices:\n  api:\n    build:\n      context: .\n      dockerfile: Dockerfile.api\n    image: openmuse-api:local\n    env_file: .env\n    ports:\n      - \"8787:8787\"\n    volumes:\n      - ./data:/app/.openmuse\n    restart: unless-stopped\n    healthcheck:\n      test: [\"CMD\", \"node\", \"-e\", \"fetch('http://127.0.0.1:8787/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\"]\n      interval: 20s\n      timeout: 5s\n      retries: 5\n      start_period: 60s\n\n  web:\n    build:\n      context: .\n      dockerfile: Dockerfile.web\n      args:\n        # ⚠️ Baked into the static site at BUILD time.\n        # Changing the API address later = rebuilding web. Pick something stable.\n        EXPO_PUBLIC_API_URL: http://<server-lan-ip>:8787\n    image: openmuse-web:local\n    ports:\n      - \"8081:80\"\n    restart: unless-stopped\n    depends_on:\n      - api\n\n  browser-worker:\n    build:\n      context: ./repo/apps/worker\n    image: openmuse-worker:local\n    init: true\n    restart: unless-stopped\n    environment:\n      WORKER_HOST: 0.0.0.0\n      WORKER_TOKEN: <same-value-as-WORKER_TOKEN-in-.env>\n    ports:\n      - \"127.0.0.1:8790:8790\"   # loopback only — the browser never needs LAN exposure\n    volumes:\n      - worker-data:/data\n    tmpfs:\n      - /tmp:size=256m,mode=1777\n    shm_size: 256mb\n    mem_limit: 2g\n    pids_limit: 256\n    read_only: true\n    cap_drop:\n      - ALL\n    security_opt:\n      - no-new-privileges:true\n    healthcheck:\n      test: [\"CMD\", \"node\", \"-e\", \"fetch('http://127.0.0.1:8790/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\"]\n      interval: 15s\n      timeout: 5s\n      retries: 3\n\nvolumes:\n  worker-data:\n```\n\nTwo things worth noticing: the API reaches the worker over Compose's internal DNS (`http://browser-worker:8790`, set in `.env` below), and the worker's published port is bound to `127.0.0.1` — browser automation has no business being on your LAN.\n\n`Dockerfile.api`\n\n```\nFROM node:24-bookworm-slim\n# Document OCR + PDF text extraction for the library (Tesseract + Poppler).\n# Don't pin apt versions: pins that don't exist in this Debian release\n# fail the build (we learned this the hard way with tesseract on bookworm).\n# If you must pin, verify the version first with `apt-cache policy <pkg>`\n# inside the base image.\nRUN apt-get update && apt-get install -y --no-install-recommends \\\n    tesseract-ocr tesseract-ocr-eng tesseract-ocr-tur poppler-utils \\\n && rm -rf /var/lib/apt/lists\nRUN corepack enable && corepack prepare pnpm@11.19.0 --activate\nWORKDIR /app\nCOPY repo ./\nRUN pnpm install --frozen-lockfile\nRUN pnpm build:server\nENV PORT=8787 HOST=0.0.0.0 DATA_DIR=/app/.openmuse\nEXPOSE 8787\nCMD [\"node\", \"dist/apps/server/src/index.js\"]\n```\n\n`Dockerfile.web`\n\n```\nFROM node:24-bookworm-slim AS build\nRUN corepack enable && corepack prepare pnpm@11.19.0 --activate\nWORKDIR /app\nCOPY repo ./\nRUN pnpm install --frozen-lockfile\nARG EXPO_PUBLIC_API_URL\nENV EXPO_PUBLIC_API_URL=$EXPO_PUBLIC_API_URL\nRUN pnpm --dir apps/mobile build:web\n\nFROM nginx:alpine\nCOPY --from=build /app/apps/mobile/dist/web /usr/share/nginx/html\nEXPOSE 80\n```\n\n`.env` file — every field explained\nCreate it **on the server with `nano`** (or your editor of choice). Secrets go into this file on the host — never into a chat window, a prompt, or a screenshot.\n\n```\nnano /DATA/AppData/openmuse/.env\nWORKSPACE_MODE=live\nAGENT_BACKEND=model\n\n# --- Model (OpenRouter example) ---\n# \"openai/\" is OpenMuse's internal routing prefix: everything after it\n# is the model id sent to OPENAI_BASE_URL.\nMODEL=openai/anthropic/claude-sonnet-4-5\nOPENAI_BASE_URL=https://openrouter.ai/api/v1\nOPENAI_API_KEY=\n\nCPK_INTELLIGENCE_API_KEY=   # required in every mode\n\n# --- Server ---\nPORT=8787\n# ⚠️ HOST=0.0.0.0, not your Tailscale or LAN IP.\n# Binding to a specific IP broke restarts with EADDRNOTAVAIL when that\n# interface wasn't up yet. 0.0.0.0 is the safe choice.\nHOST=0.0.0.0\nPUBLIC_API_URL=http://<server-lan-ip>:8787\nDATA_DIR=/app/.openmuse\nALLOWED_ORIGINS=http://<server-lan-ip>:8081\nTASK_WORKER_ENABLED=true\nCOMPUTER_ENABLED=false\n\n# --- Secrets: generate, don't invent ---\nOPENMUSE_ACCESS_KEY=        # ≥24 chars — you type this at first login\nTOKEN_ENCRYPTION_KEY=       # 32 random bytes, base64\nBROWSER_WORKER_URL=http://browser-worker:8790\nWORKER_TOKEN=               # must MATCH the value in docker-compose.yaml\n```\n\nGenerate the three secrets on the server:\n\n```\nopenssl rand -hex 24     # → OPENMUSE_ACCESS_KEY\nopenssl rand -base64 32  # → TOKEN_ENCRYPTION_KEY\nopenssl rand -hex 24     # → WORKER_TOKEN (paste into BOTH .env and docker-compose.yaml)\n```\n\n⚠️ **Compose reads `.env` only when containers are (re)created.** Editing `.env` and running `docker compose restart` does nothing — you need `docker compose up -d` so the changed containers are recreated.\n\nThe first build compiles the server and the web UI — expect 10–20 minutes on a modest box. Go make coffee.\n\n```\ncd /DATA/AppData/openmuse\ndocker compose build\ndocker compose up -d\ndocker compose ps\ncurl -s http://127.0.0.1:8787/api/health; echo\ncurl -s http://127.0.0.1:8790/health; echo\n```\n\nYou want: `api` healthy, `web` up, `browser-worker` healthy, and both `curl` calls answering. If the API container keeps restarting, check `docker compose logs api --tail 50` — the usual suspects are a wrong `HOST` value (Step 4) or a port squatter (Step 1).\n\n`http://<server-lan-ip>:8081` in your browser.`OPENMUSE_ACCESS_KEY`.\nWhat the CasaOS layer gives you: read-only tools (`casaos_list_apps`, `casaos_app_status`, `casaos_app_logs`, `casaos_system_status`) plus `casaos_start_app` / `casaos_stop_app` / `casaos_restart_app` — but mutations never execute directly. The tool call only *prepares* an action; you approve it in the Activity panel, approvals are single-use, and protected apps (OpenMuse itself and anything hosting its network access) are refused outright. Try *\"which apps are installed?\"* first, then *\"stop HandBrake\"* to see the approval card in action.\n\nThe same branch also ships a document library with OCR (Tesseract runs fully on your box — nothing is sent to the cloud) and full-text search over your uploads.\n\nFuture updates are: `git -C repo pull`, `docker compose build`, `docker compose up -d`. But **back up first, every time**:\n\n```\nTS=$(date +%F)\nsudo mkdir -p /DATA/AppData/openmuse-backup-$TS\nsudo cp -a /DATA/AppData/openmuse/docker-compose.yaml \\\n           /DATA/AppData/openmuse/Dockerfile.api \\\n           /DATA/AppData/openmuse/Dockerfile.web \\\n           /DATA/AppData/openmuse/.env \\\n           /DATA/AppData/openmuse/data \\\n           /DATA/AppData/openmuse-backup-$TS/\n```\n\nTwo backup lessons from our own scars:\n\n`/DATA/AppData/<app>` entirely — we lost a whole Jellyfin config this way. Read that checkbox twice before confirming any uninstall.`docker compose down`, restoring the backup tree, and `docker compose up -d`. Because you backed up `data/`, the app database and library survive the round trip.\n| Symptom | Root cause | Fix |\n\n|---|---|\n\n| `address already in use` on 8787 | Stale `tsx` process or orphan container from an earlier attempt | `ss -tlnp \\| grep 8787`, kill it; `docker ps -a` for orphans |\n\n| API exits with `EADDRNOTAVAIL` | `HOST` bound to an IP that wasn't up at start | `HOST=0.0.0.0`, then `docker compose up -d` |\n\n| `.env` change had no effect | Env is captured at container creation | `docker compose up -d` (recreate), not `restart` |\n\n| Web UI talks to the wrong API URL | `EXPO_PUBLIC_API_URL` is baked at build time | Rebuild `web` after changing the arg |\n\n| CasaOS dashboard shows OpenMuse as \"unknown\" | CasaOS control-plane HTTP 500, not your app | Check `docker compose ps` directly — it's usually fine |\n\n| Script gets `Session expired` | Raw access key used as a Bearer token | `POST /api/session` with the key first, use the returned session token |\n\n| `apt` build fails on a pinned package | That version doesn't exist in bookworm | Drop the pin, or verify with `apt-cache policy` in the base image |\n\n| `error: invalid argument` in a container | CasaOS custom-install `command` passed as one argument | Use `docker compose` via SSH instead of the CasaOS dialog |\n\n*Tested on CasaOS v0.4.9 with OpenMuse built from upstream `main`. If a step breaks on your box, the troubleshooting table above is where the bodies are buried.*", "url": "https://wpnews.pro/news/self-hosting-an-ai-assistant-on-casaos-a-step-by-step-openmuse-install-guide", "canonical_source": "https://dev.to/muratmed/self-hosting-an-ai-assistant-on-casaos-a-step-by-step-openmuse-install-guide-pitfalls-included-228b", "published_at": "2026-10-07 00:25:00+00:00", "updated_at": "2026-10-07 00:47:49.448156+00:00", "lang": "en", "topics": ["ai-agents", "ai-tools", "ai-products", "developer-tools", "mlops"], "entities": ["OpenMuse", "CasaOS", "CopilotKit", "Docker Compose", "GitHub", "nginx", "Node.js"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/self-hosting-an-ai-assistant-on-casaos-a-step-by-step-openmuse-install-guide", "markdown": "https://wpnews.pro/news/self-hosting-an-ai-assistant-on-casaos-a-step-by-step-openmuse-install-guide.md", "text": "https://wpnews.pro/news/self-hosting-an-ai-assistant-on-casaos-a-step-by-step-openmuse-install-guide.txt", "jsonld": "https://wpnews.pro/news/self-hosting-an-ai-assistant-on-casaos-a-step-by-step-openmuse-install-guide.jsonld"}}