First catalog apps to exercise core 26.17's app-to-app dependency feature — until now every app was standalone. - mosquitto: MQTT broker, first dependency *provider*. Mandatory auth; per-consumer accounts created by on_install/on_start hooks (scripts/provision-client.sh, scripts/ensure-client.sh) that run inside the broker container via `docker compose exec`. Provider-side password stash so on_start can restore an account after a volume wipe. - zigbee2mqtt: first dependency *consumer*. `requires` mosquitto; MQTT creds wired in from the provisioning hook via ZIGBEE2MQTT_CONFIG_* env. Serial coordinator path as a text setting + devices mapping. Supporting changes: - Bump vendored furtka_manifest.py (26.10-era -> 26.17) so the validator actually validates the `requires` schema instead of ignoring it. - Document `requires`/hooks in apps/README.md (was undocumented), including the three framework gaps building this pair surfaced. - CI now shellchecks app hook scripts (apps/*/scripts/*.sh), not just repo-root scripts/. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
9.1 KiB
Building a Furtka app from a Docker image
A Furtka app is a folder with four files. The reconciler walks /var/lib/furtka/apps/* at boot, validates each manifest, ensures the declared volumes exist, and runs docker compose up -d per app. Filesystem is the only source of truth — no database.
Use apps/fileshare/ as the reference implementation.
Folder layout
apps/<name>/
manifest.json # required — app metadata and user-facing settings
docker-compose.yaml # required — filename is .yaml, not .yml
.env.example # required — keys consumed by docker-compose, with safe defaults
icon.svg # required — referenced by manifest.icon
The folder name must equal manifest.name. The scanner rejects mismatches.
manifest.json
All top-level fields except description_long and settings are required.
{
"name": "myapp",
"display_name": "My App",
"version": "0.1.0",
"description": "One-line summary shown in the app list.",
"description_long": "Longer German prose shown on the app page. Optional.",
"volumes": ["data"],
"ports": [8080],
"icon": "icon.svg",
"settings": [
{
"name": "ADMIN_PASSWORD",
"label": "Passwort",
"description": "Wird beim ersten Start gesetzt.",
"type": "password",
"required": true
}
]
}
Rules enforced by furtka/manifest.py:
volumes— short names, strings. Namespaced tofurtka_<app>_<short>at runtime.ports— integers. Informational only; compose owns the actual port binding.settings[].name— must match^[A-Z_][A-Z0-9_]*$. This name becomes both the env-var key and the form-field ID.settings[].type— one oftext,password,number,path.settings[].required— if true, the install refuses when the value is empty.settings[].default— optional string. Used to pre-fill the form and the bootstrapped.env.
Path-type settings (host bind mounts)
Use "type": "path" when the app should point at an existing folder on the host — media libraries, document archives, photo backups. The value is written to .env like any other setting, and compose consumes it via ${VAR} substitution as a bind mount.
{
"name": "MEDIA_PATH",
"label": "Medienordner",
"description": "Absoluter Pfad zu deinem Medien-Ordner, z.B. /mnt/media.",
"type": "path",
"required": true
}
services:
app:
volumes:
- ${MEDIA_PATH}:/media:ro
The installer (install_from and update_env) refuses values that:
- aren't absolute (must start with
/), - don't exist on the host,
- aren't directories,
- resolve (after
Path.resolve()) into a system-path deny-list:/,/etc,/root,/boot,/proc,/sys,/dev,/bin,/sbin,/usr/bin,/usr/sbin,/var/lib/furtka.
Traversal like /mnt/../etc is caught too — the deny-list check runs on the resolved path.
Path settings sit alongside manifest-declared volumes. Use manifest.volumes for internal state the app owns (databases, caches, config), and path settings for user data the container should mount and — usually — read without owning. Mounting read-only (:ro) is a good default for data the app only consumes.
docker-compose.yaml
- File extension is
.yaml. The compose runner hardcodes this —.ymlwill not be found. - Reference manifest volumes as
furtka_<app>_<short>withexternal: true. The reconciler creates the volume beforecompose up, so compose must not try to manage its lifecycle. - Values from
.envare substituted by compose in the usual${VAR}form. - If the upstream image ships a HEALTHCHECK that misbehaves on Furtka's setup, disable it — a permanently-unhealthy container scares users reading
docker ps. - Pin images to a digest or stable tag when you can.
:latestis acceptable for an MVP but noisy.
Minimal example:
services:
app:
image: ghcr.io/example/myapp:1.2.3
restart: unless-stopped
environment:
- ADMIN_PASSWORD=${ADMIN_PASSWORD}
ports:
- "8080:8080"
volumes:
- furtka_myapp_data:/var/lib/myapp
volumes:
furtka_myapp_data:
external: true
.env.example
One KEY=VALUE per line. Every key declared in manifest.settings should have a line here so the compose file resolves cleanly on first install even before the user opens the form.
Do not use changeme (or any value listed in furtka.installer.PLACEHOLDER_SECRETS) as the default for a required secret. The install step scans the final .env and refuses to finish if a placeholder survives — this is the guardrail that stops us shipping an app with a known password.
For non-secret values (usernames, paths), sensible defaults are fine and go straight into .env on first install.
App-to-app dependencies (requires)
An app that needs another app at runtime (e.g. zigbee2mqtt needs an MQTT
broker) declares it in manifest.json. Installing the consumer pulls in the
provider automatically; the UI shows a confirm dialog first.
{
"name": "zigbee2mqtt",
"requires": [
{
"app": "mosquitto",
"on_install": "scripts/provision-client.sh",
"on_start": "scripts/ensure-client.sh"
}
]
}
app— the provider's name. Must resolve in installed/catalog/bundled apps.on_install/on_start— optional hook scripts, paths relative to the provider's folder (not the consumer's). Seeapps/mosquitto/scripts/for the reference pair.
How hooks run
Both hooks execute inside the provider's container via
docker compose exec -T <provider> sh -s — so they use whatever tools the
provider image ships (mosquitto's hooks call mosquitto_passwd). The
reconciler exports FURTKA_CONSUMER_APP and FURTKA_CONSUMER_VERSION into the
hook's environment so it knows who it's provisioning for.
on_installruns once, while the consumer is being installed, after the provider is up. Its stdout is merged into the consumer's.env: printKEY=VALUElines (keys must match^[A-Z_][A-Z0-9_]*$) and they become env vars the consumer's compose file can${SUBSTITUTE}. The placeholder-secret check re-runs over the merged.env, so a hook returningMQTT_PASS=changemeis refused just like an unedited.env.example.on_startruns on every boot, before the consumer starts. Must be idempotent.
Gotchas (learned building mosquitto + zigbee2mqtt)
on_startis read-only toward the consumer. Its stdout is discarded (unlikeon_install, which merges its stdout into the consumer's.env). It reads consumer state to reconcile provider state; it doesn't mutate the consumer. Core that post-dates 26.17 injects the consumer's stored.envinto the hook asFURTKA_CONSUMER_ENV_<KEY>, soon_startcan re-establish provider-side state (e.g. re-create an account after a volume wipe) with the same credentials the consumer already holds. On 26.17 itself that injection isn't there, so a provider that must survive that gap stashes what it needs aton_installtime on its own volume. mosquitto'sensure-client.shusesFURTKA_CONSUMER_ENV_MQTT_PASSwhen present and falls back to a stash (/mosquitto/data/furtka-clients/) otherwise.- No shared app network (yet). Each app is its own compose project on its
own network, so a consumer can't reach a provider by service name. Publish
the provider's port on the host and hand the consumer
host.docker.internal:<port>(the consumer addsextra_hosts: ["host.docker.internal:host-gateway"]). - No device/serial setting type. A
path-type setting can't point at a/devnode (the validator rejects non-directories and/devis on the deny-list). Use a plaintextsetting for the device path plus adevices:mapping in compose, and document the hardware requirement.
icon.svg
- 64×64 viewBox, no width/height attributes so the UI can scale it.
- Use
fill="currentColor"(andstroke="currentColor") so the icon picks up the current theme instead of baking in a color. - Keep it single-path-ish. These render small in the app grid.
- The icon is inlined into the
/appspage by the defensive SVG sanitiser, which strips<script>,on*attributes, andjavascript:refs and enforces a 16 KB cap. Anything fancier than static paths and shapes will be rejected.
Install and test
From the repo root on a dev box with Furtka installed:
sudo furtka app install ./apps/myapp
furtka app install runs a reconcile as its last step, so the container is up once the command returns. Open the Web UI (http://furtka.local/), fill in the settings form, and confirm the app starts. docker ps should show one container per compose service; docker volume ls should show furtka_myapp_*.
To bundle the app into the ISO, drop the folder into apps/ before iso/build.sh runs — the build tarballs the whole apps/ tree into the image.
Out of scope (for now)
- Sharing volumes between apps. v1 keeps them isolated.
- Auth on the Web UI. The UI itself has a banner about this.
- Automatic updates. User-triggered per-app update is
POST /api/apps/<name>/update. - A network catalog.
furtka app install <name>only resolves bundled apps in/opt/furtka/apps/.