Security-first ZimaOS app store for trusted-LAN deployments.
Store URLs:
- v2 (browse and install):
https://zima-store.oss.sqrd-cdn.com/store.json - v1 (update detection):
https://github.com/thundersquared/zima-store/archive/refs/heads/main.zip
Configure repository secret PAGES_DOMAIN with value
zima-store.oss.sqrd-cdn.com. Publish workflow uses it for generated URLs and
Pages CNAME output.
Add the v2 URL as a custom store in ZimaOS. Also register the v1 URL: the
ZimaOS update check resolves installed apps only against v1 .zip stores, by
compose project name, so apps from the v2 store alone are never offered
updates. The ZimaOS 1.7 UI rejects .zip URLs ("Unsupported import method"),
so register it through the API on the device:
curl -s -X POST "http://localhost/v2/app_management/appstore?url=https://github.com/thundersquared/zima-store/archive/refs/heads/main.zip"The v1 store is the repository's main branch archive, so it serves source
definitions as merged. Manual Docker Compose import remains possible from each
source definition.
- Cloudflared: token-managed tunnel, no published port.
- MySpeed: bridge-mode speed-test monitoring on TCP 5216 with scheduled tests and persistent history.
- Jellyfin: LinuxServer bridge-mode media server on HTTP 8097, optional HTTPS 8921, UDP discovery 7359/1900, read-only media, and configurable PUID/PGID ownership.
- Mosquitto: authenticated MQTT on TCP 1883 with first-run credential setup.
- Tailscale: kernel-mode client with persistent state and interactive login.
- Home Assistant: LinuxServer host-mode UI on TCP 8123 with zeroconf/mDNS/UPnP and Bluetooth discovery.
- ESPHome: bridge-mode Device Builder dashboard on TCP 6052 with required login and over-the-air updates to devices with fixed addresses.
- Hermes Agent: gateway plus loopback-only dashboard on TCP 9119.
- TVHeadend: bridge-mode UI and HTSP on TCP 9981 and 9982 with GPU and DVB tuner passthrough.
- Zigbee2MQTT: bridge-mode Zigbee gateway on TCP 9442 with adapter hardware opt-in.
- Proxmox Backup Server: dockurr bridge-mode HTTPS UI on TCP 8007 with persistent config and datastore.
- Scrutiny: omnibus bridge-mode S.M.A.R.T. dashboard on TCP 8080 with bundled collector and InfluxDB, and per-disk passthrough.
- scanservjs: bridge-mode SANE scanner web UI on TCP 8088 with OCR and USB
scanner passthrough, writing scans to
/DATA/Media/Scans. - Papra: rootless bridge-mode document archive on TCP 1221 with its database in
/DATA/AppData/papra, documents in/DATA/Media/Documents, and an ingestion folder on/DATA/Media/Scans. - CUPS: bridge-mode print server on TCP 631 with the Pantum Linux driver, amd64 only.
Every app image uses a pinned manifest-list digest and, where upstream
publishes one, an explicit version tag. Both
amd64 and arm64 are required, except for CUPS, which is amd64 only because
Pantum ships its driver for x86_64 only. Hardware access remains opt-in except for
the documented Home Assistant Bluetooth D-Bus path, TVHeadend GPU/DVB
devices, Scrutiny disk devices, and the scanservjs USB bus. Host networking is used only for
documented discovery or VPN exceptions. WAN exposure remains an
opt-in edit documented in the app metadata.
Definitions assume a trusted LAN. Do not expose app ports directly to the Internet. Put externally reachable services behind an authenticated reverse proxy, VPN, or tunnel. Never commit tunnel tokens, API keys, MQTT passwords, Tailscale state, or Hermes data.
Hermes dashboard access is deliberately bound to host loopback. Use an SSH tunnel or an authenticated Tailscale/Cloudflare path. The dashboard stores API keys and must not be exposed to a LAN without an authentication layer.
TVHeadend starts in bridge mode without DVB devices, GPU devices, host networking, or privileged mode. IPTV, SAT>IP, HDHomeRun, DVB adapters, and multicast discovery require deliberate host-network/device changes.
MySpeed starts without password protection. Keep its HTTP port on a trusted LAN while completing initial configuration and setting supported password protection; put any externally reachable deployment behind an authenticated reverse proxy, VPN, or tunnel. First startup downloads Ookla and LibreSpeed clients over outbound HTTPS, so DNS, GitHub, or speed-test download failures can prevent initialization. Scheduled tests consume WAN bandwidth; choose cron frequency deliberately. Persistent MySpeed data includes speed history, password state, node configuration, and integration or webhook credentials; treat volume backups and migrations as sensitive.
MySpeed drops all Linux capabilities except NET_ADMIN and NET_RAW, which
Ookla requires for interface-bound speed tests. These permit network
administration and raw packet operations; keep MySpeed trusted-LAN-only and do
not add broader capabilities.
Home Assistant uses LinuxServer host networking for zeroconf/mDNS/UPnP and
Bluetooth discovery. Read-only /run/dbus, NET_ADMIN, and NET_RAW enable
Bluetooth when host BlueZ is configured; override the source path with
HOME_ASSISTANT_DBUS_PATH when needed. Host mode exposes port 8123 and any
configured Home Assistant listeners directly on the host, so keep it
trusted-LAN-only. Set HOME_ASSISTANT_PUID and HOME_ASSISTANT_PGID to the
IDs owning the config directory. USB, serial, and other device mappings remain
disabled by default.
ESPHome runs in bridge mode on TCP 6052; keep it trusted-LAN-only. Bridge mode
blocks mDNS, so the dashboard cannot resolve .local device names or discover
new devices to adopt. Set wifi: use_address: (or manual_ip:) on every
device so status checks and over-the-air updates reach it by IP; switching to
host networking restores mDNS but exposes every dashboard listener directly on
the host. ESPHOME_USERNAME defaults to admin and ESPHOME_PASSWORD is
empty, which the dashboard rejects as a half-set pair, so it refuses to start
until a password is set. Clearing both turns login off; do not. The container
runs as root with every capability dropped except DAC_OVERRIDE; the ICMP
status sweep uses unprivileged ping sockets, which Docker enables in
bridge-mode containers, so it needs no NET_RAW. /DATA/AppData/esphome holds
secrets.yaml with Wi-Fi passwords and device API and OTA keys, plus the
PlatformIO toolchains downloaded on first build; treat backups as sensitive.
No USB or serial device is mapped, so flash new boards from another machine
or https://web.esphome.io and update them over the air afterwards.
Jellyfin publishes UDP 7359 client discovery and UDP 1900 SSDP/DLNA discovery for trusted-LAN use. Keep both ports off untrusted networks; UDP 1900 also requires no competing host SSDP service. Port 8921 is published for HTTPS but remains inactive until certificates and Jellyfin HTTPS settings are configured.
Scrutiny uses the omnibus image so the web dashboard, the S.M.A.R.T. collector,
and InfluxDB run in one container. The dashboard has no authentication, so keep
TCP 8080 on a trusted LAN. InfluxDB port 8086 is not published. Only /dev/sda
is passed through by default, with SYS_RAWIO for S.M.A.R.T. access and a
read-only /run/udev; edit the device list to match the host's disks before
installing, since a missing device prevents the container from starting. NVMe
drives need the controller device (/dev/nvme0) plus SYS_ADMIN, which grants
broad host administration rights and stays opt-in. The collector runs once at
startup and then on COLLECTOR_CRON_SCHEDULE (daily at midnight by default).
The InfluxDB volume stores an admin token; treat backups as sensitive.
scanservjs has no authentication, so keep TCP 8088 on a trusted LAN. It keeps
no state besides the scans in /DATA/Media/Scans, which it writes as root with
only DAC_OVERRIDE retained so it can write into a directory owned by another
user. The whole host USB bus (/dev/bus/usb) is passed through for USB
scanners such as the CanoScan LiDE series. That grants raw access to every USB
device on the host, so it is a documented hardware exception; narrow it to the
scanner's /dev/bus/usb/BUS/DEVICE node if its address is stable. Device nodes
created after the container starts are not visible until it restarts, so plug
the scanner in first. Network scanners are not configured. OCR bundles Tesseract
data for ces, chi_sim, deu, eng, fra, ita, nld, pol, por, rus, spa, and tur;
OCR_LANG takes Tesseract codes such as deu+eng, and any other language (for
example ron) needs its .traineddata file bind-mounted into
/usr/share/tesseract-ocr/5/tessdata first, or OCR scans fail.
Papra uses the rootless image and runs as PUID:PGID with every capability
dropped, so /DATA/AppData/papra (with a db subdirectory) and
/DATA/Media/Documents must exist and be owned by those IDs before install.
It refuses to start until AUTH_SECRET is set to a non-default value of at
least 32 characters, and APP_BASE_URL must match the address used in the
browser. Registration stays open until AUTH_IS_REGISTRATION_ENABLED is set
to false; create the first account, then close it. Outgoing email stays in
dry-run mode, so invitations and password resets are not delivered.
Papra OCRs uploads with tesseract.js in the DOCUMENTS_OCR_LANGUAGES languages
(comma separated, eng by default). The image has no Tesseract data, and
tesseract.js only caches language files in Papra's working directory /app,
which is read-only for the app user and holds the web client, so it cannot be
mounted over. Each language is therefore downloaded from cdn.jsdelivr.net on
every OCR run unless /app/<lang>.traineddata is bind-mounted. English is
mounted read-only from /DATA/AppData/papra/tessdata/eng.traineddata; add one
mount per extra language. Papra 26.7.0 exits when a language download fails
(tesseract.js raises it as an uncaught exception), so an unmounted language
without outbound HTTPS turns every OCR run into a container restart. A missing
mounted file still falls back to the download, but
Docker leaves an empty directory at the missing path, which has to be removed
before the file is downloaded there.
CUPS uses ghcr.io/thundersquared/cups-pantum, which bundles the Pantum
1.1.186 driver (P2500W and related models) on Ubuntu 26.04 LTS. That image
publishes only a moving main tag, so the store assigns a counter version. Remote
administration and printer sharing are enabled, so anyone on the LAN can print,
and the admin account can add, change, or delete printers. ADMIN_PASSWORD
must be set or the container refuses to start. CUPS asks remote clients to
switch to TLS before it accepts credentials, but it still accepts the admin
password over plain HTTP, so keep TCP 631 on a trusted LAN. Bridge mode blocks
Bonjour, so add printers by URI (for example socket://PRINTER_IP:9100) and
add the shared queue on clients as ipp://ZIMAOS_IP:631/printers/QUEUE. The
container keeps only the capabilities cupsd needs to bind port 631, switch to
the lp user for filters, and manage its spool and configuration files.
Papra's ingestion folder is enabled and mounted on /DATA/Media/Scans, the
scanservjs output. Papra only imports files inside a subfolder named after an
organization ID and deletes each file once imported, so top-level scans are
left alone. To import scans automatically, create
/DATA/Media/Scans/<organization-id> owned by PUID:PGID and point the
scanservjs scans volume at it. Papra deletes files there after import, so they
also disappear from the scanservjs file list.
GitHub Actions runs the official pinned ZimaOS v2 builder. The generated
dist/ directory is ignored locally and published as the Pages artifact.
Manual Compose import uses the files under Apps/ directly.
Renovate maintains Docker tags and digests plus GitHub Action pins. Routine
updates wait one day. Security updates can bypass that delay. Minor, patch,
and digest updates of the actions listed in renovate.json5 automerge once
their checks pass. Major action updates, app images, and anything not listed
still need a manual merge.
Renovate does not touch x-casaos.version. The ZimaOS client decides whether
an app has an update from the store manifest, and the store build drops any
x-casaos.version that is not valid semver from index.json, which leaves the
app with no detectable update path. scripts/reconcile_versions.py owns that
field instead, and it only ever runs against main. Publish runs it before
building; if main is not reconciled, publish fails without deploying and
dispatches the Reconcile versions workflow, which commits the reconciled values
straight to main; that push starts a fresh publish run. Pull requests never get
reconciled or written to by CI. The deploy gate matters because the v1 store
serves main as-is:
versionmirrors the main image tag, cleaned by the optionalversion_prefixandversion_suffixkeys in the samex-casaosblock, soghcr.io/tailscale/tailscale:v1.102.5publishes as1.102.5andeclipse-mosquitto:2.1.2-alpinepublishes as2.1.2. Both keys are consumed by the reconcile script, not by the store build. A prefix or suffix that no longer matches the tag is ignored and reported bycheck- LinuxServer
tvheadendpublishes no version tag, so it carries a counter version that the same rule increments on every digest change release_notesandupdate_atare regenerated from the image reference that actually shipped, which removes the stale release notes that image-only updates used to leave behind. Release notes always quote the unmodified tag, andupdate_atuses theYYYY-MM-DDform the store metadata contract recommends. Both are only rewritten when the app itself changed, soupdate_atstays an honest "last app update" date
No hand edits are needed. A hand-edited version is normalized back to the image
tag on main after merge, so treat version as derived state: change the image
reference instead. Run uv run scripts/reconcile_versions.py reconcile locally
if you want the metadata to match an edit before it ships. Apps are edited as
round-trip YAML documents, so comments, quoting, and prose formatting are
preserved. The script pins its own dependency, so uv run is the only entry
point needed. check runs in CI, and check-index asserts the built
index.json exposes a semver version for every app.
The main ruleset requires pull requests and does not let GitHub Actions
bypass it, so Reconcile versions pushes with a deploy key instead:
ssh-keygen -t ed25519 -N '' -C zima-store-reconcile -f reconcile_key- Settings → Deploy keys: add
reconcile_key.pubwith write access. - Settings → Secrets and variables → Actions: add the private key as
RECONCILE_DEPLOY_KEY, then delete both local files. - Settings → Rules → Main branch protection: add Deploy keys to the bypass list.
One limitation comes from the client, not the store: an upstream rebuild that
reuses an existing tag and only changes the digest updates the content_hash
but not version, so ZimaOS may not offer it as an update. A new upstream tag
is always visible.
Repository code and store definitions are Apache-2.0. Upstream applications, images, trademarks, and icons retain their respective licenses.