App descriptions are read by people deciding whether they want the app,
not by people maintaining it. Several were spending their last clause on
facts the reader cannot act on and would not recognise — and in Stoat's
case actively talking the app down: "Heavy (16 containers) and does not
federate" is a maintainer's note, not a description.
Eight rewritten, all the same fault:
stoat "Heavy (16 containers) and does not federate", and LiveKit
named as though the reader would know what it is
vikunja "Runs as a single container on SQLite, with no database sidecar"
stalwart "in a single container"
gitea "written in Go", plus "self-hosted Git service" twice in one line
vaultwarden "an alternative implementation of the Bitwarden server API
written in Rust" — says what it is to a developer, not what it
does for you
speedtest "implemented in Javascript"
adguard "resolving blocked domains to a local blackhole address"
matrix "Installs Synapse plus the Element web client"
Deliberately kept, because they change whether the app suits you rather
than merely describing how it is built: Rocket.Chat's free-edition user
cap, Mattermost's unlimited users, Navidrome's Subsonic compatibility
(it tells you which phone apps will work), Stalwart's protocol list, and
Gluetun's provider count.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three apps were instanceable and the rest were silent, so the feature
looked far narrower than it is. Every app has now been checked against
the two rules instance_create.sh enforces, and the answer recorded in
its config rather than left unset.
32 apps are instance-safe and now say so. Six are not, and each says why
in its own words instead of being indistinguishable from an app nobody
had reviewed:
pihole a DNS server must own port 53
unbound a resolver must own its fixed 5335
stalwart a mail server must own 25/465/587/993
traefik must own 443, and one Traefik routes every other app
prometheus node-exporter and cadvisor carry no "prometheus" prefix
stoat pins 7881, and database/redis/rabbit/minio carry no prefix
The first four are genuinely one-per-host: the port is not arbitrary, it
is the protocol. The last two are compose-identity problems and could be
fixed by prefixing those service names, which is a change to make
deliberately rather than in passing.
Recorded as an explicit false with a reason, not left unset, so the next
person reads a decision instead of an absence. The audit was verified not
to pass anything vacuously: every app resolves at least one service name,
so no app reached "eligible" merely because nothing was found to check.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Same sweep as AUTH_PROFILE, run across all 110 key suffixes in the app configs.
CFG_GITEA_ADMIN_EMAIL and CFG_INVIDIOUS_ADMIN_EMAIL: both apps' auth adapters
take an email argument for createUser but never read or persist the config key,
so it sat empty forever. bookstack, mattermost and rocketchat do read theirs;
these two were copies that never got wired.
CFG_CROWDSEC_AUTO_UPDATE: superseded by UPDATE_TYPE (auto|manual), which crowdsec
also declares. The only AUTO_UPDATE readers left are CFG_GIT_AUTO_UPDATE and
CFG_REQUIREMENT_CONFIGS_AUTO_UPDATE, neither of them per-app.
Not removed, because each is a gap in the code rather than a key to delete, and
deleting would cement the bug: CFG_CROWDSEC_HOST_SERVICE (documented as the unit
stop/restart hits, but only HOST_SERVICES is ever read), CFG_CROWDSEC_LAPI_HOST
and CFG_CROWDSEC_BOUNCER_NAME_TRAEFIK (the bouncer name is hardcoded in
crowdsec_install_host.sh, so editing the setting does nothing).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Eleven app configs declared CFG_<APP>_AUTH_PROFILE as a "capability tier for the
WebUI auth tools". Nothing read it — not a shell script, not the frontend, and it
was never emitted into apps.json, so the WebUI could not have acted on it even in
principle.
The job it was meant to do is already done, and done better: authAdapterCanDo
tests `declare -F authAdapter_<app>_<method>`, so what an app can do is derived
from the functions it actually implements. A declared tier is a second source of
truth that can only drift — traefik declared single_password while its adapter
implements setPassword only, and linkding declared nothing at all while shipping
a full multi-user adapter, and neither mismatch had any effect.
Removed the key and its comment from all eleven configs, and replaced the stale
contract note in auth_adapter.sh with what the dispatcher really does.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Answers "should we stop creating an admin/pass on start" with the split the
catalog actually has, rather than one way for everything.
Ten apps need it: adguard, authelia, bookstack, matrix, nextcloud, owncloud,
pihole, rocketchat, stalwart, speedtest and headscale either pass the generated
password into the container or hand it to an install hook that creates the
account. There the password IS the working credential — dropping it would lock
you out. Left alone.
Three do not create an account at all: gitea, invidious and mattermost seed no
user (the first one comes from their own signup flow or the Create Account tool),
so the password minted at install named nothing. The WebUI credentials card
showed a password that could not log in. They now match linkding — an empty,
unslotted ADMIN_PASSWORD the auth adapter fills when the operator makes the first
admin, and keeps in step on later resets. Unslotted because the slot number marks
a value the installer generates.
mattermost's adapter also had linkding's bug: it persists ADMIN_PASSWORD but the
config declared only ADMIN_EMAIL, so the write was a no-op.
WebUI: rocketchat's generated admin password had no field mapping, so the card
could not show it. Added, plus a generic ADMIN_USER entry — six apps record an
admin username the card had no way to display.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both configs defined a second secret in a later section starting the numbering
over, so CFG_GITEA_METRICS_TOKEN_1 and CFG_GITEA_ADMIN_PASSWORD_1 both read
RANDOMIZEDPASSWORD1 — and the replacer generates one value per distinct
placeholder and seds every occurrence, so the two came out identical. Same for
Invidious's HMAC key and admin password. Predates the slot rename.
Currently latent, since neither admin password is consumed by anything, but it
would silently pair a live secret with whatever gets wired to the other key.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Installing rocketchat failed with
invalid IPv4 address: ParseAddr("IP_DATA_2"): unable to parse IP
ipUpdateComposeTags allocates one IP per SERVICE_TAG_N annotation and fills
IP_TAG_i only where SERVICE_TAG_i exists. The four new apps tagged only their
primary service, so every sidecar — matrix's postgres, mattermost's postgres,
rocketchat's mongo, and fifteen of stoat's sixteen — kept a literal IP_DATA_n
in the deployed compose and docker refused to create the container.
Tag every service that carries an ipv4_address, index-aligned with its IP_TAG.
For stoat that also meant moving caddy from SERVICE_TAG_1 to _6 so the indices
line up with the IPs rather than the reading order.
mastodon had the same latent break (IP_TAG_2 and _3 untagged) and is fixed the
same way — it would have failed on first install for the same reason.
SERVICE_TAG carries the compose *key*, not container_name: 'libreportal app
restart <app> <service>' passes it to 'docker compose restart', which only
understands keys.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds the decision half of the app updater. Detection (P2) and the
snapshot-first apply/revert (P3) were already real, but nothing ever
pressed the button — every update waited for a click.
CFG_<APP>_UPDATE_TYPE=auto|manual per app, default auto (33 templates)
CFG_UPDATER_AUTO=true|false master switch, default true
updaterAppPolicy resolves the two the way backupResolveStrategy already
resolves backup strategy: the global switch can only make things more
manual. updaterApplyAuto runs at the end of `updater check` and enqueues
the ordinary updater_apply task for each auto app that has an update —
never applies inline, so an automatic update is the same code path, task
log, History entry and Roll back button as a manual one.
Safety: each attempt stamps its target digest under generated/auto/, so a
build that fails is rolled back and then left alone rather than retried on
every scan; in-flight updater tasks are skipped so scans can't stack.
Tracked end to end: updates.json carries each app's resolved update_type,
History entries carry trigger=manual|auto. The WebUI says whether updates
install themselves, chips only the apps that opted out, labels automatic
history, and — since an auto app's pending update needs no decision — keeps
it off the Overview board's "Needs action" view.
Also fixes artifactApplyAuto enqueueing without --detach: it runs inside
the single-threaded task processor's own poll, so following the new task in
the foreground waits for a task that cannot start until it returns.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Add a `#LIBREPORTAL|<APP>_VERSION_TAG|<current-tag>` sentinel to each app's
anchor (`<slug>-service`) image line, so a `CFG_<APP>_VERSION` config drives
that tag through the existing tagging system — the config becomes the source,
the compose tag is derived (like ports/IPs/domains already are). This is the
"install/pin a specific version" knob.
- Placeholder is the literal current tag (e.g. `31-fpm-alpine`), NOT a `*_DATA`
token: an unset var leaves the line untouched (inert comment, keeps the real
tag) and never trips the up_app stale-tag scanner.
- Only the anchor line is tagged (sidecars mariadb/redis/nginx stay
tracked-by-digest, not user-version-picked). ollama correctly targets
`ollama/ollama`, not the companion open-webui.
- Untagged anchors normalized to `:latest` (semantic no-op) so they're
templatable too. The manager (libreportal) is skipped — it updates via its
release channel, not a docker tag.
32 apps wired. Verified: CFG_NEXTCLOUD_VERSION=32 → `nextcloud:32` (prefix
preserved, idempotent, sidecars untouched); all compose files still valid YAML.
Inert until a version config is set, so no behaviour change on existing installs.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
Tool descriptions were leaking internal vocabulary (Django superuser,
Postgres bcrypt update, htpasswd in protectionauth.yml, gitea admin
user change-password CLI, trusted_domains list, …) and repeating the
label as a full sentence. Beginners don't care, and even experienced
users don't need the CLI name to know what a button does.
Rewrites every tool description to a single short sentence plain
enough that a first-time installer can read it without context.
Conventions applied across the board:
- One sentence, sentence-case
- Plain English: "Set a new password", "Add a new user",
"Permanently remove a user", "List every user"
- "Leave blank to generate one" only where it's actually useful
(password fields), and matches the field placeholder text
- No CLI names, no schema field names, no internal file paths
- Destructive actions stop saying "permanently" twice (the action
label + the confirm modal already cover that)
- Field placeholders harmonised: "Leave blank for random" /
"Leave blank to generate" → consistently "Leave blank to generate"
Touched files (descriptions only — no logic, no fields removed):
containers/adguard/tools/adguard.tools.json
containers/bookstack/tools/bookstack.tools.json
containers/dashy/tools/dashy.tools.json
containers/focalboard/tools/focalboard.tools.json
containers/gitea/tools/gitea.tools.json
containers/gluetun/tools/gluetun.tools.json
containers/invidious/tools/invidious.tools.json
containers/linkding/tools/linkding.tools.json
containers/nextcloud/tools/nextcloud.tools.json
containers/pihole/tools/pihole.tools.json
containers/traefik/tools/traefik.tools.json
Signed-off-by: librelad <librelad@digitalangels.vip>
Finishes the installApp refactor started in d941f59 (Wave A). Every app
whose <app>.sh was either pure boilerplate (Wave B) or boilerplate +
small custom logic (Wave C) now routes through the generic driver in
scripts/app/install/app_install.sh; bespoke logic moved to declarative
hooks in containers/<app>/scripts/<app>_install_hooks.sh.
Net: ~4,000 lines of duplicated 10-step sequence gone. From 31 per-app
.sh files (pre-Wave-A) down to 2 intentional keepers.
DELETED outright (pure boilerplate — driver replaces them identically):
jellyfin, mastodon, focalboard, ipinfo, speedtest, dashy, invidious,
nextcloud, ollama, vaultwarden, pihole
DELETED + hook-extracted (small bespoke step preserved in a hook):
bookstack, moneyapp, owncloud, trilium, searxng, gitea, headscale,
unbound, prometheus, grafana, gluetun, wireguard, jitsimeet, authelia,
traefik, adguard, onlyoffice
KEPT (intentional special cases):
crowdsec — host-app pattern (no docker compose, runs as apt+
systemd via installCrowdsecHost; uninstall/stop/
restart hooks already live in this file and are
invoked by dockerUninstall/Stop/RestartApp directly).
libreportal — WebUI bootstrap. Pre-compose image build + post-install
webuiLibrePortalUpdate + bootstrap-time suppression of
menuShowFinalMessages don't fit the generic flow.
Driver change — scripts/app/install/app_install.sh:
Moved monitoringToggleAppConfig "$app_name" "docker-compose.yml" from
the post-start integrations block into the install body at post-compose
(right after dockerComposeSetupFile, before docker-compose up). The
toggle edits the compose file on disk — running it after start meant
the container had already been brought up with the unmodified compose,
so the metrics endpoint wouldn't reflect CFG_<APP>_MONITORING until
the next restart. Matches the original ordering in every per-app .sh
that used to call it inline.
Hook surface (declare-f-gated, silent no-op when absent):
<slug>_install_pre before any install work
<slug>_install_post_setup after dockerConfigSetupToContainer
<slug>_install_post_compose after dockerComposeSetupFile (+ the
shared monitoring toggle on the compose)
<slug>_install_post_start after dockerComposeUpdateAndStartApp
<slug>_install_message_data echoes extra argv for menuShowFinalMessages
<slug>_install_post very last thing, after the final message
+ the existing _uninstall_pre/_post, _stop_post, _restart_post
Notable extractions:
bookstack — _install_post_start: probe :PORT_1/login until 200/302,
then `bookstack:create-admin` inside the container with
CFG_BOOKSTACK_ADMIN_{EMAIL,PASSWORD}; falls back to the
seeded admin@admin.com on timeout.
adguard — _install_post_start drives the wizard's HTTP API
(POST /control/install/configure) so the admin doesn't
click through five pages, then pins the admin bind back
to 0.0.0.0:3000 (matches the compose mapping) and health
checks. _install_message_data echoes user/password to
menuShowFinalMessages.
authelia — _install_pre requirements; _install_post_compose copies
configuration.yml + users_database.yml, substitutes
theme/domain/host, generates JWT/session/storage secrets,
toggles monitoring on configuration.yml; _install_post_start
argon2-hashes the admin password via the container, writes
users_database.yml, restarts; _install_post echoes creds.
traefik — _install_pre prompts for the LE email if CFG_TRAEFIK_EMAIL
is unset; _install_post_compose copies static + dynamic
configs, wires CFG_TRAEFIK_DASHBOARD_ACCESS (local-only /
domain-only / public), toggles monitoring on traefik.yml,
then traefikUpdateWhitelist + traefikSetupLoginCredentials.
wireguard — _install_pre host-conflict guard (/etc/wireguard/params);
_install_post_compose persists CFG_WIREGUARD_SUBNET,
resolves WG_HOST (domain+traefik → host_setup, else IP),
runs runAppCfg wireguard-ip-forward; _install_post_start
restarts after wg-easy installs its iptables rules.
jitsimeet — _install_post_setup downloads the tagged release zip from
GitHub; _install_post_compose mass-edits the .env and runs
gen-passwords.sh; _install_post_start rewrites nginx
default site to usedport1/2 + restart.
prometheus — _install_post_compose seeds prometheus.yml under
$containers_dir/prometheus/prometheus/; _install_post_start
sets 0777 on storage dirs so the container TSDB can write
regardless of host UID mapping.
grafana — _install_pre requirements; _install_post_start 0777 on
grafana_storage.
gluetun — _install_post_start refreshes the provider snapshot,
reattaches every routed app (the netns container ID is
stale after gluetun gets recreated), then prompts to
onboard any existing apps.
+ the smaller bookstack-shape extractions for owncloud (version scrape),
trilium / searxng (wait-for-first-boot-config), gitea (Prometheus
bearer token sync), headscale / unbound (config copy), moneyapp
(Auth.js AUTH_URL), onlyoffice (compose-resolved user/pass into the
final message).
Manifest + arrays regenerated. Verified end-to-end:
- bash -n on every hook file + the driver: clean
- Each hook file sources cleanly in a subshell, exposes only the
intended functions, flagged lazy-loadable (not eager)
- Smoke-stubbed install run for jellyfin (pure), nextcloud (pure),
bookstack (hooked), crowdsec (kept): correct dispatch in all cases —
deleted apps route to installApp, kept apps still hit their real
function
Signed-off-by: librelad <librelad@digitalangels.vip>
Sweep of every containers/<app>/<app>.sh after the install-side fix that
went into config_file_setup_data.sh — these were the same class of bug:
bare `sudo sed -i` / `sudo docker exec` calls left over from when the
manager carried NOPASSWD:ALL. After the rootless+de-sudo hardening (Model
A, sudoers scoped to LP_HELPERS + LP_SYSTEM only) those calls fail at
runtime, so every per-app routine that uses one would refuse on install
or in its post-install tweak step.
Each call routes through the existing `runFileOp` shim, which picks the
right path per CFG_DOCKER_INSTALL_TYPE (dockerinstall in rootless, manager
in rootful) — same pattern setup_dns.sh / authelia.sh / config_file_setup_data.sh
already use.
Fixed:
gitea.sh:65 — sync GITEA_METRICS_TOKEN into prometheus-scrape.yml
owncloud.sh:88 — fill OWNCLOUD_SETUP_* in the setup-webform html
searxng.sh:87 — flip simple_style: auto → CFG_SEARXNG_THEME
trilium.sh:89 — rewrite trilium-data/config.ini port=
bookstack.sh:139 — bookstack:create-admin via `docker exec`
bookstack.sh:148 — admin@admin.com cleanup via `docker exec ... tinker`
`bash -n` clean on every touched file. Untested live (none of these apps
are installed on the verify VM) but mechanically equivalent to the
already-validated config_file_setup_data.sh fix.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
The hardcoded uid:gid in libreportal.backup.files labels was brittle: matched the
default PUID in the compose, but a PUID change (or new image version) would drift
silently and the next restore would chown to a stale owner. Make it impossible to
drift by letting the engine learn the uid at capture time.
backup_files.sh:
- After a successful tar capture, run `stat -c '%u:%g'` inside the container and
write the result to a <host_subdir>.lp-owner sidecar in staging. The sidecar
rides in the snapshot alongside the captured tree.
- Restore reads it back when the descriptor doesn't pin uid:gid; falls back to
0:0 with a clear notice if missing.
- The 5-field form (with explicit uid:gid) is still supported as an override; it
wins and skips the sidecar write entirely.
Update all 4 current labels to the new 3-field form
"<container>:<container_path>:<host_subdir>" (nextcloud, bookstack, gitea,
owncloud). Engine handles both formats during the transition.
Verified with stubs: 3-field capture writes the sidecar with the discovered
33:33; restore reads it back; 5-field override correctly skips the sidecar
write. backup_files.sh parses.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
Three closeouts in one pass:
1. DEVELOPMENT.md — consolidated hook-conventions table covering all 8 per-app
hook types (tools / update-specifics / compose-tags / webui-refresh / the two
traefik markers / the two network-provider hooks). One place to look instead
of inferring from the codebase.
2. Nextcloud APCu wired alongside Redis: appUpdateSpecifics_nextcloud now sets
memcache.local=\OC\Memcache\APCu too (was deferred from the fpm switch). APCu
= cheap in-process cache; the fpm-alpine image ships the extension. CLI mode
may emit a harmless "no memory cache" notice on `occ` runs — Nextcloud is
graceful, the FPM worker still uses APCu fine.
3. Container-side file-capture rollout to 3 confident cases:
- bookstack: lscr.io/linuxserver/bookstack with PUID=1000 → /config (1000:1000)
- gitea: gitea/gitea with USER_UID=1000 → /data (1000:1000)
- owncloud: owncloud/server (Apache/PHP) → /mnt/data (33:33, www-data)
Snapshots are now complete for these (the dir's excluded from the raw restic
pass and captured live through the container as a tar → libreportal-owned
staging, same proven pattern as Nextcloud). Less-evidenced candidates left
for live verification: linkding, mastodon, jellyfin, trilium, focalboard,
invidious, vaultwarden, headscale-service — each needs its in-container uid
confirmed before labeling (wrong uid won't break backup, but restore would
chown to the wrong owner).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
Each app now carries everything under containers/<app>/: Tools-tab actions in
tools/ (declaration <app>.tools.json + function <app>_<tool_id>.sh) and logic
helpers in scripts/ (e.g. <app>_auth.sh). The container scan live-sources every
.sh under the app (maxdepth 3, prunes only resources/) and webui_tools.sh
auto-merges the .tools.json, so an app is a true drop-in — no central edit, no
array regen.
- Empty the central webui_tools.sh heredoc; all 34 tools across 11 apps now
come from per-app declarations (verified byte-identical to the old output).
- Retire the orphaned mattermost tool scripts to scripts/unused (there is no
containers/mattermost; its install fn already lived in unused).
- Update the dispatch comment/error path, the auth-adapter doc, and
DEVELOPMENT.md to the new convention.
- Regenerate static arrays (files_app.sh no longer lists app/containers/*).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
Verified on a live install: gitea's app.ini sets [database] PATH=/data/gitea/
gitea.db with mount ./data/gitea:/data, so the host path is data/gitea/gitea/
gitea.db. (Created only after gitea's web setup completes; until then live
backup safely falls back to stop.)
focalboard intentionally still omitted — its db lives at /opt/focalboard/data/
focalboard.db inside the container, which the compose does not mount, so it
isn't persisted to the app dir and can't be backed up until that's fixed.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
Every backup-scope app now carries CFG_<APP>_BACKUP_STRATEGY=auto, so the
Backup Strategy dropdown appears in each app's Advanced tab — not just the
DB apps.
To keep it honest, the 'live' option is hidden where it isn't safe:
- apps.json generator emits backup_live_capable per app (from compose backup
labels: a dumpable DB, or a live-safe marker).
- apps-manager filters the live option out of the strategy select when the
current app isn't live-capable, so apps like gitea/focalboard (a DB we don't
yet dump) never offer it.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
The static per-app CFG_<APP>_HOST_NAME is gone. host_setup (the app's
canonical FQDN, feeding the legacy single DOMAINSUBNAME_DATA used by app env
vars, the app URL and trusted-domains) is now derived from the app's primary
Traefik port's subdomain: first recommended port, else first Traefik port;
@/root -> apex, set -> sub.domain, empty -> app-name. Removes HOST_NAME from
all app configs, the config-form field mapping (Hostname), the dead
headscale stub, and wireguard.sh (now uses host_setup). Completes the move to
dynamic per-port subdomain routing.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
Replace the static one-host-per-app model with per-port routers: each
Traefik-managed port carries a subdomain (12-col PORT format) and gets a
DOMAINSUBNAME_TAG_<n> host, so one container can serve unlimited hosts.
tagsProcessorPortSubdomains stamps per-port hosts (subdomain @/empty = apex,
multi-level allowed); tagsProcessorPortRouterBlocks comments out
# TRAEFIK_PORT_<n>_BEGIN/END blocks for non-Traefik ports so unfilled
placeholders never ship (mirrors GLUETUN_OFF). Convert all 27 router apps
(subdomains seeded from HOST_NAME; headscale admin. prefix -> subdomain).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
A free, open, self-hosted app platform (GNU AGPLv3): one-click app deploys,
Traefik reverse proxy with automatic SSL, rootless Docker support, gluetun
VPN routing, and a web dashboard to manage it all.
Free & open forever to self-host; optional paid hosted services fund it.
See PROMISE.md.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>