Maison is a lightweight self-hosted dashboard for managing Docker apps on a single host. It is a dashboard-only reimagining of CasaOS: same look, same feel, same app store — but stripped down to the one thing most people actually use it for: a home screen for your apps + a store to install more.
Maison is not CasaOS and shares none of its code at runtime. It mirrors CasaOS's
UX and is 100% compatible with the CasaOS App Store format, so the exact same store
used by casa-img works here unchanged.
It ships as one container: a Go binary with the Svelte UI embedded in it, driving the host Docker engine over the bind-mounted socket. No database, no file manager, no login.
cp .env.example .env # set DATA_HOST_PATH + DOCKER_GID for your host
docker compose up -d --build # dashboard on http://localhost:8080No auth, by design. Maison has no login screen and assumes it sits behind a trusted boundary (LAN, VPN, or an authenticating proxy). Never expose
:8080directly to the internet.
Maison runs in a container, but the apps it installs come up on the host Docker daemon — so their bind-mount sources are resolved on the host, not inside this container. Two env vars keep that straight:
| Env | Meaning | Default |
|---|---|---|
DATA_ROOT |
Where the data folder is mounted inside the container. Maison reads and writes its own files here. | /DATA |
DATA_HOST_PATH |
The host path of that same folder. Written into every installed app's compose, so the host daemon binds the right directory. | = DATA_ROOT |
STATE_DIR |
Where everything Maison owns lives — settings, store cache, and the deployment's .env.app. An in-container path, like DATA_ROOT. Move it to keep Maison's state out of a folder that is also an app folder, or onto another volume. |
${DATA_ROOT}/AppData/maison |
Set DATA_HOST_PATH to wherever the data folder really lives on the host, and bind that
same host path to /DATA:
environment:
DATA_ROOT: "/DATA"
DATA_HOST_PATH: "/opt/maison/DATA" # <- the host directory
volumes:
- type: bind
source: /opt/maison/DATA # <- MUST equal DATA_HOST_PATH
target: /DATAUse a host bind, not a named volume — a bind mount can't point inside another container's named volume.
The README is the overview. These are authoritative and win on any conflict:
| Doc | What it specifies |
|---|---|
docs/app-model.md |
Where an app lives on disk and how its tile state is derived. Start here. |
docs/lifecycle.md |
What install / start / update / save / uninstall / backup / restore actually do, in order, and their failure semantics. |
docs/x-compose-app.md |
Maison's own compose extension: folders, hooks, and the resolved web-UI URL. |
docs/domains.md |
The additional domains apps are published on (sslip.io / nip.io / your own), and the Caddy routes Maison generates for them. |
docs/FEATURE-COMPARISON.md |
Row-by-row scope table vs casa-img. |
docs/backup.md |
Design, not yet implemented. Offsite backup and disaster recovery: the two backup sets, the pluggable engine, scheduling, retention, encryption, and recovery mode. |
Reference implementation: the full CasaOS bundle Maison slims down lives at
D:\workspace\yundera\yundera-root\packages\casa-img(container path/d/workspace/yundera/yundera-root/packages/casa-img). When a CasaOS behaviour is ambiguous, that package is the source of truth. When a Maison behaviour is ambiguous,docs/is.
- Dashboard-first. The app grid + system widgets are the product. Everything else is trimmed.
- Pixel-for-pixel CasaOS UX. Layout, widgets, interactions, and visual design match the CasaOS dashboard (see §4). A CasaOS user should feel no difference on the home screen.
- CasaOS App Store, unchanged. Consume the identical app-store zip
casa-imguses. No new store format, no forked catalog. Multi-store, with auto-update. - Unmanaged-app discovery. Any Compose stack on the host carrying
x-casaosmetadata — even one Maison didn't install — surfaces as a tile. This replaces CasaOS's manual "install a customized app" form. - Single lightweight container. One image, one port, the host Docker socket.
- Zero-config. Boots to a usable dashboard: no setup wizard, no login.
| Excluded | Why |
|---|---|
| File manager / Files app | Maison is dashboard-only. No /DATA browser, no upload/download UI. |
| Authentication / users / login | No login screen, no sessions. Maison assumes a trusted network boundary; the operator owns access control. |
| Multi-user / RBAC | Single implicit operator. No accounts. |
| Global search bar | The dashboard has no search box. |
| Promo / onboarding cards | No getting-started cards on the home screen. |
| Manual "install a customized app" form | Replaced by unmanaged-app auto-discovery (§4.3). |
| Terminal / SSH / hardware tools | No disk/RAID management, network shares, or remote-storage tooling. |
| Being CasaOS | Not a fork you can drop CasaOS plugins into. Compatibility is store format only. |
casa-img is a full CasaOS bundle: a dashboard plus a file manager, user accounts,
disk/storage management, network shares, and remote-storage tooling. Maison keeps only
the dashboard + app-management surface.
| Feature area | casa-img | Maison |
|---|---|---|
| App-grid dashboard (same UX) | ✅ | ✅ |
| App store: browse / install / uninstall / auto-update | ✅ | ✅ |
| Multi-store | ✅ | ✅ |
| Multi-service Compose apps | ✅ | ✅ |
| System widgets (CPU / RAM / storage) | ✅ | ✅ |
| Per-app logs, container stats, live status | ✅ | ✅ |
| i18n / multi-language | ✅ | ✅ |
| Authentication / users | ✅ | ❌ |
| File manager / Files | ✅ | ❌ |
| Global search bar · promo cards | ✅ | ❌ |
| Manual "install customized app" form | ✅ | ❌ → unmanaged-app discovery |
| On-disk app layout | CasaOS nesting | 🔶 flat AppData/<app> (app-model.md) |
| Per-app config edits | rewrites the compose | 🔶 override compose file (§5.1) |
| Disk/RAID mgmt, Samba shares, remote storage | ✅ | ❌ |
Full table: docs/FEATURE-COMPARISON.md.
The dashboard is a 1:1 copy of the CasaOS home screen. Live reference: https://demo.nsl.sh/#/ (the casa-img demo — minus the login page and Files tile, which Maison does not have).
Theming is wallpaper-only (CasaOS has no light/dark toggle); the App Store is a panel, not a page you navigate away to; settings live in a top-bar dropdown.
┌──────────────────────────────────────────────────────────────────────────┐
│ [top bar: brand · social links · settings dropdown] │ ← top toolbar
├───────────────────────┬──────────────────────────────────────────────────┤
│ ┌─────────────────┐ │ │
│ │ 12:28 │ │ App [ + ]│ ← app grid header
│ │ Thu, 9 Jul │ │ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ │
│ └─────────────────┘ │ │ App │ │ App │ │ App │ │ App │ ... │ ← installed apps
│ ┌─────────────────┐ │ │Store │ │ A │ │ B │ │ C │ │
│ │ System status │ │ └──────┘ └──────┘ └──────┘ └──────┘ │
│ │ 12% CPU 0°C │ │ ┌──────┐ ┌──────┐ │
│ │ 9% RAM │ │ │ App │ │ App* │ * = unmanaged (auto-detected)│
│ └─────────────────┘ │ │ D │ │ E │ │
│ ┌─────────────────┐ │ └──────┘ └──────┘ │
│ │ Storage │ │ │
│ │ Healthy │ │ (no global search bar, no promo cards) │
│ │ 12.95/386 GB │ │ │
│ │ ▓░░░░░░░░ │ │ │
│ └─────────────────┘ │ │
│ [ Widget settings › ]│ │
└───────────────────────┴──────────────────────────────────────────────────┘
- Clock/date — large local time + full date.
- System status — two radial gauges: CPU % (with temperature,
0°Cwhen unavailable) and RAM % (with absolute value). Live-updating. - Storage — disk health badge,
Used / Total, usage bar. - Widget settings — toggle which widgets show.
Just the app grid — no global search bar, no promo cards.
- Each tile = icon + name, with a status dot driven by the container health check. Hover reveals Open and a burger (⋯) menu: Open, Settings, Restart, Stop, Start, Uninstall. Tiles are drag-to-reorder.
- A tile is greyed when stopped, shows a
…overlay while a lifecycle op is in flight, and shows two progress bars (image download + stack start) while installing. - Unmanaged apps — any host Compose stack carrying
x-casaosthat Maison didn't install appears automatically, badged unmanaged. Maison reads itsx-casaosfor icon/name/link and can start/stop/restart it, but its config is owned by whatever created it. +menu → add an external link (pin a bookmark tile to any URL).- The App Store tile opens the store panel (§5).
Wallpaper, language (en_us, fr_fr, de_de, zh_cn — one JSON per language),
widget visibility, and the app-store source URLs (multi-store).
- Source: a GitHub zip of Compose listings, identical to
casa-img's:https://github.com/Yundera/AppStore/archive/refs/heads/main.zip. SetAPPSTORE_URLto a comma-separated list for multiple stores. Catalogs are cached under${DATA_ROOT}/AppData/maison/appstoreand refreshed hourly. - App format: standard
docker-compose.yml+ the CasaOSx-casaosblock (title, icon, tagline, category, screenshots, main port/scheme/path). Read and honoured unchanged. - Store UI: category browse, featured/most-popular, in-store search, and an app detail
page (screenshots, description, developer, install). Deep-linkable at
/store/<app>. - Install: writes the app's project to
${DATA_ROOT}/AppData/<app>/and brings it up through the lifecycle pipeline (§6.2). The store'sdocker-compose.ymlis copied byte-for-byte and never modified — customization goes in the override file, which is what lets updates stay clean. - Env / template injection: the CasaOS/
casa-imgvariables resolve as they do upstream —DATA_ROOT,REF_NET,REF_PORT,REF_DOMAIN,REF_SCHEME,REF_SEPARATOR, plus base vars (PUID,PGID,TZ,AppID) and the app's own.env. Exact rules:casa-img/docs/environment-variable-injection.md.
Maison keeps a CasaOS-style config window (ports, env, volumes) but diverges on how edits persist:
- The store's
docker-compose.ymlis never modified. - Edits are written to a separate
docker-compose.override.yml, layered on via Compose override semantics. The running app = base + override. - The override also carries the update reference (which store, which catalog id) — so it survives a base re-copy on update.
One Go binary with the Svelte SPA embedded (//go:embed), talking to the host Docker
daemon. No database: the filesystem and the Docker daemon are the state.
┌──────────────────────── Maison container ─────────────────────────┐
│ Svelte 5 SPA (embedded) ──REST /api + WebSocket /ws──► Go server │
│ ├── system CPU/RAM/disk │
│ ├── apps registry (managed │
│ │ + unmanaged) │
│ ├── appstore fetch/cache/merge │
│ ├── installer install + update │
│ └── stackup ──► docker compose ─┼──► /var/run/docker.sock
│ Serves on one HTTP port (:8080) │ (HOST engine)
└──────────────────────────────────────────────────────────────────────┘
| Package | Role |
|---|---|
internal/server |
chi router: REST /api, WebSocket /ws, SPA catch-all. Also dispatches by Host: — a request for an app's gateway host, while that app is down, gets the launch gate instead of the dashboard. |
internal/apps |
The tile list: reconciles on-disk projects with live Docker state; surfaces unmanaged x-casaos stacks. |
internal/appstore |
Fetches, caches, and merges CasaOS store zips (multi-store). |
internal/installer |
Store install + update, with two-track progress (image pull, stack start). |
internal/stackup |
Every docker compose up goes through here (see below). |
internal/composecmd |
Shells out to the docker compose plugin. |
internal/dockerx |
Docker Engine API: discovery, lifecycle, container event stream. |
internal/envinject |
CasaOS env/template rules + the host-path rewrites. |
internal/xcasaos · internal/xcomposeapp |
The two compose extensions. |
internal/live |
WebSocket hub, channel-multiplexed; sampling only runs while a client is subscribed. |
web/ |
Svelte 5 + Vite. Vite builds straight into internal/ui/dist, which the Go binary embeds. |
Every docker compose up goes through internal/stackup:
ensure folders → pre_up hook → docker compose up -d → post_up hook
Install, start, update, save-config and save-web-UI all land there. Never call
composecmd.Up directly — an app started that way comes up without its declared
directories, and the bug only surfaces on someone's second boot. Full sequences and
failure semantics: docs/lifecycle.md.
One multiplexed WebSocket carries system stats, app status, and per-app logs/stats. Container events, lifecycle transitions, and install progress all rebroadcast the app list, so tiles move on their own. Channels are subscribe-gated: nobody watching, nothing sampled.
# Backend (serves the embedded UI on :8080)
go run ./cmd/maison
go test ./...
# Frontend (Vite on :5173, proxies /api + /ping + /ws to :8080)
npm --prefix web install
npm --prefix web run dev
npm --prefix web run build # → internal/ui/dist (the Go embed dir)
npm --prefix web run check # svelte-checkgo build needs internal/ui/dist to exist or the //go:embed fails — a placeholder
index.html is committed there for exactly that reason. Run the Vite build for a real UI.
The root docker-compose.yml runs Maison alone, which is enough to browse the store and
install ordinary apps. It cannot test SSO-enabled store apps: those need a Caddy router
(to route them by their caddy_* labels) and a Dex OIDC provider. dev/ adds exactly
those, wired the way a production PCS is:
cd dev
cp .env.dev.example .env.dev # set DOCKER_GID + DATA_HOST_PATH
./gen-certs.sh # once
docker compose --env-file .env.dev up -d --buildSee dev/README.md — including the TLS-trust caveat that bites
back-channel OIDC calls.
One container, host Docker socket bind-mounted, one published port, apps on a shared
bridge network. This is the shipped docker-compose.yml:
services:
maison:
image: maison:latest
build: .
container_name: maison
hostname: maison
restart: unless-stopped
ports:
- "8080:8080"
environment:
DATA_ROOT: "/DATA" # in-container mount target
DATA_HOST_PATH: "${DATA_HOST_PATH:-/DATA}" # the SAME folder, host-side
PUID: "1000"
PGID: "1000"
TZ: "${TZ:-UTC}"
# App-store routing template (CasaOS/casa-img compatible). Optional.
REF_NET: "mesh"
REF_PORT: "80"
REF_SCHEME: "http"
REF_DOMAIN: "${DOMAIN:-}"
APPSTORE_URL: "https://github.com/Yundera/AppStore/archive/refs/heads/main.zip"
group_add:
- "${DOCKER_GID:-999}" # the docker socket's group on the host
networks:
- mesh
volumes:
- type: bind # source MUST equal DATA_HOST_PATH
source: ${DATA_HOST_PATH:-/DATA}
target: /DATA
bind:
propagation: rshared
- type: bind
source: /var/run/docker.sock
target: /var/run/docker.sock
networks:
mesh:
driver: bridge
name: mesh| Var | Default | Purpose |
|---|---|---|
DATA_ROOT |
/DATA |
Data folder inside the container. |
DATA_HOST_PATH |
= DATA_ROOT |
That folder's path on the host. |
APPSTORE_URL |
Yundera AppStore zip | Store source(s), comma-separated. |
PROTECTED_APPS |
— | Store ids the user cannot uninstall (e.g. maison,casaos), comma-separated. The tiles still show; only Uninstall is withheld — in the menu and in the API. |
PUID / PGID |
1000 |
Ownership applied to app folders. |
TZ |
— | Timezone for the dashboard and installed apps. |
REF_NET, REF_PORT, REF_SCHEME, REF_DOMAIN, REF_SEPARATOR |
— / - |
Store templating: app network + {app}{sep}{domain} hostnames. |
HTTP_ADDR |
:8080 |
Listen address. |
/var/run/docker.sockis the only privilege Maison needs — it is how apps get installed and run. Mounting it is equivalent to root on the host; run Maison only on hosts you trust it on.DOCKER_GIDmust matchstat -c '%g' /var/run/docker.sock./DATA(bind,rshared) holds app data and the compose projects Maison writes.- State lives at
${DATA_ROOT}/AppData/maison/— settings, store cache, and the deployment's.env.app. It is Maison's own app directory, so everything Maison owns is in one place. It holds nodocker-compose.ymlon a standalone install and therefore renders no tile; a deployment that installs the dashboard's own compose stack here gets a Maison tile, which is intended. - Backups live at
${DATA_ROOT}/AppData/.backups/<app>/<stamp>— one archive per app per moment, holding the whole app folder (compose + override +.env+ data). Make one from an app's Backups tab; browse and restore every archive on the box, including those of uninstalled apps, from Settings → Backups. These are on the data disk, so on their own they are a rollback mechanism, not disaster recovery. - Cloud backup (Settings → Cloud backup) adds a remote engine — kopia today —
that sends apps and your Documents / Downloads / Media to a repository off the
box, nightly, with tiered retention. It streams straight to the repository rather
than staging a copy, so an app occupying most of its own disk can still be backed
up. The engine runs as a container; Maison ships no binary and installs nothing on
the host. Credentials are rendered onto the box out of band — Maison only reads
them. See
docs/backup.md. - Uninstall never deletes. The app folder is moved into
.backups/(or zipped, on request); the data stays put and is one restore away. - Health:
GET /ping→ 200. - TLS / public routing is out of scope — front Maison with a reverse proxy
(Caddy/Traefik/mesh-router), exactly as
casa-imgis fronted.
- Slim-from source of truth:
/d/workspace/yundera/yundera-root/packages/casa-img—Dockerfile(the 8-service bundle),dev/docker-compose.yml(deployment model),conf/app-management/app-management.conf(store URL + paths),s6-overlay/scripts/casa-init.sh(socket-GID trick),docs/environment-variable-injection.md(template rules),CasaOS-UI/(the Vue 2.7 dashboard Maison matches). - Live UX reference: https://demo.nsl.sh/#/
- CasaOS App Store format: https://github.com/Yundera/AppStore · upstream https://github.com/IceWhaleTech/CasaOS-AppStore