diff --git a/containers/libreportal/frontend/components/apps/core/css/apps.css b/containers/libreportal/frontend/components/apps/core/css/apps.css
index 1b67208..01fea2c 100644
--- a/containers/libreportal/frontend/components/apps/core/css/apps.css
+++ b/containers/libreportal/frontend/components/apps/core/css/apps.css
@@ -542,6 +542,24 @@
border: 1px solid var(--border-subtle, var(--border-color));
border-radius: 12px;
}
+/* The "not available" variant of the bar: same frame, quieter, since it states a
+ fact rather than offering an action. */
+.instance-family-bar-off {
+ background: transparent;
+ border-style: dashed;
+}
+.instance-family-note {
+ font-size: 13px;
+ color: var(--text-secondary);
+ line-height: 1.45;
+}
+.instance-family-note code {
+ font-size: 12px;
+ padding: 1px 5px;
+ border-radius: 5px;
+ background: rgba(var(--text-rgb), 0.08);
+ color: var(--text-primary);
+}
.instance-family-label {
font-size: 11px;
font-weight: 700;
diff --git a/containers/libreportal/frontend/components/apps/core/js/apps-grid.js b/containers/libreportal/frontend/components/apps/core/js/apps-grid.js
index ab09c95..fd78624 100644
--- a/containers/libreportal/frontend/components/apps/core/js/apps-grid.js
+++ b/containers/libreportal/frontend/components/apps/core/js/apps-grid.js
@@ -331,7 +331,41 @@ Object.assign(AppsManager.prototype, {
const type = isInstance ? myCfg[`CFG_${U(cleanAppName)}_INSTANCE_OF`] : cleanAppName;
const typeCfg = cfgOf(type);
const isCapable = String(typeCfg[`CFG_${U(type)}_MULTI_INSTANCE`]).toLowerCase() === 'true';
- if (!isCapable) return '';
+
+ const typeAppEarly = apps.find(x => slugOf(x) === type);
+ const typeTitleEarly = typeAppEarly ? (typeAppEarly.name || type).split(' - ')[0].trim() : type;
+
+ // Not instanceable: say so where the pills would have been, rather than
+ // rendering nothing. An empty space reads as "this page has no instance
+ // feature", which sends people looking for a setting that isn't missing.
+ //
+ // Where the reason is knowable from config, give it. A pinned host port is
+ // the common blocker and the only one visible client-side: the port rows are
+ // right here, so this mirrors _instanceCheckPortsInstanceable exactly —
+ // skip `disabled` rows (they publish nothing) and `random` ones (already
+ // per-instance). Anything else is left unexplained rather than guessed at,
+ // since the remaining blocker lives in the compose, which the frontend
+ // never sees.
+ if (!isCapable) {
+ const pinned = [];
+ for (let i = 1; i <= 20; i++) {
+ const row = typeCfg[`CFG_${U(type)}_PORT_${i}`];
+ if (!row) continue;
+ const f = String(row).split('|');
+ const ext = (f[2] || '').split(':')[0];
+ if (!ext || ext === 'random' || f[3] === 'disabled') continue;
+ if (!pinned.includes(ext)) pinned.push(ext);
+ }
+ const why = pinned.length
+ ? `${esc(typeTitleEarly)} publishes on fixed host port${pinned.length > 1 ? 's' : ''} `
+ + `${esc(pinned.join(', '))}, which a second copy could not bind.`
+ : `${esc(typeTitleEarly)} isn't enabled for multiple instances.`;
+ return `
+
+ Instances
+ Not available — ${why}
+
`;
+ }
const instances = apps
.map(slugOf)
diff --git a/containers/searxng/searxng.config b/containers/searxng/searxng.config
index 7328ec8..0390281 100755
--- a/containers/searxng/searxng.config
+++ b/containers/searxng/searxng.config
@@ -11,6 +11,11 @@
# HEADSCALE = options : false, local, remote (see general config). e.g false or local,remote
#
CFG_SEARXNG_APP_NAME=searxng
+# MULTI_INSTANCE = if true, this app can run as multiple isolated instances
+# (own data/DB/subdomain/backups) via `libreportal instance create`. Only set on
+# apps whose compose identity (container_name, Traefik routers, backup labels)
+# is instance-safe — see scripts/instance/instance_create.sh.
+CFG_SEARXNG_MULTI_INSTANCE=true
CFG_SEARXNG_BACKUP=false
CFG_SEARXNG_BACKUP_STRATEGY=auto
CFG_SEARXNG_UPDATE_TYPE=auto
diff --git a/containers/vaultwarden/vaultwarden.config b/containers/vaultwarden/vaultwarden.config
index e88c091..77c34d7 100755
--- a/containers/vaultwarden/vaultwarden.config
+++ b/containers/vaultwarden/vaultwarden.config
@@ -12,6 +12,11 @@
# MONITORING = if true, export this app's metrics to Prometheus + Grafana (needs both apps installed)
#
CFG_VAULTWARDEN_APP_NAME=vaultwarden
+# MULTI_INSTANCE = if true, this app can run as multiple isolated instances
+# (own data/DB/subdomain/backups) via `libreportal instance create`. Only set on
+# apps whose compose identity (container_name, Traefik routers, backup labels)
+# is instance-safe — see scripts/instance/instance_create.sh.
+CFG_VAULTWARDEN_MULTI_INSTANCE=true
CFG_VAULTWARDEN_BACKUP=true
CFG_VAULTWARDEN_BACKUP_STRATEGY=auto
CFG_VAULTWARDEN_UPDATE_TYPE=auto
diff --git a/scripts/instance/instance_create.sh b/scripts/instance/instance_create.sh
index 499c0fb..f9a860c 100644
--- a/scripts/instance/instance_create.sh
+++ b/scripts/instance/instance_create.sh
@@ -312,6 +312,8 @@ _instanceRewriteCompose() {
_instanceRewriteTools() {
local type="$1" slug="$2" dir="$3" src_compose="$4"
local d f base
+ local type_u="${type^^}" slug_u="${slug^^}"
+ type_u="${type_u//-/_}"; slug_u="${slug_u//-/_}"
# Container identities as they appear in hook/tool code. The per-token rename
# below is what catches `docker exec -u git gitea-service …`: the docker-verb
@@ -361,6 +363,24 @@ _instanceRewriteTools() {
# names and prose.
sed -i -E "s/(\b(container|container_name)=\")${type}(\")/\1${slug}\3/g" "$f"
+ # Function names carrying the app as a SUFFIX. The prefix rule above
+ # only matches _, so appSetupComposeTags_vaultwarden survived
+ # untouched — and docker_config_setup_data.sh dispatches that hook as
+ # appSetupComposeTags_${app_name}, i.e. ..._vaultwarden_work for an
+ # instance. The clone therefore defined a function nobody calls (under
+ # a name that collides with the base app's), its compose tags were
+ # never filled, and the pre-start guard refused to launch the
+ # instance. Anchored on the () of a definition so only real function
+ # names are touched. Affects 8 apps that ship this hook shape.
+ sed -i -E "s/\b([A-Za-z_][A-Za-z0-9_]*)_${type}(\(\))/\1_${slug}\2/g" "$f"
+
+ # The uppercase tag namespace, mirroring rule 4 of the compose rewrite.
+ # These hooks pass tag NAMES as strings ("VAULTWARDEN_ADMIN_TOKEN_1_TAG"),
+ # which the lowercase renames above cannot see. The cloned compose has
+ # already moved to _..._TAG, so leaving these behind would update
+ # a tag that no longer exists in the file.
+ sed -i -E "s/\b${type_u}_([A-Z0-9_]*_TAG)\b/${slug_u}_\1/g" "$f"
+
# The app's own deployed directory. Hooks that build it from
# "${containers_dir}/..." instead of "$containers_dir$app_name/..."
# read and WRITE the base app's files — adguard's auth adapter edits