Application packages for Hola. Each package is a
Docker Compose app that Hola deploys behind its Traefik front door (apps are reachable
at <app>.<HOLA_BASE_DOMAIN> — no host ports, ingress is Traefik-only).
| App | Description |
|---|---|
| 💰 Actual Budget | Local-first personal finance and budgeting |
| 🖥️ Apache Guacamole | Clientless remote desktop gateway (RDP/VNC/SSH in the browser) |
| 💾 Backrest | Backup orchestrator (restic) with a web UI |
| 📚 Calibre-Web | Browse, read, and download your Calibre ebook library |
| 🍵 Gitea | Self-hosted Git service |
| 🛩️ Hangar | Self-hosted fleet control plane — git repo hygiene & remediation across providers |
| 🏠 Homepage | A highly customizable application dashboard / start page |
| 📷 Immich | Self-hosted photo & video management |
| 🍲 Mealie | Self-hosted recipe manager & meal planner |
| 🔗 n8n | Workflow automation tool |
| 📄 Paperless-ngx | Document management — scan, index, and archive your documents |
| 📅 Postiz | Open-source social media scheduling tool with AI features |
| 🐧 Ubuntu Webtop | A full Ubuntu desktop environment in your browser |
| 📈 Uptime Kuma | Self-hosted uptime monitoring and status pages |
| 🔐 Vaultwarden | Self-hosted, Bitwarden-compatible password manager |
The authoritative index is catalog.json (generated by
bin/build-catalog.sh); this table is a convenience overview.
src/<name>/
├── package.json # name + version + OCI annotations
└── src/
├── compose.yaml # prebuilt image, no host ports, named volumes
└── manifest.json # Hola defaults (ingress port, default env, volumes)
Packages are published to GHCR as loose OCI file layers: each top-level file under
src/<name>/src/ becomes its own layer titled by filename, so the Hola server's
oras pull -o <dir> yields compose.yaml / manifest.json directly (no tarball to unpack).
Per-app metadata the Hola server reads at deploy time. Key fields:
| Field | Required | Purpose |
|---|---|---|
name |
yes | App id (matches the package name). |
ingress.service |
yes | The compose service Traefik routes to and that receives injected SSO/auth env. Must name a service in compose.yaml. Hola attaches this one service to its hola network. |
ingress.port |
yes | The container port that service listens on (Traefik targets it). |
defaultEnv[] |
no | Env vars surfaced in the install wizard (key, value, isSecret, description). |
defaults.ports[] / defaults.volumes[] |
no | Default port/volume intents shown in the wizard. |
auth |
no | SSO integration (native-oidc / forward-auth / native-ldap); drives per-app auth provisioning. |
consumes[] |
no | Cross-app capabilities (e.g. app-registry). |
provides[] |
no | Capability contracts this app performs for others (backup@1). See Capability contracts. |
accepts[] |
no | Capability contracts this app opts in to being a subject of (backup@1). See Capability contracts. |
upgrade |
no | Upgrade-safety metadata — breaking, version-skip guard rails, pre-upgrade backup policy. See Upgrade safety. |
backup |
no | Per-app pre/post-backup hooks for transaction-consistent snapshots (e.g. pg_dump). See Backup hooks. |
push |
no | Directories the app accepts bulk data into via hola app data push. See Push targets. |
ingress.service matters for multi-service apps. Hola attaches exactly one
service to its routing network and injects auth env into it. If you omit
ingress.service, Hola falls back to the service named after the app id, then the
first service — so an app whose web tier is named differently (e.g. Immich's
immich-server, Paperless's webserver) must declare it, or it will be
mis-routed (and its SSO env wired into the wrong container). CI rejects a package
whose ingress.service is missing or doesn't name a real compose service.
The Hola server reads every block with narrow-shape coercion: it keeps only the fields documented below and silently drops anything else (and any malformed value). A typo'd field name is a no-op, not an error — so check the rendered behavior, not just that CI passed.
Hola's semver describes impact on the Hola user, not upstream's numbers. The
upgrade block makes a release's upgrade characteristics machine-enforceable: the
server validates them on a promote, and they're surfaced to the operator before a
risky upgrade. Declare it on the version being upgraded to.
| Field | Type | Effect |
|---|---|---|
breaking |
boolean |
Marks a migrating/breaking release. Surfaced to the operator to confirm before the promote. |
minFromVersion |
string |
Server-enforced floor. Promoting from below it is rejected with an actionable error (upgrade to the floor first). For an app with a documented minimum upgrade origin (e.g. Immich's 1.107.2). |
waypoints[] |
string[] |
Server-enforced. Versions a deployment must pass through one at a time; a promote that would skip past one is rejected and names the next stop. For chains that must be walked step-by-step (Nextcloud one-major-at-a-time; Immich waypoints). |
upgradeNotesUrl |
string |
Release/upgrade notes link rendered in the promote dialog. |
preUpgradeBackup |
"required" | "recommended" | "none" |
required ⇒ Hola always takes a pre-upgrade snapshot before the promote and fails the upgrade if it can't (fail-closed). recommended/none are advisory. |
The skip-guard only fires for a real forward upgrade (target newer than installed);
same-version re-promotes and rollbacks pass through. Only set minFromVersion /
waypoints when upstream genuinely requires it — they block otherwise-valid
upgrades.
A capability contract is a named, versioned relationship where one app performs
a capability on another — backup@1 being the case that forced the model. It has
two roles, and both are declared in the manifest:
| Field | Role | Meaning |
|---|---|---|
provides[] |
provider | This app performs the capability. backup@1 ⇒ it is a backup engine (Backrest). |
accepts[] |
acceptor | This app opts in to being a subject of it. backup@1 ⇒ "back me up". |
// manifest.json — a Postgres-backed app being backed up
"accepts": ["backup@1"],
"backup": { "preHook": { … }, "postHook": { … } }The contract, not the app, is the coupling point: an acceptor never names Backrest, so replacing the backup engine is a catalog change rather than an edit to every manifest in the catalog.
Contract ids are a closed set defined by the server
(packages/shared/src/contracts.ts),
mirrored as an enum in schemas/manifest.schema.json — a contract has to exist
there before it can be declared here. Today: backup@1 (app-provided),
plus auth@1 and push@1, which the platform provides and no app may claim in
provides.
CI (bin/validate-manifest.mjs) enforces the parts a schema can't:
| Rule | |
|---|---|
| error | A backup block with no backup@1 in accepts[] — the app filled in how and never said whether. |
| error | accepts naming auth@1/push@1 without the matching block, which declares participation the app can't deliver. |
| error | provides naming a contract the platform provides, or either field naming a contract that doesn't exist. |
| warn | The app runs a database server and accepts nothing — it will show up as uncovered. |
| warn | The app runs a database server, accepts backup@1, and declares no hooks — the snapshot will copy live database files. |
Warnings don't fail the build; whether an app is backed up is the author's call to make, but it shouldn't be made by accident.
Two rules do most of the work:
- Acceptance is declared, never inferred from the block. The typed block
(
backup) says how an app participates;acceptssays whether it does. An app that needs no hooks at all — SQLite, flat-file — still declaresaccepts: ["backup@1"], with nobackupblock. Without the declaration Hola cannot tell a genuinely-covered app from one nobody ever considered, and the dashboard's coverage view reads it as uncovered rather than fine. Declaring the block and forgettingacceptsis a CI error. - Privilege attaches to the provider role, and the operator consents to it.
backup@1's provider grant is a read-only, identity-mapped mount of every app's data root. Hola injects it because the app declares the role — the bundle cannot and must not mount it incompose.yaml— and the install wizard shows the operator what they're agreeing to before it does. This replaces the oldconsumes: apps-dataline, which disclosed the same privilege only to whoever reviewed the bundle.
A file-level snapshot of a running app (Hola's backup + the pre-upgrade snapshot)
is crash-consistent, not transaction-consistent — fine for most apps and for
SQLite, but a live SQL database can need a quiesce or dump first. Declare hooks and
Hola runs them in the app's own containers (via docker compose exec) around
the snapshot.
// manifest.json
"backup": {
"preHook": { "service": "db", "command": ["sh", "-c", "pg_dump -U postgres app > /backups/dump.sql"] },
"postHook": { "service": "db", "command": ["rm", "-f", "/backups/dump.sql"] }
}| Field | Type | Purpose |
|---|---|---|
preHook |
{ service, command[] } |
Run before the file capture — quiesce or dump (e.g. pg_dump). service must name a compose service; command is exec-form (argv), not a shell string. |
postHook |
{ service, command[] } |
Run after the capture — clean up (e.g. remove the dump). Same shape. |
Rules that make the hooks useful:
- Declare
accepts: ["backup@1"]alongside the block. The block alone doesn't opt the app in — see Capability contracts. CI rejects hooks without the declaration. - Write the dump where the snapshot can see it. The snapshot captures the app's
on-disk data root, so the dump must land inside a path bind-mounted under the
app's data root — e.g. mount
${HOLA_APP_DATA}/backups:/backupsandpg_dump > /backups/dump.sql. The hook runs inside the container, so use the container-side path (Hola does not rewrite${HOLA_APP_DATA}inside hook commands). - Failure handling. A
preHookfailure is fail-closed when the target release declaresupgrade.preUpgradeBackup: "required"(the upgrade aborts rather than snapshot a useless dump), and best-effort (warn + continue) otherwise. ThepostHookalways runs (even if the capture failed) and never fails the upgrade. - Multi-DB apps: point the hook at the database service (
postiz-postgres,immich-postgres, etc.). One hook per block today.
Some apps need data that's too big or too structured to arrive through their own web upload: a Calibre library, a media tree, a document archive to seed. Declare the directories that accept it and operators can push to them by name:
Honoured by Hola 0.10.0 and newer. An older server parses the manifest keys it knows and ignores the rest, so declaring
pushis safe on any host — it just does nothing until the host is upgraded, and the operator gets no signal about that. Say so in the app's README (see calibre-web) rather than assuming it works. A manifest-levelminHolaVersionthat would let the platform enforce this is proposed in try-hola/hola#417; note there that a floor is for features an app can't run without, whichpushis not.
// manifest.json
"push": [
{
"id": "library",
"label": "Calibre library",
"description": "Your Calibre library folder — the one containing metadata.db.",
"path": "books",
"mode": "mirror",
"quiesce": "stop"
}
]hola app data push calibre-web-ab12cd34 --list
hola app data push calibre-web-ab12cd34 library ~/Calibre\ Library --host me@server| Field | Required | Purpose |
|---|---|---|
id |
yes | Stable identifier the CLI takes as an argument. Unique within the app. |
label |
yes | Human-friendly name shown by --list. |
description |
no | What the operator should point at it. |
path |
yes | Directory relative to the app's data root — the host side of a bind under ${HOLA_APP_DATA}, not a container path. |
mode |
no | mirror (rsync --delete) or additive (default, never deletes). |
quiesce |
no | stop (stop the app for the push, start after) or none (default). |
postHook |
no | {service, command[]}, same shape as a backup hook — a reindex/reconnect instead of a bounce. |
Rules that make a push target correct:
pathis data-root-relative. If compose mounts${HOLA_APP_DATA}/books:/books, declarebooks— not/books. The server resolves it against the deployment's data root and drops any target that escapes it (absolute,.., or a symlink pointing out), so a wrong path silently vanishes from--listrather than writing somewhere unexpected. CI rejects the obvious cases at PR time.mirrordeletes; declare it deliberately. It makes the server copy match the operator's exactly, which is right for a directory that genuinely is a replica of something they maintain elsewhere (a Calibre library) and wrong for an inbox. Mode is a property of the target, not a flag the operator picks — a stray--deleteagainst an additive target would destroy data.quiesce: stopwhen the app holds the data open. Calibre-Web caches itsmetadata.dbconnection, so replacing that file under a running process leaves it reading a stale inode. A bounce also makes the new data visible.postHookis the no-bounce alternative for apps with a reindex/reconnect endpoint. It runs viadocker compose execin the app's own containers,servicemust name a real compose service, and it shares the same ~60s budget as a backup hook.
Pushes are one-way — the operator's machine is the source of truth and the app's data root is a replica. Nothing merges back.
- Scaffold:
./bin/create-package.sh <name>createssrc/<name>/in the loose format. - Edit
src/<name>/src/compose.yaml+manifest.json(use a prebuilt image; declare named volumes; noports:host publishing). - Open a PR. Two CI jobs run:
verify-packages, per changed package — checks it haspackage.json,src/compose.yamlandsrc/manifest.json, and thatmanifest.jsondeclares aningress.servicenaming a real service incompose.yaml.validate-catalog, over every manifest pluscatalog.json— the shared inputs (schemas/manifest.schema.json,bin/validate-manifest.mjs) apply to all apps, so a change to either is checked against the whole catalog rather than only the packages it touched. A schema-only PR changes no package at all, which is exactly the case the per-package job can't cover.bin/validate-catalog.mjsthen checks the index itself againstschemas/catalog.schema.json, including the release-channel grammar — which the server enforces by dropping a malformed entry rather than erroring, so it has to fail here.
- Merge to
main. CI publishesghcr.io/try-hola/<name>:<version>(+:latest) as loose layers and regenerates the rootcatalog.jsonindex.
A pre-release version (0.11.0-rc.1) takes a different path — published from the pull
request, :latest untouched, listed on its own channel. See
Release channels.
./bin/push-oci-package.sh <name> ghcr.io/try-hola apps # needs `oras login ghcr.io`
./bin/push-oci-package.sh <name> ghcr.io/try-hola apps none # …publish :<version> only
./bin/build-catalog.sh # regenerate catalog.jsonThe fourth argument is the moving tag: latest (the default) also moves :latest; none
publishes only the immutable :<version>. A pre-release version never moves :latest either way.
catalog.json (generated by bin/build-catalog.sh) is the index the Hola server consumes via
HOLA_CATALOG_URL. The official, hosted copy is:
https://raw.githubusercontent.com/try-hola/apps/main/catalog.json
A fresh Hola install points at this URL by default, so published apps appear in the web catalog.
An app can be listed at more than one version, each tagged with a channel, so an operator can
run hola install <app> --channel rc --as <name> without the rc becoming anyone else's default
(try-hola/hola ADR 0005):
"versions": [
{ "version": "0.10.1", "channel": "stable", "refs": { "oci": "ghcr.io/try-hola/remo:0.10.1" } },
{ "version": "0.11.0-rc.1", "channel": "rc", "refs": { "oci": "ghcr.io/try-hola/remo:0.11.0-rc.1" } }
]The short version of how that gets published:
- Stable comes from
src/<name>/package.json; pre-releases come from the app's GHCR tags. A pre-release bundle is published from its pull request and its version is never merged into main'spackage.json, so the registry is the only place it exists.bin/build-catalog.shlists the tags withoras repo tags. - Retention: the newest stable, plus every pre-release newer than it. An rc retires itself the moment it graduates. To abandon one, delete that version of the GHCR package.
- The channel name is derived from the version: the alphabetic prefix of the first prerelease
identifier, lower-cased (
0.11.0-rc.1→rc,2.0.0-beta.3→beta). It is emitted explicitly on every entry,stableincluded. :latestis never moved for a pre-release (bin/push-oci-package.sh), because:latestis what an unpinned pull resolves to.
Full flow, retention rules and the CATALOG_PRERELEASES modes: docs/release-channels.md.
Catalog entries pin a specific upstream image (tag@sha256:<digest> in
src/<app>/src/compose.yaml). Renovate (config in
renovate.json) watches those pins and opens a PR when an upstream
image publishes a newer tag — so we learn when a catalog entry needs refreshing.
It's notify-only (never auto-merges), because Hola's bundle version is decoupled from
upstream's number: a Renovate PR bumps only the image, and a maintainer must also bump the
Hola bundle version (package.json + manifest.json, describing impact on the Hola
user) and review the manifest's upgrade/backup metadata before merging. On merge, CI
regenerates catalog.json, and Hola servers then surface "update available" for installed
deployments. Renovate runs via the GitHub App (install it once on the org).
Exception — ghcr.io/immich-app/postgres is Renovate-disabled. Immich's Postgres image
is coupled to the immich-server version: its tag encodes the PG major and the exact
VectorChord/pgvecto.rs extension versions, and Immich pins one specific image per server
release in their own docker-compose.yml. It must move only in lockstep with an
immich-server bump (to whatever that release's compose pins), never independently — a
decoupled PG-major bump diverges from Immich's tested config and needs a manual data
migration Immich doesn't support. (Newer tags like 16-* exist on GHCR, but Immich ships
14-* across v2.7.5 and the v3.0.0 RCs.) So when you bump immich-server, also update its
immich-postgres to match Immich's compose for that version.
Newly published GHCR packages are private by default. For the Hola server to pull them without
credentials, set each package's visibility to public once (GitHub → the package → Package
settings → Change visibility). A private package would require the server to oras login with a
GHCR token instead.