Skip to content

Latest commit

 

History

186 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

¡Hola! Packages

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).

Available apps

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.

Layout

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).

manifest.json

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.

Upgrade safety (upgrade)

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.

// manifest.json
"upgrade": {
  "breaking": true,                 // this release migrates/breaks; the operator must confirm before promoting
  "minFromVersion": "1.107.2",      // floor: a deployment must already be at/above this to promote to this version
  "waypoints": ["1.132.3"],         // must be promoted THROUGH these one at a time (no skipping past them)
  "upgradeNotesUrl": "https://…",   // link shown in the promote dialog
  "preUpgradeBackup": "required"    // "required" | "recommended" | "none"
}
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.

Capability contracts (provides / accepts)

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; accepts says whether it does. An app that needs no hooks at all — SQLite, flat-file — still declares accepts: ["backup@1"], with no backup block. 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 forgetting accepts is 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 in compose.yaml — and the install wizard shows the operator what they're agreeing to before it does. This replaces the old consumes: apps-data line, which disclosed the same privilege only to whoever reviewed the bundle.

Backup hooks (backup)

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:/backups and pg_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 preHook failure is fail-closed when the target release declares upgrade.preUpgradeBackup: "required" (the upgrade aborts rather than snapshot a useless dump), and best-effort (warn + continue) otherwise. The postHook always 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.

Push targets (push)

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 push is 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-level minHolaVersion that 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, which push is 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:

  • path is data-root-relative. If compose mounts ${HOLA_APP_DATA}/books:/books, declare books — 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 --list rather than writing somewhere unexpected. CI rejects the obvious cases at PR time.
  • mirror deletes; 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 --delete against an additive target would destroy data.
  • quiesce: stop when the app holds the data open. Calibre-Web caches its metadata.db connection, so replacing that file under a running process leaves it reading a stale inode. A bounce also makes the new data visible.
  • postHook is the no-bounce alternative for apps with a reindex/reconnect endpoint. It runs via docker compose exec in the app's own containers, service must 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.

Workflow

  1. Scaffold: ./bin/create-package.sh <name> creates src/<name>/ in the loose format.
  2. Edit src/<name>/src/compose.yaml + manifest.json (use a prebuilt image; declare named volumes; no ports: host publishing).
  3. Open a PR. Two CI jobs run:
    • verify-packages, per changed package — checks it has package.json, src/compose.yaml and src/manifest.json, and that manifest.json declares an ingress.service naming a real service in compose.yaml.
    • validate-catalog, over every manifest plus catalog.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.mjs then checks the index itself against schemas/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.
  4. Merge to main. CI publishes ghcr.io/try-hola/<name>:<version> (+ :latest) as loose layers and regenerates the root catalog.json index.

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.

Publishing manually

./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.json

The 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

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.

Release channels (pre-releases)

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's package.json, so the registry is the only place it exists. bin/build-catalog.sh lists the tags with oras 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.1rc, 2.0.0-beta.3beta). It is emitted explicitly on every entry, stable included.
  • :latest is never moved for a pre-release (bin/push-oci-package.sh), because :latest is what an unpinned pull resolves to.

Full flow, retention rules and the CATALOG_PRERELEASES modes: docs/release-channels.md.

Keeping upstream images current (Renovate)

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.

GHCR visibility

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.

About

Application packages for ¡Hola!

Resources

Stars

0 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages