---
name: zimaos-platform
description: Build, deploy, administer, inspect, and debug applications on a ZimaOS (CasaOS) server. Covers server setup, Docker/CasaOS container management, app data inspection, and web app development/deployment.
version: 2.0.0
author: Hermes Agent
license: MIT
metadata:
  hermes:
    tags: [zimaos, casaos, docker, administration, web-app, fastapi, deployment, app-data, inspection]
    related_skills: [home-assistant, openhue]
---

# ZimaOS Platform — Server Administration, App Deployment & Data Inspection

Comprehensive guide for managing a ZimaOS (CasaOS-based Debian) server. Covers SSH setup, storage, Docker/CasaOS quirks, web app deployment patterns, and live app data inspection without stopping containers.

## System Overview

ZimaOS is an appliance OS by IceWhale running on Debian. Key characteristics:
- `/root` is on a **read-only overlayfs** — persistent files must live under `/DATA/`
- `/DATA/AppData/` holds all application configs
- Docker CLI requires `DOCKER_CONFIG=/tmp/docker-config` env var
- No `unattended-upgrades` or `ufw`
- CasaOS service: `zimaos-app-management` (systemd), not `casaos-app-management`

## SSH Setup

### Read-Only /root Quirk

Cannot write to `/root/.ssh/authorized_keys` directly. Use `/DATA/.ssh/` instead:

```bash
sudo mkdir -p /DATA/.ssh
echo 'ssh-ed25519 AAA... user@host' | sudo tee /DATA/.ssh/authorized_keys
sudo chmod 600 /DATA/.ssh/authorized_keys
sudo sed -i 's|#AuthorizedKeysFile.*|AuthorizedKeysFile /DATA/.ssh/authorized_keys|' /etc/ssh/sshd_config
sudo systemctl reload sshd
```

### Verification

```bash
systemctl status sshd
sudo grep -E '^(#\s*)?(PubkeyAuthentication|PasswordAuthentication|PermitRootLogin|AuthorizedKeysFile)' /etc/ssh/sshd_config
sudo cat /DATA/.ssh/authorized_keys
```

## Storage Layout

| Path | Purpose |
|---|---|
| `/DATA/` | Main writable data partition |
| `/DATA/AppData/` | Application configs (HA, Plex, etc.) |
| `/DATA/.hermes/` | Hermes Agent config and skills |
| `/DATA/.ssh/` | SSH authorized_keys (persistent) |
| `/media/HDD_1TB/` | Additional storage drives |

## Docker & CasaOS Management

### Environment Quirks

```bash
export DOCKER_CONFIG=/tmp/docker-config  # Required for docker CLI
```

For Docker compose builds, the directory must be writable by the docker group:
```bash
sudo mkdir -p /tmp/docker-config && sudo chown -R az-a:docker /tmp/docker-config
```

### Compose File Locations

Two patterns exist for compose files:

**Pattern A — CasaOS-managed (most apps):** `/var/lib/casaos/apps/<AppName>/docker-compose.yml`
Both paths are equivalent:
- `/var/lib/casaos/apps/<AppName>/`
- `/DATA/.casaos/apps/<AppName>/`

**Pattern B — Self-built custom apps:** `/DATA/AppData/<appname>/casaos-compose.yml`
Some custom apps (MuslimOS, CallKeep, DailyDose) store their compose file directly in the AppData directory. These are managed via `docker compose -f /DATA/AppData/<appname>/casaos-compose.yml` from that directory, not from `/var/lib/casaos/apps/`.

When unsure which pattern an app uses, check both locations:
```bash
ls /var/lib/casaos/apps/<AppName>/docker-compose.yml 2>/dev/null || ls /DATA/AppData/<appname>/casaos-compose.yml 2>/dev/null
```

### CasaOS App Management Commands

```bash
# Restart app management after compose changes
sudo systemctl restart zimaos-app-management

# Container basics
docker ps -a
docker inspect <name>
docker logs --tail 100 <name>
```

### Naming Conventions

- Compose `name:` field (top-level, lowercase): prevents random Docker names
- `container_name:` (PascalCase): prevents ugly `<app>-<service>-1` names
- Volume paths: use underscores, never spaces
- Never use `docker run -d` for CasaOS-managed apps — always use compose

### Tailscale via Docker

Install Tailscale as a container with `network_mode: host`. Full setup and flag-change recovery procedure in `references/tailscale-docker.md`.

### Docker Pitfalls

- `sudo env DOCKER_CONFIG=...` may be blocked by `/etc/sudoers` — use `sudo bash -c 'export DOCKER_CONFIG=... && docker compose ...'` instead
- Docker build blocked by tool policy (foreground timeout) — use `background=true` + `notify_on_complete=true`
- `docker compose up -d` alone reuses cached images; always `build --no-cache` when source files change
- Compose without `x-casaos` block registers as `v2app` but fails with "Derzeit nicht unterstützt"

## Modifying Existing ZimaOS Apps

When the user asks to add a feature to an existing app (e.g., MuslimOS, CallKeep, DailyDose), follow this workflow:

### 1. Inspect thoroughly before touching anything

```bash
# Find all app files
find /DATA/AppData/<appname> -maxdepth 4 -type f | head -80
# Read server, frontend HTML, CSS, JS completely
# Read the compose file for port/volume mapping
# Read the data file (db.json, sqlite) for schema
```

Understand the full architecture: server language/framework, frontend patterns, data schema, CSS design system, event handling, API conventions. Do NOT start coding until you can explain how the existing tabs/features work.

### 2. Write a detailed spec for the subagent

The spec must include:
- Exact file paths to modify
- Existing code patterns to follow (copy the style of existing features)
- Design system constraints (CSS variables, class names, color palette)
- API endpoint signatures matching existing conventions
- Data schema extensions
- Verification steps (API curl commands, browser checks)

### 3. Delegate to kimi-k2.7-code

Use `delegate_task` with the full spec. The subagent runs on the coding model. Your role is orchestration, not implementation.

### 4. Verify and restart

```bash
# Check the changes exist
grep -n "<new function/endpoint>" /DATA/AppData/<appname>/app/server.js
# Restart the container
sudo docker restart <container_name>
# Test API endpoints with curl
curl -s http://localhost:<port>/api/<new-endpoint>
# Confirm browser-visible changes
```

### Renaming an Existing App (Dashboard Display Name)

**CRITICAL: CasaOS identifies apps by `store_app_id`, NOT by `name:` or `container_name:`. Changing `store_app_id` breaks the app registration — the app disappears from the dashboard entirely. Only change the display title.**

When the user wants to rename an app in the CasaOS dashboard (e.g., MuslimOS → EhliSünnet):

1. **Change ONLY the display title and container_name** — do NOT touch `name:`, `services:` key, `networks:`, or `store_app_id:`:
   - `container_name:` → new container name (cosmetic, Docker-level only)
   - `x-casaos: title: custom:` → new display name (what shows in dashboard)
   - Leave `name:`, `services:` key, `networks: default: name:`, and `store_app_id:` matching the original app directory name

2. **Stop and remove the old container** before starting the new one:
   ```bash
   docker stop <old-container-name> && docker rm <old-container-name>
   ```

3. **Start with the updated compose**:
   ```bash
   DOCKER_CONFIG=/tmp/docker-config docker compose -f <compose-path> up -d
   ```

4. **Sync BOTH compose file locations** if the app has files in both `/DATA/AppData/<appname>/casaos-compose.yml` AND `/var/lib/casaos/apps/<AppName>/docker-compose.yml`. Apply the same changes to both files.

5. **Restart CasaOS app management** so the dashboard picks up the new title:
   ```bash
   sudo systemctl restart zimaos-app-management
   ```

**Pitfall:** If you skip step 2, the new container will fail with "port is already allocated" because the old container still holds the port. Always stop+rm the old container first.

**Pitfall:** Changing `store_app_id` or `name:` to a different value than the app directory name causes the app to **disappear from the dashboard**. CasaOS uses `store_app_id` as the primary key to locate the app's compose file. If you break this link, the app becomes invisible.

**Pitfall:** The CasaOS dashboard caches app names. If the old name still shows after restart, hard-refresh the browser (Ctrl+Shift+R).
- **Don't guess app structure.** Read the files first. Every app has its own conventions.
- **Port mapping matters.** The container port (e.g., 3000) ≠ host port (e.g., 8099). Test against the host port.
- **Container restart is required** after any server.js change. `docker restart`, not just file save.
- **Test DB may be created during verification.** Clean up temporary test data after confirming functionality.

### Architecture

Build small, self-contained web apps that run on ZimaOS. Pattern: one Python file (FastAPI backend + static serving) + one HTML file (frontend). Accessible from any device on the local network. No Docker build step needed for dev; optional Dockerfile for production.

### Design Rules

**Primary (default) — Blue glass-morphism, system-aware theme:**
- System-aware light/dark via `@media (prefers-color-scheme: dark)`
- Blue palette: primary `#2563EB`, accent `#3B82F6`, dark accent `#1E3A5F`
- Light: `--bg: #F8FAFC`, `--card-glass: rgba(255,255,255,0.75)`
- Dark: `--bg: #0A0E1A`, `--card-glass: rgba(255,255,255,0.06)`
- Mobile-first, safe-area insets, bottom-sheet modals, 44px tap targets
- Smooth transitions: `cubic-bezier(0.25, 0.1, 0.25, 1)`

**Alternative — Stripe Link warm editorial:**
- Warm off-white `#fdf5ef`, serif headlines (Lora), system UI font
- Accent blue `#00579F`, red `#EF4562`, green `#0E6245`
- Use when user explicitly requests warm/editorial/serif style

### FastAPI Backend Template

Mount `StaticFiles` **AFTER** all API routes so endpoints take priority:

```python
from pathlib import Path
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles

FRONTEND_DIR = Path(__file__).parent / "frontend"
app = FastAPI()

# API routes FIRST
@app.get("/api/stats")
def stats(): return {"status": "ok"}

# Static files LAST
if FRONTEND_DIR.exists():
    app.mount("/", StaticFiles(directory=str(FRONTEND_DIR), html=True), name="frontend")

if __name__ == "__main__":
    import uvicorn
    uvicorn.run(app, host="0.0.0.0", port=3001)
```

### CasaOS Dashboard Integration

Two valid deployment patterns:

**Pattern A: `container` type via `docker run` + `casaos.*` labels**
- Simplest to launch
- Auto-detected by `zimaos-app-management`
- NOT clickable in dashboard (missing routing fields)
- Only use for direct `http://<IP>:<port>` access

**Pattern B: `v2app` type via `x-casaos` compose file (RECOMMENDED)**
- Compose file in `/var/lib/casaos/apps/<StoreAppID>/`
- Must include `x-casaos` top-level block with all routing fields
- Clickable tile in CasaOS dashboard immediately
- Survives reboots as managed app

Critical fields in `x-casaos`:
- `hostname: ""` — empty string for universal LAN/VPN/remote access
- `icon: http://<LAN_IP>:<port>/icon.svg` — cosmetic only
- `port_map`, `scheme: http`, `index: /`
- `store_app_id` must match directory name

See `references/casaos-dashboard-debugging.md` for complete "Derzeit nicht unterstützt" diagnosis and fix pipeline.

### Common Pitfalls

| Symptom | Cause | Fix |
|---|---|---|
| PermissionError on `/media/HDD_1TB/` | Root-owned mount | `sudo mkdir -p <path> && sudo chown az-a:users <path>` |
| Frontend 404 | StaticFiles mounted before API routes | Move mount to END of file |
| "Derzeit nicht unterstützt" | Missing `x-casaos` or wrong `hostname` | See dashboard debugging reference |
| Blank page over VPN | `hostname` hardcoded to LAN IP | Set `hostname: ""` |
| Old UI after rebuild | Browser cache (iframe) | Hard refresh `Ctrl+Shift+R` |
| App shows as "Legacy App" | Used `docker run` instead of compose, OR `hostname` is a hardcoded IP (e.g. `172.29.0.1`) instead of `""`, OR `icon` is a URL pointing to the container itself | Always `docker compose up -d` for managed apps. Set `hostname: ""` and use a base64 data-URL for the icon (see below). |

## App Data Inspection (Live, Without Stopping)

Inspect persistent data, configurations, databases, and frontend assets from running CasaOS/ZimaOS apps.

### Locating an App

```bash
# Find compose and data directories
ls -la /var/lib/casaos/apps/<AppName>/
cat /var/lib/casaos/apps/<AppName>/docker-compose.yml | grep -A2 "volumes:"

# Find app code and data
find /DATA/AppData/<appname> -maxdepth 4 -type f 2>/dev/null | head -80
find /DATA/AppData/<appname> -name "*.db" -o -name "*.sqlite" -o -name "*.json" 2>/dev/null
```

Typical paths:
- App code: `/DATA/AppData/<appname>/app/`
- App data/DB: `/DATA/AppData/<appname>/data/`
- Large user data: `/media/HDD_1TB/<AppName>/`

### SQLite Inspection

```bash
# Tables
sqlite3 /pfad/zur/app.db "SELECT name FROM sqlite_master WHERE type='table';"
# Last entries
sqlite3 /pfad/zur/app.db "SELECT * FROM <table> ORDER BY id DESC LIMIT 10;"
```

### JSON Configs

```bash
cat /DATA/AppData/<appname>/data/db.json | python -m json.tool
```

### Frontend-Asset Mining

Many custom ZimaOS apps (e.g., CallKeep, MuslimOS, DailyDose) pack logic in `/app/public/app.js`. Search for hardcoded lists and API endpoints:

```bash
grep -n "const SUNNAH\|const .*KEYS\|const .*LIST\|fetch(" /DATA/AppData/<appname>/app/public/app.js | head -30
```

See `references/callkeep-db-schema.md` and `references/muslimos-data.md` for example schemas of known apps.

### Inspection Pitfalls

- Compose files may be root-protected — use `sudo cat`
- Reading SQLite while running is safe; writes only when designed for it
- Data may live outside `/DATA/AppData/` — always check compose file volumes
- Dashboard name ≠ container name — search filesystem when `docker ps` doesn't match

## Server Audit Workflow

Use `references/server-audit-checklist.md` for a comprehensive audit covering SSH, network, system, Docker, HA, and security.

Quick one-shot audit:
```bash
hostnamectl && cat /etc/os-release && uname -a && free -h && df -h
ip -4 addr show | grep inet && ip route | grep default
docker ps -a && docker version --format '{{.Server.Version}}'
ss -tulpn | grep LISTEN
```

## Persistent Static Website Serving

Serve a static HTML site (or directory) with guaranteed uptime across reboots. Combines a systemd-managed Python HTTP server with Tailscale Funnel for a permanent public URL.

### When to use this

- User has a static HTML file (landing page, investor deck, product page) and wants it **permanently reachable** — not just for this session
- Cloudflare Quick Tunnels (`trycloudflare.com`) are **temporary** (die after hours/days, URL changes every restart) — use this pattern instead when persistence matters
- Tailscale is already running on the server (check: `docker ps --filter "name=tailscale"`)

### Step 1: Create systemd service for the webserver

```bash
# Write service file (use write_file tool, not terminal — avoids foreground-detection block)
# Path: /tmp/<name>.service, then sudo cp to /etc/systemd/system/

[Unit]
Description=<Name> Website Server
After=network.target

[Service]
Type=simple
ExecStart=/usr/bin/python3 -m http.server <PORT> --bind 127.0.0.1
WorkingDirectory=/DATA
Restart=always
RestartSec=5
User=az-a
Group=users

[Install]
WantedBy=multi-user.target
```

```bash
sudo cp /tmp/<name>.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl start <name>
sudo systemctl enable <name>   # auto-start on boot
curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:<PORT>/<file>.html  # verify 200
```

### Step 2: Expose via Tailscale Funnel (permanent public URL)

Tailscale Funnel gives a **stable, permanent public URL** — no trycloudflare.com randomness, no expiry, no account needed beyond your existing Tailscale setup.

**Prerequisites:**
- Tailscale container running with `network_mode: host` (can reach `127.0.0.1:<PORT>`)
- Funnel must be **enabled once** in the Tailscale admin console (per-node setting)

**Check current state:**
```bash
docker exec tailscale tailscale funnel status 2>&1
# "No serve config" → Funnel not yet configured
docker exec tailscale tailscale serve status 2>&1
# "No serve config" → Serve not yet configured
```

**Enable Funnel (one-time manual step):**
```bash
# Attempt to configure serve — this will output an admin URL if Funnel is not yet enabled:
docker exec tailscale sh -c 'tailscale serve --bg --set-path / http://127.0.0.1:<PORT> 2>&1'
# Output will contain: https://login.tailscale.com/f/serve?node=<nodeID>
# User must click this link ONCE to enable Funnel for this node
```

After the user clicks the admin link, re-run the serve command:
```bash
docker exec tailscale sh -c 'tailscale serve --bg --set-path / http://127.0.0.1:<PORT> 2>&1'
# Then enable Funnel:
docker exec tailscale sh -c 'tailscale funnel --bg --set-path / http://127.0.0.1:<PORT> 2>&1'
```

**Result:** The site is available at `https://<tailnet-name>.ts.net/<file>.html` — permanent, stable URL.

**Pitfalls:**
- Funnel enablement is a **manual user action** — the admin console link must be clicked by the Tailscale account owner. The agent cannot automate this step.
- The `tailscale serve` command may time out waiting for the admin action — use `timeout=15` and expect exit code 124 if Funnel is not yet enabled.
- Tailscale container uses `DOCKER_CONFIG` permission warning on every command — this is harmless noise, ignore it.
- If Tailscale is not running, see `references/tailscale-docker.md` for setup.

## Support Files

- `references/cloudflared-quick-tunnel.md` — Temporary public exposure via Cloudflare Quick Tunnel (trycloudflare.com). **Note: these die after hours/days and URL changes every restart. For persistent exposure, use Tailscale Funnel (see "Persistent Static Website Serving" above).**
- `references/tailscale-funnel.md` — Tailscale Funnel setup: permanent public URLs for local services
- `references/tailscale-docker.md` — Tailscale container setup and flag-change recovery
- `references/server-audit-checklist.md` — Full server audit checklist
- `references/casaos-dashboard-debugging.md` — Complete "Derzeit nicht unterstützt" diagnosis and fix
- `references/callkeep-db-schema.md` — Example SQLite schema for CallKeep app
- `references/muslimos-data.md` — Example data structure for MuslimOS app
- `references/callkeep-overdue-query.md` — Example overdue-call query for CallKeep
- `scripts/callkeep-overdue.py` — Script listing overdue/today calls from CallKeep DB
- `references/zeitgeist-architecture.md` — Zeitgeist app: file map, data schema, design system, header-stats pattern, modification checklist
