# App storage & lifecycle model How Maison lays apps out on disk and derives their dashboard state. This model **diverges from CasaOS** on purpose: the on-disk folder is the single source of truth for *what apps exist*, and the live Docker state is the single source of truth for *how each one is doing*. Nothing about an app is kept in a database or a registry file — the filesystem and the Docker daemon **are** the state. > This document is authoritative for the app layout and supersedes the older > `AppData/casaos/apps/` nesting described elsewhere. Maison uses a **flat** > `AppData/` layout. For what Maison *does* to this layout — the install / start / update / save / uninstall sequences, and the `folders` and `hooks` that hang off them — see [`lifecycle.md`](./lifecycle.md). --- ## On-disk layout Every app is one directory directly under the data root: ``` /DATA/AppData// ├── docker-compose.yml # strict copy from the store — never modified ├── docker-compose.override.yml # user edits from the config window (Compose override) ├── .env # prefilled by Maison on create, then user-editable ├── .seed/ # the store's seed tree, copied in at install ├── .icon.png # the app's icon, copied in at install (any image extension) ├── .init/ # markers for init steps that run once └── … # any other files the app needs (configs, seed data, …) ``` `` is the Compose project name and the tile identity. The directory name is what the dashboard shows. ### File roles | File | Origin | Mutated by | Purpose | |------|--------|-----------|---------| | `docker-compose.yml` | **Strict copy from the store listing.** | Never — Maison treats it as read-only. | The pristine app definition. Keeping it byte-for-byte identical to the store is what lets updates stay clean (re-copy on update, overrides survive). | | `docker-compose.override.yml` | Generated from the **per-app config window** (ports, env, volumes, …). | Maison, on every config save — and on every up, for the routes it generates (see [`domains.md`](./domains.md)). | User customizations, layered on top via Compose override semantics. The running stack = `docker-compose.yml` + `docker-compose.override.yml`. | | `.env` | **Prefilled by Maison on create** (PUID/PGID, TZ, `REF_*`, domain, generated secrets, …), then hand-editable. | Maison on create; user thereafter. | Variable substitution for both compose files. | | `.seed/` | Copied from the store app's `seed/` folder at install, refreshed on update. | Maison, on install and update. | The app's initial data tree, mirroring this folder. Written out create-if-absent on every up, so a missing file is restored and an edited one is left alone. Nothing ever writes back into it. See [`x-compose-app.md`](./x-compose-app.md). | | `.icon.` | Copied from the store app's folder at install, refreshed on update — the file the compose's `icon:` names beside itself, or a plain `icon.`; downloaded only when the compose names an absolute URL instead (see [Assets](./x-compose-app.md#assets)). | Maison, on install and update. | The tile's icon, so the dashboard renders an installed app from disk rather than from anyone's server — it keeps its icon offline, and after the store repo moves or disappears. The app list points every managed tile at `/api/apps//icon`; an app with no copy falls back to the `icon:` value in its compose when that is a URL. Delete the file and it is taken again at the next boot from whatever the app itself still has — a URL to download, or an image file in its own folder — and otherwise at the app's next update, which is the next time the store's copy is at hand. A compose dropped into an app folder by hand beside an `icon.png` gets a tile the same way. | | `.init/` | Written by Maison when a `when: once` init step succeeds. | Maison. | One empty file per completed step, so a seeder that must not run twice does not. It lives here rather than in Maison's own state so that it travels with the app's backup — an app restored from an archive does not re-seed a database it already has. | | everything else | The app (bind-mount targets, config files, databases, …). | The app at runtime, and Maison's seed/files declarations on first write. | User data. **Never** deleted by Maison (see uninstall). | The stack is always brought up from this directory as `docker compose -f docker-compose.yml -f docker-compose.override.yml … up`, with `.env` resolved from the same folder — so what runs is exactly what is on disk. ### Update reference (in the override's `x-compose-app`) On install, Maison records **where the app came from** so it can later pull a fresher `docker-compose.yml`. The reference lives in the override's `x-compose-app` block (so it survives base re-copies, and the strict base stays byte-identical to the store): ```yaml # docker-compose.override.yml x-compose-app: store-ref: github.com/Yundera/AppStore/archive/refs/heads/main.zip/-/catalog/apps/jellyfin ``` One key, holding the whole reference: `/-//`. It is the same grammar the store's own deep links use (`internal/appstore/ref.go`, which is the authority), so what a person copies out of the store's address bar is exactly what they can paste into the Update tab to point the app somewhere else. The scheme is implied — `https` unless written out — and the apps folder is part of the reference rather than a constant, so an update resolves to the same store *and* the same folder the install came from. An app installed before the single-locator form recorded three fields instead: ```yaml x-compose-app: store: https://github.com/Yundera/AppStore/archive/refs/heads/main.zip store-app-id: jellyfin store-apps-path: catalog/apps # only when it was not the default Apps/ ``` Those are still **read** when `store-ref` is absent, so such an app keeps updating from where it came from. Nothing writes them any more: the next install or retarget replaces them with `store-ref` and removes them, because two records of where an app updates from — one of them stale after a retarget — is only ever noticed once it has sent an app back to the store it was moved off. The same block carries the manifest of the **Caddy routes Maison generates** to publish the app on the deployment's additional domains (`generated-routes`) — the record of which label keys are Maison's to rewrite, so regenerating them can never touch the operator's. See [`docs/domains.md`](./domains.md). The per-app **Update** tab uses this to: 1. fetch the store's current listing for the app named by `store-ref`, and 2. compare it byte-for-byte with the installed `docker-compose.yml`. The comparison is that simple only because the base is the store's bytes and nothing else. Maison once transformed the store's file on the way in, so both sides of this comparison had to be transformed identically for it to mean anything. When they differ, **Update now** overwrites the strict base with the store's version and runs `docker compose up -d` (base + override). The override and `.env` are never touched. Apps with no recorded reference (installed before this feature, or unmanaged stacks) simply report "no update reference". The reference is **editable** from that tab (`PUT /api/apps/{id}/update/ref`), which is how an app follows a different store — a fork, a lab store, a pinned tag — without being reinstalled. The reference is resolved against its store *before* it is recorded, so a locator that names nothing fails in the box it was typed into rather than as a "couldn't check" later; and because nothing Docker can see changes, the stack is left running. An app with no reference at all can be given one this way, including one Maison merely discovered. What that buys is real and so is the risk: the next update overwrites the app's `docker-compose.yml` with whatever the new reference names, so pointing an app at a *different app* replaces it in place, over its existing volumes. The update takes a rollback point first (see [`lifecycle.md`](./lifecycle.md)), which bounds the damage but does not undo the choice. Settings → Updates lists the apps with no reference together, and offers each the store app it most plausibly came from — by the project name an install of it would have created and by its main service's image. It is a suggestion and nothing more: the operator confirms it per app, it goes through the same `PUT`, and there is deliberately no way to link them all at once. See [`lifecycle.md`](./lifecycle.md) § *Update all*. ### Editing the override — form and YAML The settings window splits the two compose files by what you can *do* to them: | Tab | View | What it is | |---|---|---| | **Override** | **Form** | Field-by-field editor (image, restart, ports, volumes, environment; devices, cap_add, command, privileged, limits under *Advanced*). Each field shows the store's value as a ghost placeholder and is marked when overridden; clearing a field resets it to the store's. | | **Override** | **YAML** | The `docker-compose.override.yml` itself. Anything the form can't express belongs here. | | **Compose** | **Store** | The strict `docker-compose.yml` as shipped. Read-only — Maison never modifies it. | | **Compose** | **Effective** | `docker compose config` over base + override: the merged, interpolated project. Read-only; this is what actually runs, and the view that answers "what did my override actually *do*" — which of the store's values survived, and which yours replaced. | Form and YAML are two views of the same file and either can save it. The form **patches the override's YAML node tree** rather than regenerating it, so comments, key order, and keys it doesn't model (`x-compose-app`, `healthcheck`, `depends_on`, …) survive a save untouched. **Compose's merge rules are not uniform, and the form speaks them:** - **Scalars** (`image`, `restart`, …) — the override replaces the base. - **Sequences** (`ports`, `volumes`, `devices`, `cap_add`) — the override is **appended** to the base, not substituted for it. A form that merely *listed* the ports you want would therefore keep publishing the store's as well. So: when the form's list only adds to the store's, it writes just the extras; when it **edits or removes** one of the store's, it writes the whole list under Compose's `!override` tag, which replaces the base's outright. - **Mappings** (`environment`) — merged key by key. The form writes only the variables that differ from the store's. **Removing** one of the store's variables can't be expressed by a key merge, so that too falls back to `!override`. `!override` requires Docker Compose **v2.24.4+**. A construct the form can't represent faithfully — a long-syntax port, a list-form `command`, a node tagged by hand — is shown read-only ("edit in the YAML view") and is **never rewritten** by a form save. Whether a field is editable is recomputed from the files on every save, never trusted from the client. Every save, from either view, is **validated first** (`docker compose config` over base + candidate). An override Compose won't parse is rejected before it is written, so a typo can't leave an app that no longer comes up. --- ## State model — the folder and Docker together Maison never invents state. A tile's existence comes from the **folder**; a tile's appearance comes from **Docker**. ### 1. Existence — driven by the folder ``` folder present at /DATA/AppData/ ≡ an app tile in Maison no folder ≡ no tile ``` Create a folder (even by hand) → the app appears. Remove/rename it → the app disappears. There is nothing else to register. ### 2. Appearance — driven by the live Docker state For each existing app, the tile reflects what Docker reports for that project: | Docker state | Tile appearance | Interaction | |---|---|---| | **No live stack** (folder exists, stack not started / fully down) | **Greyed** icon | Burger menu available (Start, Settings, Uninstall, …). Not clickable to "open". | | **Operation in progress** (up / down / restart / pull mid-flight) | Greyed icon with a **`…` overlay** | **No burger menu** while the operation runs — the tile is busy. | | **Install / uninstall in progress** | Greyed icon with **one progress bar**, coloured by the step running: blue (downloading), green (starting), red (uninstalling) | Same as busy: no burger menu, not clickable. The `…` overlay gives way to the bar. | | **Live stack** (running) | **Full-colour, clickable** icon | Click opens the web UI; burger menu available. | ### 3. Health dot — driven by the Docker health check A small dot in the **top-left** of the tile reflects the container health check: | Dot | Meaning | |---|---| | 🟢 **Green** | Health check passing. | | 🟠 **Orange** | Health check failing / unhealthy (or still `starting`). | The dot only appears when Docker reports a health check for the stack; a stack with no health check has no dot. ### 4. Which grid — driven by the app's `view` Existence and appearance say *whether* a tile is drawn; the app's `x-compose-app` `view` says *where*. `system` puts it in the dashboard's System grid — a category, with no behaviour attached (stop, uninstall and backup are the app's own `lifecycle` and `backup.skip` declarations); `service` puts it in the Services grid, which is also where an app that says nothing lands when it declares no web UI; anything else lands in the ordinary grid. There is no way to have no tile: an app Maison manages is always shown somewhere. [`x-compose-app.md`](./x-compose-app.md) is authoritative for it. --- ## Backups live in Maison's own folder Every archive of every app sits under one directory inside Maison's app folder, one sub-directory per app: ``` /DATA/AppData// the live app /DATA/AppData/maison/.backups/// a folder archive /DATA/AppData/maison/.backups//.zip a compressed archive /DATA/AppData/maison/.backups//.staging-/ a snapshot still being taken ``` `` is `YYYY-MM-DD_HHMMSS` — seconds included, so the name is the whole identity and two archives of one app can never collide. Anything in that directory that does not parse as a stamp (a staging folder, a `.partial` zip, a stray file) is **not an archive**: it is never listed and never restorable, which is what makes an interrupted backup inert rather than dangerous. Two properties of the location are load-bearing: - **The nesting hides it.** `managedDirs` reads the top level of `AppData` and never descends, so an archive tree one level down cannot be mistaken for an app whatever it is called. The leading dot is not what does the hiding — a dotted name could not be an app anyway (below) — it is kept because a dot-name under a state directory reads as "not yours to touch". - **It is inside `AppData`, so archiving is a rename.** Same filesystem means `os.Rename`, which is instantaneous no matter how much data the folder holds. This is why the path is spelled out against `AppData` rather than built from `STATE_DIR`: `STATE_DIR` may point at another volume, and following it there would silently turn every uninstall into a full copy. The cost of the nesting is that **Maison's own folder contains the archives of every app, including its own.** That is handled where it matters, not by a special case for one app name: - Backing up the app whose folder holds the tree **excludes the tree** — the same mechanism an app uses to declare a derived folder (`x-compose-app`'s `backup.exclude`), applied by Maison rather than declared. Without it the staging directory would be inside the folder being mirrored. - **Restoring or uninstalling that app is refused** up front. Both would rename the folder into a directory inside itself, and an in-place restore — which deletes what the backup does not have — would delete every archive on the box. A previous version kept the tree at `/DATA/AppData/.backups/`. Boxes that have one there keep it: it is **not migrated and never read again**, so archives taken before the move stop appearing in the UI and the directory can be deleted by hand. An archive carries the **whole app folder** — `docker-compose.yml`, the override, `.env` and the data — so it restores into a working app on its own, without the store. There is deliberately **no distinction between an archive made by an uninstall and one made on demand.** A backup is a backup, whatever created it; both are listed, restored and deleted the same way. --- ## Uninstall = archive (never delete) Maison **never removes user data.** Uninstalling an app **moves** its folder into the backups tree: ``` /DATA/AppData/ ↓ uninstall — local engine /DATA/AppData/maison/.backups//2026-07-10_153045 ↓ uninstall — remote engine a snapshot in the repository; nothing is left on this disk ``` - **An uninstall is a backup through the default engine**, and where it lands follows that setting like any other backup (`backup.md` §Uninstalling an app). The stack is stopped, backed up, and only then are the containers and the folder removed. - On the **local** engine the backup is a rename of the directory, exactly as above — the bytes on disk are untouched and only the path changes, which is what keeps an uninstall instant at any size. - On a **remote** engine the folder is uploaded and then deleted, so an uninstalled app's data survives losing this disk. Nothing is left in the local archive tree. - **Zip is an option, and a local-engine one.** When enabled, the folder is compressed to `.zip` instead of a plain move. Default is a plain move (fast, no copy). A remote engine ignores it — a zip defeats deduplication — and the dialog hides it. - **It runs detached, with progress on the tile.** Confirming the dialog only *starts* the uninstall; the dialog closes immediately and the tile carries the same progress bar an install shows, in red (see `lifecycle.md`). An upload, or a zipped archive of a large app, runs for minutes and nothing waits on it. - The app vanishes from the grid — its folder is gone — but its data is one restore away, from the store's install dialog or from Settings → Backups. --- ## Backup = archive without uninstalling The same archive, made while the app stays installed. It is the only operation that must stop a running app, and it is built to stop it for as little time as possible: ``` copy app up full mirror into .staging- (no downtime) stop ─────────────────────────────────────────────── downtime starts sync app down only what changed during the copy start ─────────────────────────────────────────────── downtime ends compress app up zip the snapshot, then delete it (zip only) ``` Downtime is proportional to what the app **wrote during the first pass**, not to how big it is — a 40 GB app whose data is mostly at rest is down for seconds. The result is consistent either way: every byte in the snapshot was read either before the app touched it again, or while the app was down. A backup while the app is *already* stopped skips the stop and the restart entirely. Two guards, because a backup is the one feature that can hurt the box it protects: - **Free space is checked before any bytes move** — the app folder's measured size plus headroom (×1.1 for a folder archive, ×2 for a zip, which needs the snapshot and the zip on disk at once). Filling the data disk does not just fail the backup; every other app starts failing writes. - **The restart is deferred**, so a failure anywhere after the stop still brings the app back up. An app left down is worse than a missing backup. --- ## Restore = the swap Restoring over a live app **archives what is there first**: ``` 1. stop the app (if it was running) 2. rename AppData/ → maison/.backups// ← instant, costs nothing 3. put the chosen archive back as AppData/ 4. start the app (if it was running) ``` Step 2 is what makes a mis-clicked restore undoable, and it is free: a rename inside the same tree. It also balances the books — a *folder* archive is consumed by step 3 (renamed back), and step 2 has just created one in its place, so the archive count does not drop. A *zip* is extracted rather than moved, so it survives and can be restored again. Restoring an app with no live folder — an uninstalled one, reached from Settings → Backups — is the same path with steps 1, 2 and 4 having nothing to do. Once the folder lands, the app has a tile again. > **Scope.** Local archives are on the same disk as the apps. They cover > a bad update, a broken config or a regretted uninstall — **not** a failed disk. > On their own they are a rollback mechanism, not disaster recovery. > > Surviving the loss of the disk needs a **remote engine** — the engine is a setting on > Settings → Backups — which sends apps *and* the user-data set to a repository off the > box. Both tiers > coexist: a backup is listed once, with a badge saying where it actually is, and a > restore comes from whichever tier holds it — preferring the local one, because > that restore is a rename. See [`backup.md`](./backup.md). --- ## Install from backup = uninstall, inverted Archives are not write-only: the store reads them back. Clicking **Install** on a store app first asks the server for that app's archives (`GET /api/store/{id}/backups`). With none — the common case — it installs straight away, one click as before. With some, it offers them: ``` ┌──────────────────────────────────┐ │ ▸ Fresh install │ │ │ │ RESTORE FROM BACKUP │ │ ▸ 2026-07-10 15:30 folder │ │ ▸ 2026-06-02 09:00 zip · 78.7 MB │ └──────────────────────────────────┘ ``` Picking one posts `{"from_backup": ""}` to the install endpoint, and the install becomes: 1. **restore** the archive as `AppData//` — a folder archive is *renamed* back (no copy, and the archive is consumed); a zip is *extracted* (a copy, so the zip survives and can be restored again), 2. then run the **ordinary install** on top of it. Nothing about step 2 is special-cased, because the install is already non-destructive over what it finds: it overwrites `docker-compose.yml` with the store's current version (the strict base is meant to be replaceable) but **never clobbers an existing `.env`**, and never touches app data. So the app comes back with its old data and its old variables, on a fresh app definition. The project name is resolved **server-side** (`Installer.ProjectFor`): a store id is `Dufs`, but its compose project — and therefore its backup directory — is `dufs`. The client cannot derive this, since the project name may come from the compose file's own `name:`. This path **refuses to overwrite a live app** (`apps.RestoreBackup` errors when `AppData//` is already present), because an install-from-backup is meant to land on a clean slate. To put an archive back over an app that is still installed, use the restore in the app's **Backups** tab — which does the swap above, archiving the current state on the way. Same archives, two entry points, and neither can destroy the data it is about to replace. --- ## An app's name is a compose project name An app's folder name **is** its Compose project name, so only a name Compose would accept as one can be an app: lowercase letters, digits, `-` and `_`, starting with a letter or digit. Anything else directly under `AppData/` — a dotted scratch folder such as `.staging-`, say — is not an app, whatever it holds, and never gets a tile. The installer derives every app id this way, so it never creates such a folder itself. This is a consequence of what an app *is*, not a hiding feature: there is no way to take a real app off the dashboard. The same rule is the traversal guard for every path Maison builds from an app name (`ValidProjectName`). Containers no app accounts for — a compose project with no Maison folder and no recognised metadata, or a container started with `docker run` — get no tile either, because they are not apps. Settings › Resources › Apps lists them, read-only, so the page still accounts for every container on the host. --- ## Summary | Concern | Source of truth | |---|---| | Which apps exist | Presence of `AppData//` (a valid compose project name) | | App definition | `docker-compose.yml` (strict store copy) + `docker-compose.override.yml` (user edits) | | Variables | `.env` (prefilled on create) | | Running / stopped / busy / clickable | Live Docker state | | Health dot | Docker health check | | Which grid (app / services / system) | The app's `x-compose-app` `view`, else whether it declares a web UI | | Uninstall | Move to `maison/.backups//` (optionally `.zip`) — data never deleted | | Backup | Two-pass copy into `maison/.backups//`; the app is down only for the delta pass | | Restore | Archive the current folder, then put the chosen one back — always reversible | | Install from backup | Restore an archive as `AppData//`, then install over it (keeps its `.env` + data) | | Where backups live | `AppData/maison/.backups//[.zip]` | | Containers outside any app | Listed read-only in Settings › Resources › Apps |