The service that installs and runs apps on a CasaOS host. It is the Docker Compose layer of the system: it keeps the app store catalogues, turns a store entry into a docker-compose.yml, runs Compose against the local Docker daemon, and reports container status, logs and available updates back to the dashboard.
There is no database. An installed app is its compose file plus whatever the Docker daemon reports about it.
Components are not installed on their own. The distribution is installed and upgraded with one command:
-
An app can keep its secrets in a .env. An installed app may carry a .env next to its docker-compose.yml, edited from the dashboard through GET/PUT /v2/app_management/compose/{id}/env: PUT replaces the whole file (an empty body removes it) and re-creates the app so the new values apply. A reference to one of its keys (${KEY}, $KEY, ${KEY:-default}), or to a key the runtime defines ($AppID, ${TZ}, $PUID, ${PGID}), now survives every settings round trip and the App Store update as written, where before the first save or update baked the resolved value into the compose file and every later .env edit was a no-op; the key has to be in .env when the compose is saved, or the reference is baked as a literal. It is kept in environment and every other free-text field (image, command, labels, env_file, x-casaos...) and in the host side of ports and volumes, which come back in the long syntax; a typed field such as cpus, mem_limit or privileged cannot hold one, and the editing load (GET /compose/{id} as YAML) fails with a 500 naming that field instead of serving the resolved value. A .env that defines a key the runtime sets itself (TZ, PUID, PGID, AppID, the environment of the CasaOS process) is refused with a 400 naming the key, since the runtime's value wins over the file anyway. When the compose file does not load, the pull fails or the app fails to start after a .env change, the previous .env is put back together with the previous docker-compose.yml and the app is started from them again.
-
A $ no longer doubles on every save. The settings handler parsed the incoming YAML without interpolation and then re-escaped every $, so a value it had produced itself came back doubled: $2a$12$… became $$2a$$12$$…, then $$$$2a$$$$12$$$$…, and the container received a broken password hash. Parsing with interpolation on makes the round trip idempotent. Fixes CasaOS#1988.
-
Architecture filtering. An app declaring architectures: [] was hidden on every host, because an explicit empty list decodes to an empty slice rather than nil and only nil was treated as "runs anywhere" — which is what the dashboard already assumed. Category counts were computed from the unfiltered catalogue, so on arm hosts they were larger than the number of cards listed under them. Both now agree with the list. Supersedes the draft in #202, which dereferenced that empty list and hardcoded amd64.
-
Releases. The upstream workflow needed IceWhale's secrets and is gone. .github/workflows/release.yml runs the tests and publishes with GoReleaser on a v*.*.* tag using the default GITHUB_TOKEN. Our change to .goreleaser.yaml is one line, release.github.owner, now ReCasaOS, so the archive layout the installer consumes is unchanged.
-
No geo-IP at install time. build/scripts/migration/script.d ran __get_download_domain at top level, curling ipconfig.io/country and then ifconfig.io/country_code to pick a download mirror by country. install.sh runs every script in that directory on every install and every upgrade, so both services were contacted each time, before the script had even decided whether a migration was due. On a current box it is not, though this repository is the one whose list still carries a reachable v0.4.5 entry. The migration tools now come from GitHub unconditionally, and the domain is a constant: it is not an environment knob either, because what it points at is downloaded and run as root without verification.
-
The compose lifecycle test is opt-in. It skipped itself only when no Docker daemon was reachable, which is the wrong question: a CI runner has a daemon and no CasaOS behind it, so the test ran and failed on the missing app store. It now requires CASAOS_INTEGRATION.
-
Backups. POST /v2/app_management/compose/{id}/backup copies an app to an rclone remote -- S3, SFTP, FTP, or anything else rclone speaks -- on demand or on a schedule with a retention (/backup/schedules, /backup/runs, /backup/destinations). None of the transport is new code: this distribution already installs rclone and runs it as a daemon on a unix socket, so the retries, the resume and the incremental comparison are its. What is written here is what rclone cannot know: which paths belong to the app and which of them are data (/var/run/docker.sock is a door, /dev and /proc are kernel interfaces, tmpfs is empty at every start, an anonymous volume has no name to restore it under -- each is named in the manifest with its reason), and how to hold the app still while it is copied, since a database copied while it writes produces a backup that looks fine and does not restore. What is about to be stopped is written down before the first container goes down and erased after the last comes back, and a note still there when the service starts is an app a killed backup left off, started again.
-
Backups with restic. A destination can be switched to restic (PUT /backup/destinations/{name}/restic, BSD-2-Clause): the same runs, schedules, retention and restores, but each run is stored in a restic repository on the destination (folder casaos-restic, reached through the same rclone remote), so a run costs what changed since the last one instead of another full copy, and files keep their owners and permissions. A copy becomes one restic snapshot tagged with the app, the stamp and the path it was asked to land at; a run is the snapshots that share a stamp, and deleting one forgets them and prunes. Opt-in per destination, and reversible: DELETE on the same path sends the next backups back to rclone and deletes nothing, and PUT without a password takes the repository up again. What the destination holds, the stamped copies from before the switch and the repository after a switch back, is listed and restorable together, and a schedule's retention counts them as one set of runs. The repository's password is made on the box, kept in /var/lib/casaos/backups/restic/<name>.key (root only, and not in the box's own backup) and returned once by the call that switches the destination: lose it and nothing can read the repository. Giving the call the password of a repository that is already there attaches the destination to it, which is how backups are reached again on another box.
-
Backups the fleet master can drive and check. POST /backup/schedules/run starts the scheduled backups now, for the couples (app, destination) it is given, and answers which started and which were refused and why (202 if at least one started, 409 if none could, 404 if nothing matched). A schedule now reports last_slot and next_slot (RFC 3339 with the box's offset, so a master in another time zone reads them as they are), a scheduled run records the time it actually began, and GET /operations carries os_upgrade_open: the box is not backing up because an OS upgrade holds it. Data verification (POST/GET /backup/verifications, POST /backup/verifications/{id}/cancel) re-reads the run it is given (destination, app, stamp) from the destination and compares it with what was written, limited to 1 GiB, 100,000 files and one hour -- a bigger backup ends as not_verifiable, which is neither a pass nor a failure. GET/PUT /backup/verification-schedule verifies every few days (1 to 30, at HH:MM) the newest successful run of each scheduled couple, one after another (only this tour picks the run; a verification asked for by hand checks the stamp it is given); off by default, because a verification reads the data back from remote storage (and bills for it on some of them). A destination switched to restic is not verified this way: its verification is the weekly restic check the box already runs. A destination switched back from restic to rclone is not verified by this feature either, because its repository key still exists.
-
A container CasaOS did not install can be identified and removed. GET /v2/app_management/container/{id}/detail answers what the dashboard could not: image, command, restart policy, networks, published ports, host paths, named volumes with their size, environment. DELETE /container/{id} removes it, and removes named volumes only when asked and only when the daemon says nothing else uses them, re-deciding against the daemon at the moment of the request rather than against what the screen was drawn from. A container belonging to a compose project is refused: the app's own uninstall is how a stack goes.
-
An app whose compose file has no x-casaos is a first-class app. Six defects on this distribution began with one written by hand: it stayed grey while every container ran, its status was computed and then dropped by the dashboard's own grid, clicking it opened nothing. The status no longer depends on a catalogue entry, and a web port is inferred from what the containers publish when the file names none -- the main service's, against a short list of ports images serve interfaces on, and nothing rather than a wrong link when none matches.
-
An update never replaces a floating tag with a fixed one. An app tracking develop came back pinned to whatever version the catalogue named, because the decision read the catalogue's tag alone. Both references decide now; a digest is the opposite case, since pull app@sha256:... returns the same image for ever, so a digest-pinned app can only be updated by rewriting it.
-
One container at a time. POST /compose/{id}/container/{cid}/status starts, stops or restarts one service of an app instead of the whole stack, and GET /compose/{id}/container/{cid}/stats samples its CPU and memory as docker stats reports them.
-
Docker labels on a container, from the wizard. The install wizard and an app's settings can set labels on a service (labels of the compose file, or custom_labels of an app file, which POST /convert turns into them). What the user adds or changes is checked on POST /compose and PUT /compose/{id}: a name of 1 to 128 characters of letters, digits and . _ - /; a value of at most 2048 bytes with no control character; at most 50 per service; and none of the names this box or Docker Compose set (casaos, origin, icon, name, desc, host, index, web, protocol, custom_id, show_env, io.casaos.*, com.docker.compose.*), so a label cannot turn a container into a CasaOS v1 app. Labels the app already has, or the store gives it, are not checked again, so a saved app is never refused for what it came with. Labels for a reverse proxy (traefik.*) are allowed: they are why labels were asked for, and what they route is the user's to check. GET /info lists labels under features. Ported from brianewman's CasaOS-AppManagement#213 and CasaOS-UI#259 (Apache-2.0), reworked: the original took any name, in the app file path only, and a label could replace casaos or origin.
-
Resource history and alerts for every container (see docs/stats.md). A sampler measures each container every 5 s and keeps per-minute avg/min/max for 7 days under /var/lib/casaos/stats/app_management. GET /v2/app_management/stats lists the containers (compose services as app:<application>:<service>:<n>, any other container as container:<name>), GET /stats/{id}?range=live|15m|1h|24h|7d returns the series, and GET|PUT /stats/{id}/alerts reads and sets the two thresholds (cpu_high 90 % for 10 min, mem_high 90 % for 5 min). Not measured, because Docker has no source for them: cpu/iowait, pids/fds, and memory/swap on cgroup v2; those series are absent, never zero. A firing and a resolved alert are published as resource:alert-firing and resource:alert-resolved on the message bus (properties item, name, kind, situation, value, minutes, since, source). The sampler's CPU cost has not been measured on a real Docker yet.
-
The module path is github.com/ReCasaOS/CasaOS-AppManagement. The Go module and every import of it moved off github.com/IceWhaleTech, and the shared library is now github.com/ReCasaOS/CasaOS-Common v0.4.22, so logs, stack traces and go version -m on the shipped binary name this distribution rather than upstream; App Store catalogue and CDN icon URLs still point at IceWhale's repositories and are unchanged.
-
Loopback is not an identity (v0.4.49). Any request from 127.0.0.1 skipped the token. Loopback is not this box's services alone: a container on the host network, or any local account, reaches the same addresses. A request skips the token only when it is one of this box's services, which come from loopback with the secret the gateway writes for this boot (/var/run/casaos/internal.secret, readable by root only); the dashboard always had a token. Installing a compose file is root on the host, so this mattered here most.
-
A retention for backups somebody asks for, and a copy of the catalogue (v0.4.50). POST /compose/{id}/backup takes keep: once the backup has landed, older backups of that app at that destination beyond that number are deleted, the same arithmetic a schedule's retention uses. Absent keeps everything, as before. And the App Store's catalogue, the store/main.zip IceWhale builds on CasaOS-AppStore@gh-pages, is fetched from this distribution's nightly copy of it (ReCasaOS/_appstore, the same bytes) when the original stops answering, the pre-2025 _appstore URL an upgraded box may still carry included; the configured URL is not changed, and the original is tried first on every update (corrected in v0.4.53: v0.4.50 mapped a URL no box uses).
-
Every app at once (v0.4.52). GET /compose/updates is what updating everything would do: the apps with an update on offer and, for each, the images the update would write (check=true asks the registries first). POST /compose/updates with a list of ids runs the same update the button runs, one app after another, on this box rather than from the browser, and GET /compose/updates/run says what became of each: updated, current with the reason, failed with the error. One run at a time.
The compose editor in an app's settings is a dashboard feature. The endpoint it saves through, PUT /v2/app_management/compose/{id}, is upstream; what this fork changed behind it is the $ handling and the .env references above.