cd /news/ai-agents/self-hosting-an-ai-assistant-on-casa… Β· home β€Ί topics β€Ί ai-agents β€Ί article
[ARTICLE Β· art-146455] src=dev.to β†— pub= topic=ai-agents verified=true sentiment=Β· neutral

Self-Hosting an AI Assistant on CasaOS: A Step-by-Step OpenMuse Install Guide (Pitfalls Included)

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.

by read8 min views1 publishedOct 7, 2026

How to deploy 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.

A home server running CasaOS (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:

| Container | What it is | Port |

|---|---|

| api | The Node API server + agent brain | 8787 |

| web | The chat UI (static site on nginx) | 8081 |

| browser-worker | Sandboxed browser for web tasks | 8790 (loopback only) |

OpenMuse ships no official Dockerfiles and no CasaOS guide, so this post provides both. We'll deploy the casaos-manager-v3 branch, which adds a CasaOS control layer, a document library, and OCR on top of upstream OpenMuse.

npx copilotkit@latest login, then npx copilotkit@latest project select) The 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.

ssh <user>@<server-lan-ip>

Everything below runs on the server.

A 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:

ss -tlnp | grep -E '8787|8081|8790'
docker ps -a --format '{{.Names}} {{.Status}}' | grep -i -E 'openmuse|worker'

If anything shows up, stop/remove it now. Every "address already in use" mystery we've seen traced back to this step being skipped.

We'll keep everything under /DATA/AppData/openmuse β€” the conventional CasaOS app-data location, so backups and future you know where to look.

The Dockerfiles below expect the OpenMuse source tree in a repo/ subdirectory, so clone it there:

sudo mkdir -p /DATA/AppData/openmuse
sudo chown $USER:$USER /DATA/AppData/openmuse
cd /DATA/AppData/openmuse
git clone --branch casaos-manager-v3 https://github.com/Magrebi/openmuse.git repo
mkdir -p data

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.

Your tree should look like this:

/DATA/AppData/openmuse/
β”œβ”€β”€ docker-compose.yaml   # you write this (Step 3)
β”œβ”€β”€ Dockerfile.api        # you write this (Step 3)
β”œβ”€β”€ Dockerfile.web        # you write this (Step 3)
β”œβ”€β”€ repo/                 # the OpenMuse source (cloned above)
β”œβ”€β”€ .env                  # you write this (Step 4)
└── data/                 # app database + library files (created at runtime)

docker-compose.yaml

name: openmuse
services:
  api:
    build:
      context: .
      dockerfile: Dockerfile.api
    image: openmuse-api:local
    env_file: .env
    ports:
      - "8787:8787"
    volumes:
      - ./data:/app/.openmuse
    restart: unless-stopped
    healthcheck:
      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))"]
      interval: 20s
      timeout: 5s
      retries: 5
      start_period: 60s

  web:
    build:
      context: .
      dockerfile: Dockerfile.web
      args:
        EXPO_PUBLIC_API_URL: http://<server-lan-ip>:8787
    image: openmuse-web:local
    ports:
      - "8081:80"
    restart: unless-stopped
    depends_on:
      - api

  browser-worker:
    build:
      context: ./repo/apps/worker
    image: openmuse-worker:local
    init: true
    restart: unless-stopped
    environment:
      WORKER_HOST: 0.0.0.0
      WORKER_TOKEN: <same-value-as-WORKER_TOKEN-in-.env>
    ports:
      - "127.0.0.1:8790:8790"   # loopback only β€” the browser never needs LAN exposure
    volumes:
      - worker-data:/data
    tmpfs:
      - /tmp:size=256m,mode=1777
    shm_size: 256mb
    mem_limit: 2g
    pids_limit: 256
    read_only: true
    cap_drop:
      - ALL
    security_opt:
      - no-new-privileges:true
    healthcheck:
      test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:8790/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
      interval: 15s
      timeout: 5s
      retries: 3

volumes:
  worker-data:

Two 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.

Dockerfile.api

FROM node:24-bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
    tesseract-ocr tesseract-ocr-eng tesseract-ocr-tur poppler-utils \
 && rm -rf /var/lib/apt/lists
RUN corepack enable && corepack prepare pnpm@11.19.0 --activate
WORKDIR /app
COPY repo ./
RUN pnpm install --frozen-lockfile
RUN pnpm build:server
ENV PORT=8787 HOST=0.0.0.0 DATA_DIR=/app/.openmuse
EXPOSE 8787
CMD ["node", "dist/apps/server/src/index.js"]

Dockerfile.web

FROM node:24-bookworm-slim AS build
RUN corepack enable && corepack prepare pnpm@11.19.0 --activate
WORKDIR /app
COPY repo ./
RUN pnpm install --frozen-lockfile
ARG EXPO_PUBLIC_API_URL
ENV EXPO_PUBLIC_API_URL=$EXPO_PUBLIC_API_URL
RUN pnpm --dir apps/mobile build:web

FROM nginx:alpine
COPY --from=build /app/apps/mobile/dist/web /usr/share/nginx/html
EXPOSE 80

.env file β€” every field explained Create 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.

nano /DATA/AppData/openmuse/.env
WORKSPACE_MODE=live
AGENT_BACKEND=model

MODEL=openai/anthropic/claude-sonnet-4-5
OPENAI_BASE_URL=https://openrouter.ai/api/v1
OPENAI_API_KEY=

CPK_INTELLIGENCE_API_KEY=   # required in every mode

PORT=8787
HOST=0.0.0.0
PUBLIC_API_URL=http://<server-lan-ip>:8787
DATA_DIR=/app/.openmuse
ALLOWED_ORIGINS=http://<server-lan-ip>:8081
TASK_WORKER_ENABLED=true
COMPUTER_ENABLED=false

OPENMUSE_ACCESS_KEY=        # β‰₯24 chars β€” you type this at first login
TOKEN_ENCRYPTION_KEY=       # 32 random bytes, base64
BROWSER_WORKER_URL=http://browser-worker:8790
WORKER_TOKEN=               # must MATCH the value in docker-compose.yaml

Generate the three secrets on the server:

openssl rand -hex 24     # β†’ OPENMUSE_ACCESS_KEY
openssl rand -base64 32  # β†’ TOKEN_ENCRYPTION_KEY
openssl rand -hex 24     # β†’ WORKER_TOKEN (paste into BOTH .env and docker-compose.yaml)

⚠️ 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.

The first build compiles the server and the web UI β€” expect 10–20 minutes on a modest box. Go make coffee.

cd /DATA/AppData/openmuse
docker compose build
docker compose up -d
docker compose ps
curl -s http://127.0.0.1:8787/api/health; echo
curl -s http://127.0.0.1:8790/health; echo

You 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).

http://<server-lan-ip>:8081 in your browser.OPENMUSE_ACCESS_KEY. What 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.

The 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.

Future updates are: git -C repo pull, docker compose build, docker compose up -d. But back up first, every time:

TS=$(date +%F)
sudo mkdir -p /DATA/AppData/openmuse-backup-$TS
sudo cp -a /DATA/AppData/openmuse/docker-compose.yaml \
           /DATA/AppData/openmuse/Dockerfile.api \
           /DATA/AppData/openmuse/Dockerfile.web \
           /DATA/AppData/openmuse/.env \
           /DATA/AppData/openmuse/data \
           /DATA/AppData/openmuse-backup-$TS/

Two backup lessons from our own scars:

/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. | Symptom | Root cause | Fix |

|---|---|

| 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 |

| 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 |

| .env change had no effect | Env is captured at container creation | docker compose up -d (recreate), not restart |

| Web UI talks to the wrong API URL | EXPO_PUBLIC_API_URL is baked at build time | Rebuild web after changing the arg |

| CasaOS dashboard shows OpenMuse as "unknown" | CasaOS control-plane HTTP 500, not your app | Check docker compose ps directly β€” it's usually fine |

| Script gets Session expired | Raw access key used as a Bearer token | POST /api/session with the key first, use the returned session token |

| 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 |

| error: invalid argument in a container | CasaOS custom-install command passed as one argument | Use docker compose via SSH instead of the CasaOS dialog |

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.

── more in #ai-agents 4 stories Β· sorted by recency
── more on @openmuse 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain β€” perfect for shipping the agent you just read about.

$git push zahid main
β†’ Live at https://your-agent.zahid.host βœ“
Get free account β†’ Pricing
from €0/mo Β· no card required
LIVE [news/self-hosting-an-ai-a…] indexed:0 read:8min 2026-10-07 Β· β€”