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: view, 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.
- App / System. The section heading is a switch when the box has system apps —
the platform's own pieces, which declare
view: systemin their compose (docs/x-compose-app.md). They get their own grid, and their menu withholds Stop and Uninstall (the API refuses both); Restart stays.view: hiddenkeeps an app off the dashboard entirely. With no system app declared, the heading is a plain "App" and nothing else changes. - 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 zip of Compose listings over HTTP, laid out as
casa-img's:https://github.com/Yundera/AppStore/archive/refs/heads/main.zip. Any host serving a zip will do — nothing in Maison is specific to one forge. 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 — see below.
A store reference addresses one app in one store, and is what /store/… carries:
/store/<locator>/-/<apps folder>/<app>
/store/git.example.org/appstore/archive/main.zip/-/Apps/FileBrowser
- The locator is the store's zip URL with the scheme left off (
httpsis implied; writehttp://out in full if you mean it). It need not be a configured source — this is what lets a link show an app from a store the box has never added, and the detail page warns before installing one. Nothing is written to the source list. - The apps folder is named by the link rather than assumed, so a store is free to keep
its apps somewhere other than
Apps/. /store/<app>with no/-/still means "this app, from the merged catalog", exactly as before, and the older/store/<app>?store=<url>form is still read (and rewritten into the grammar above on arrival).
The separator is GitLab's /-/, split on the last occurrence — a GitLab-hosted store's
own archive URL contains one:
/store/git.example.org/group/project/-/archive/main/project-main.zip/-/Apps/FileBrowser
A deep link only fetches and shows an app. Installing is still a click, and that is deliberate: a URL anyone can paste must not be able to install software on a box.
The grammar lives in internal/appstore/ref.go (authority) and web/src/lib/storeref.ts
(the SPA's mirror). The API takes the same reference as ?store= + ?apps_path= alongside
a single-segment app id.
Optional, at the store root, next to category-list.json:
{ "name": "Yundera App Store" }It is what the source list and the unlisted-store warning show. A store without one is named by its URL — deliberately, rather than by a name derived from the URL's shape: "owner/repo" is one forge's path layout, it means nothing for a store served from anywhere else, and it renders two refs of the same repository identically, which is exactly when somebody most needs to know which one they are about to install from.
A store that has never been fetched successfully has no name of its own yet, and is listed by URL until it has.
- 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. - Variables, not rewrites: everything the deployment contributes reaches an app as a
${VAR}its own compose already references —APP_NET,DATA_ROOT,APP_DOMAIN, the rest of.env.app, plus the base vars Maison computes (PUID,PGID,TZ,AppID). They are written into the app's.envand resolved bydocker composeon every up, so adocker compose up -dyou run by hand in the app's folder does exactly what Maison does. Seedocs/app-env.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 |
The variables an app's compose is interpolated with, and host↔container path mapping. It does not touch the compose itself. |
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}"
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. |
PUID / PGID |
1000 |
Ownership applied to app folders. |
TZ |
— | Timezone for the dashboard and installed apps. |
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 are one feature with one engine setting, on one page
(Settings → Backups): the engine and schedule at the top, every backup below.
Make one from an app's Backups tab, or let the nightly run do it; browse and
restore every backup on the box — including those of uninstalled apps — from that
page. Each row says where it is, and a restore comes from wherever it actually is
rather than from whichever engine is selected today, so switching engines never
strands what the previous one wrote.
local(default) writes archives to${DATA_ROOT}/AppData/.backups/<app>/<stamp>, one per app per moment, holding the whole app folder (compose + override +.env- data). On the data disk, so on its own it is a rollback mechanism, not disaster recovery.
- A remote engine — kopia today — sends apps and your files (everything at the
data root except
AppData/) to a repository off the box, nightly, with tiered retention. Your files get their own card on the Backups page, and restore either into a new folder — the safe way to get a few files back — or over the live tree, which takes an undo snapshot first and never touches app data. 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. Seedocs/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