diff --git a/neode-ui/src/views/Dashboard.vue b/neode-ui/src/views/Dashboard.vue
index a0e39f6f..16b0716e 100644
--- a/neode-ui/src/views/Dashboard.vue
+++ b/neode-ui/src/views/Dashboard.vue
@@ -75,9 +75,6 @@
-
-
-
@@ -118,6 +115,9 @@
+
+
+
diff --git a/scripts/container-specs.sh b/scripts/container-specs.sh
new file mode 100755
index 00000000..e20ea679
--- /dev/null
+++ b/scripts/container-specs.sh
@@ -0,0 +1,579 @@
+#!/bin/bash
+# Container specification registry — SINGLE SOURCE OF TRUTH
+# Every container's exact creation spec lives here.
+# Sourced by reconcile-containers.sh, first-boot-containers.sh, deploy scripts.
+#
+# Usage:
+# source container-specs.sh
+# load_spec "bitcoin-knots" # Sets SPEC_* variables
+# all_specs # Returns ordered list of all containers
+
+[ -n "${_CONTAINER_SPECS_LOADED:-}" ] && return 0
+_CONTAINER_SPECS_LOADED=1
+
+# Source image versions
+for f in /opt/archipelago/image-versions.sh \
+ "$(dirname "${BASH_SOURCE[0]}")/image-versions.sh" \
+ "$(dirname "${BASH_SOURCE[0]}")/../image-versions.sh"; do
+ [ -f "$f" ] && { source "$f"; break; }
+done
+
+# Source common utilities (mem_limit)
+for f in "$(dirname "${BASH_SOURCE[0]}")/lib/common.sh" \
+ /opt/archipelago/scripts/lib/common.sh; do
+ [ -f "$f" ] && { source "$f"; break; }
+done
+
+# ── Environment detection ─────────────────────────────────────────────
+detect_environment() {
+ DISK_GB=$(df --output=size -BG / 2>/dev/null | tail -1 | tr -dc '0-9')
+ DISK_GB=${DISK_GB:-500}
+ TOTAL_MEM_MB=$(($(awk '/MemTotal/{print $2}' /proc/meminfo 2>/dev/null || echo 16000000) / 1024))
+ LOW_MEM=false
+ [ "$TOTAL_MEM_MB" -lt 12000 ] && LOW_MEM=true
+ HOST_IP=$(hostname -I 2>/dev/null | awk '{print $1}')
+ HOST_IP=${HOST_IP:-127.0.0.1}
+
+ # Secrets
+ SECRETS_DIR="/var/lib/archipelago/secrets"
+ BITCOIN_RPC_USER="archipelago"
+ BITCOIN_RPC_PASS=$(cat "$SECRETS_DIR/bitcoin-rpc-password" 2>/dev/null || echo "")
+ MEMPOOL_DB_PASS=$(cat "$SECRETS_DIR/mempool-db-password" 2>/dev/null || echo "")
+ BTCPAY_DB_PASS=$(cat "$SECRETS_DIR/btcpay-db-password" 2>/dev/null || echo "")
+ MYSQL_ROOT_PASS=$(cat "$SECRETS_DIR/mysql-root-db-password" 2>/dev/null || echo "")
+ FEDI_HASH=$(cat "$SECRETS_DIR/fedimint-gateway-hash" 2>/dev/null || echo "")
+}
+
+# ── Spec variables ────────────────────────────────────────────────────
+# Each load_spec_* function sets these variables:
+# SPEC_NAME Container name
+# SPEC_IMAGE Full image reference (pinned)
+# SPEC_NETWORK Network mode (archy-net, bridge, host)
+# SPEC_PORTS Space-separated host:container port pairs
+# SPEC_VOLUMES Space-separated host:container volume mappings
+# SPEC_MEMORY Memory limit (e.g. 2g, 512m)
+# SPEC_CAPS Space-separated capabilities to add
+# SPEC_SECURITY Security options
+# SPEC_RESTART Restart policy
+# SPEC_HEALTH_CMD Health check command
+# SPEC_ENV Space-separated KEY=VALUE environment variables
+# SPEC_CUSTOM_ARGS Extra args appended to podman run
+# SPEC_READONLY true/false for --read-only
+# SPEC_TMPFS Space-separated tmpfs mounts
+# SPEC_TIER 0=DB, 1=Core, 2=Service, 3=App, 4=UI
+# SPEC_DATA_DIR Host data directory (for ownership fix)
+# SPEC_DATA_UID Host UID:GID for data dir (rootless mapped)
+# SPEC_DEPENDS Space-separated container dependencies
+# SPEC_LOCAL_IMAGE true if image is built locally (don't pull)
+# SPEC_OPTIONAL true if container should be skipped when image missing
+
+reset_spec() {
+ SPEC_NAME="" SPEC_IMAGE="" SPEC_NETWORK="bridge" SPEC_PORTS=""
+ SPEC_VOLUMES="" SPEC_MEMORY="512m" SPEC_CAPS="CHOWN FOWNER SETUID SETGID DAC_OVERRIDE"
+ SPEC_SECURITY="no-new-privileges:true" SPEC_RESTART="unless-stopped"
+ SPEC_HEALTH_CMD="" SPEC_ENV="" SPEC_CUSTOM_ARGS="" SPEC_READONLY="false"
+ SPEC_TMPFS="" SPEC_TIER="3" SPEC_DATA_DIR="" SPEC_DATA_UID="100000:100000"
+ SPEC_DEPENDS="" SPEC_LOCAL_IMAGE="false" SPEC_OPTIONAL="false"
+ SPEC_ENTRYPOINT=""
+}
+
+# ── Tier 0: Databases ────────────────────────────────────────────────
+
+load_spec_archy-mempool-db() {
+ reset_spec
+ SPEC_NAME="archy-mempool-db"
+ SPEC_IMAGE="${MARIADB_IMAGE:-docker.io/library/mariadb:11.4}"
+ SPEC_NETWORK="archy-net"
+ SPEC_MEMORY="$(mem_limit archy-mempool-db)"
+ SPEC_VOLUMES="/var/lib/archipelago/mysql-mempool:/var/lib/mysql"
+ SPEC_HEALTH_CMD="mariadb -uroot -e 'SELECT 1' || exit 1"
+ SPEC_ENV="MYSQL_DATABASE=mempool MYSQL_USER=mempool MYSQL_PASSWORD=$MEMPOOL_DB_PASS MYSQL_ROOT_PASSWORD=$MYSQL_ROOT_PASS"
+ SPEC_TIER="0"
+ SPEC_DATA_DIR="/var/lib/archipelago/mysql-mempool"
+ SPEC_DATA_UID="100999:100999"
+ SPEC_CAPS="CHOWN FOWNER SETUID SETGID DAC_OVERRIDE"
+}
+
+load_spec_archy-btcpay-db() {
+ reset_spec
+ SPEC_NAME="archy-btcpay-db"
+ SPEC_IMAGE="${BTCPAY_POSTGRES_IMAGE:-docker.io/library/postgres:15}"
+ SPEC_NETWORK="archy-net"
+ SPEC_MEMORY="$(mem_limit archy-btcpay-db)"
+ SPEC_VOLUMES="/var/lib/archipelago/postgres-btcpay:/var/lib/postgresql/data"
+ SPEC_HEALTH_CMD="pg_isready -U postgres || exit 1"
+ SPEC_ENV="POSTGRES_DB=btcpay POSTGRES_USER=btcpay POSTGRES_PASSWORD=$BTCPAY_DB_PASS"
+ SPEC_TIER="0"
+ SPEC_DATA_DIR="/var/lib/archipelago/postgres-btcpay"
+ SPEC_DATA_UID="100070:100070"
+ SPEC_CAPS="CHOWN FOWNER SETUID SETGID DAC_OVERRIDE"
+}
+
+load_spec_immich_postgres() {
+ reset_spec
+ SPEC_NAME="immich_postgres"
+ SPEC_IMAGE="ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0"
+ SPEC_NETWORK="bridge"
+ SPEC_MEMORY="$(mem_limit immich_postgres)"
+ SPEC_VOLUMES="/var/lib/archipelago/immich-db:/var/lib/postgresql/data"
+ SPEC_ENV="POSTGRES_USER=postgres POSTGRES_DB=immich POSTGRES_PASSWORD=$BTCPAY_DB_PASS"
+ SPEC_TIER="0"
+ SPEC_DATA_DIR="/var/lib/archipelago/immich-db"
+ SPEC_DATA_UID="100070:100070"
+ SPEC_CAPS="CHOWN FOWNER SETUID SETGID DAC_OVERRIDE"
+ SPEC_OPTIONAL="true"
+}
+
+load_spec_immich_redis() {
+ reset_spec
+ SPEC_NAME="immich_redis"
+ SPEC_IMAGE="${VALKEY_IMAGE:-docker.io/valkey/valkey:8}"
+ SPEC_NETWORK="bridge"
+ SPEC_MEMORY="$(mem_limit immich_redis)"
+ SPEC_TIER="0"
+ SPEC_CAPS="CHOWN SETUID SETGID"
+ SPEC_OPTIONAL="true"
+}
+
+# ── Tier 1: Core Infrastructure ──────────────────────────────────────
+
+load_spec_bitcoin-knots() {
+ reset_spec
+ SPEC_NAME="bitcoin-knots"
+ SPEC_IMAGE="${BITCOIN_KNOTS_IMAGE:-docker.io/bitcoinknots/bitcoin:28.1}"
+ SPEC_NETWORK="archy-net"
+ SPEC_PORTS="8332:8332 8333:8333"
+ SPEC_VOLUMES="/var/lib/archipelago/bitcoin:/home/bitcoin/.bitcoin"
+ SPEC_MEMORY="$(mem_limit bitcoin-knots)"
+ SPEC_HEALTH_CMD="bitcoin-cli -rpcuser=\$BITCOIN_RPC_USER -rpcpassword=\$BITCOIN_RPC_PASS getblockchaininfo || exit 1"
+ SPEC_TIER="1"
+ SPEC_DATA_DIR="/var/lib/archipelago/bitcoin"
+ SPEC_DATA_UID="100101:100101"
+ # Dynamic: prune on small disk
+ if [ "${DISK_GB:-0}" -lt 1000 ]; then
+ SPEC_CUSTOM_ARGS="-server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=512"
+ else
+ SPEC_CUSTOM_ARGS="-server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096"
+ fi
+}
+
+load_spec_electrumx() {
+ reset_spec
+ SPEC_NAME="electrumx"
+ SPEC_IMAGE="docker.io/lukechilds/electrumx:v1.16.0"
+ SPEC_NETWORK="archy-net"
+ SPEC_PORTS="50001:50001"
+ SPEC_VOLUMES="/var/lib/archipelago/electrumx:/data"
+ SPEC_MEMORY="$(mem_limit electrumx)"
+ SPEC_HEALTH_CMD="curl -sf http://localhost:8000/ || exit 1"
+ SPEC_ENV="DAEMON_URL=http://$BITCOIN_RPC_USER:$BITCOIN_RPC_PASS@bitcoin-knots:8332/ COIN=Bitcoin DB_DIRECTORY=/data SERVICES=tcp://:50001,rpc://0.0.0.0:8000"
+ SPEC_TIER="1"
+ SPEC_DATA_DIR="/var/lib/archipelago/electrumx"
+ SPEC_DEPENDS="bitcoin-knots"
+ SPEC_CAPS=""
+}
+
+# ── Tier 2: Services ─────────────────────────────────────────────────
+
+load_spec_lnd() {
+ reset_spec
+ SPEC_NAME="lnd"
+ SPEC_IMAGE="${LND_IMAGE:-docker.io/lightninglabs/lnd:v0.18.5-beta}"
+ SPEC_NETWORK="archy-net"
+ SPEC_PORTS="9735:9735 10009:10009 8080:8080"
+ SPEC_VOLUMES="/var/lib/archipelago/lnd:/root/.lnd"
+ SPEC_MEMORY="$(mem_limit lnd)"
+ SPEC_HEALTH_CMD="curl -sf --insecure https://localhost:8080/v1/getinfo || exit 1"
+ SPEC_TIER="2"
+ SPEC_DATA_DIR="/var/lib/archipelago/lnd"
+ SPEC_DEPENDS="bitcoin-knots"
+}
+
+load_spec_mempool-api() {
+ reset_spec
+ SPEC_NAME="mempool-api"
+ SPEC_IMAGE="${MEMPOOL_BACKEND_IMAGE:-docker.io/mempool/backend:v3.0.0}"
+ SPEC_NETWORK="archy-net"
+ SPEC_PORTS="8999:8999"
+ SPEC_VOLUMES="/var/lib/archipelago/mempool:/data"
+ SPEC_MEMORY="$(mem_limit mempool-api)"
+ SPEC_HEALTH_CMD="curl -sf http://localhost:8999/ || exit 1"
+ local MYSQL_CNT="archy-mempool-db"
+ SPEC_ENV="MEMPOOL_BACKEND=electrum ELECTRUM_HOST=electrumx ELECTRUM_PORT=50001 ELECTRUM_TLS_ENABLED=false CORE_RPC_HOST=bitcoin-knots CORE_RPC_PORT=8332 CORE_RPC_USERNAME=$BITCOIN_RPC_USER CORE_RPC_PASSWORD=$BITCOIN_RPC_PASS DATABASE_ENABLED=true DATABASE_HOST=$MYSQL_CNT DATABASE_DATABASE=mempool DATABASE_USERNAME=mempool DATABASE_PASSWORD=$MEMPOOL_DB_PASS"
+ SPEC_TIER="2"
+ SPEC_DATA_DIR="/var/lib/archipelago/mempool"
+ SPEC_DEPENDS="bitcoin-knots electrumx archy-mempool-db"
+ SPEC_CAPS=""
+}
+
+load_spec_archy-mempool-web() {
+ reset_spec
+ SPEC_NAME="archy-mempool-web"
+ SPEC_IMAGE="${MEMPOOL_WEB_IMAGE:-docker.io/mempool/frontend:v3.0.0}"
+ SPEC_NETWORK="archy-net"
+ SPEC_PORTS="4080:8080"
+ SPEC_MEMORY="$(mem_limit archy-mempool-web)"
+ SPEC_HEALTH_CMD="curl -sf http://localhost:8080/ || exit 1"
+ SPEC_ENV="FRONTEND_HTTP_PORT=8080 BACKEND_MAINNET_HTTP_HOST=mempool-api"
+ SPEC_TIER="2"
+ SPEC_DEPENDS="mempool-api"
+ SPEC_CAPS=""
+}
+
+load_spec_archy-nbxplorer() {
+ reset_spec
+ SPEC_NAME="archy-nbxplorer"
+ SPEC_IMAGE="${NBXPLORER_IMAGE:-docker.io/nicolasdorier/nbxplorer:2.5.13}"
+ SPEC_NETWORK="archy-net"
+ SPEC_PORTS="32838:32838"
+ SPEC_VOLUMES="/var/lib/archipelago/nbxplorer:/data"
+ SPEC_MEMORY="$(mem_limit archy-nbxplorer)"
+ SPEC_HEALTH_CMD="curl -sf http://localhost:32838/ || exit 1"
+ SPEC_ENV="NBXPLORER_DATADIR=/data NBXPLORER_NETWORK=mainnet NBXPLORER_CHAINS=btc NBXPLORER_BIND=0.0.0.0:32838 NBXPLORER_BTCRPCURL=http://bitcoin-knots:8332 NBXPLORER_BTCRPCUSER=$BITCOIN_RPC_USER NBXPLORER_BTCRPCPASSWORD=$BITCOIN_RPC_PASS NBXPLORER_POSTGRES=User ID=btcpay;Password=$BTCPAY_DB_PASS;Host=archy-btcpay-db;Port=5432;Database=nbxplorer;Include Error Detail=true"
+ SPEC_TIER="2"
+ SPEC_DATA_DIR="/var/lib/archipelago/nbxplorer"
+ SPEC_DEPENDS="bitcoin-knots archy-btcpay-db"
+ SPEC_CAPS=""
+}
+
+load_spec_btcpay-server() {
+ reset_spec
+ SPEC_NAME="btcpay-server"
+ SPEC_IMAGE="${BTCPAY_IMAGE:-docker.io/btcpayserver/btcpayserver:1.13.7}"
+ SPEC_NETWORK="archy-net"
+ SPEC_PORTS="23000:49392"
+ SPEC_VOLUMES="/var/lib/archipelago/btcpay:/datadir"
+ SPEC_MEMORY="$(mem_limit btcpay-server)"
+ SPEC_HEALTH_CMD="curl -sf http://localhost:49392/ || exit 1"
+ SPEC_ENV="ASPNETCORE_URLS=http://0.0.0.0:49392 BTCPAY_PROTOCOL=http BTCPAY_HOST=$HOST_IP:23000 BTCPAY_CHAINS=btc BTCPAY_BTCEXPLORERURL=http://archy-nbxplorer:32838 BTCPAY_BTCRPCURL=http://bitcoin-knots:8332 BTCPAY_BTCRPCUSER=$BITCOIN_RPC_USER BTCPAY_BTCRPCPASSWORD=$BITCOIN_RPC_PASS BTCPAY_POSTGRES=User ID=btcpay;Password=$BTCPAY_DB_PASS;Host=archy-btcpay-db;Port=5432;Database=btcpay;Include Error Detail=true"
+ SPEC_TIER="2"
+ SPEC_DATA_DIR="/var/lib/archipelago/btcpay"
+ SPEC_DEPENDS="archy-nbxplorer archy-btcpay-db"
+}
+
+load_spec_fedimint() {
+ reset_spec
+ SPEC_NAME="fedimint"
+ SPEC_IMAGE="${FEDIMINT_IMAGE:-docker.io/fedimint/fedimintd:v0.5.1}"
+ SPEC_NETWORK="archy-net"
+ SPEC_PORTS="8173:8173 8174:8174 8175:8175"
+ SPEC_VOLUMES="/var/lib/archipelago/fedimint:/data"
+ SPEC_MEMORY="$(mem_limit fedimint)"
+ SPEC_HEALTH_CMD="curl -sf http://localhost:8174/ || exit 1"
+ SPEC_ENV="FM_DATA_DIR=/data FM_BITCOIND_USERNAME=$BITCOIN_RPC_USER FM_BITCOIND_PASSWORD=$BITCOIN_RPC_PASS FM_BITCOIN_NETWORK=bitcoin FM_BIND_P2P=0.0.0.0:8173 FM_BIND_API=0.0.0.0:8174 FM_BIND_UI=0.0.0.0:8175 FM_P2P_URL=fedimint://$HOST_IP:8173 FM_API_URL=ws://$HOST_IP:8174 FM_BITCOIND_URL=http://$HOST_IP:8332"
+ SPEC_TIER="2"
+ SPEC_DATA_DIR="/var/lib/archipelago/fedimint"
+ SPEC_DEPENDS="bitcoin-knots"
+}
+
+load_spec_fedimint-gateway() {
+ reset_spec
+ SPEC_NAME="fedimint-gateway"
+ SPEC_IMAGE="${FEDIMINT_GATEWAY_IMAGE:-docker.io/fedimint/gatewayd:v0.5.1}"
+ SPEC_NETWORK="archy-net"
+ SPEC_PORTS="8176:8176"
+ SPEC_VOLUMES="/var/lib/archipelago/fedimint-gateway:/data"
+ SPEC_MEMORY="$(mem_limit fedimint-gateway)"
+ SPEC_HEALTH_CMD="curl -sf http://localhost:8175/ || exit 1"
+ SPEC_TIER="2"
+ SPEC_DATA_DIR="/var/lib/archipelago/fedimint-gateway"
+ SPEC_DEPENDS="bitcoin-knots fedimint"
+ # Custom entrypoint depends on whether LND is available
+ local LND_CERT=/var/lib/archipelago/lnd/tls.cert
+ local LND_MAC=/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon
+ if [ -f "$LND_CERT" ] && [ -f "$LND_MAC" ]; then
+ SPEC_VOLUMES="$SPEC_VOLUMES $LND_CERT:/lnd/tls.cert:ro $LND_MAC:/lnd/admin.macaroon:ro"
+ SPEC_ENTRYPOINT="gatewayd --data-dir /data --listen 0.0.0.0:8176 --bcrypt-password-hash $FEDI_HASH --network bitcoin --bitcoind-url http://$HOST_IP:8332 --bitcoind-username $BITCOIN_RPC_USER --bitcoind-password $BITCOIN_RPC_PASS lnd --lnd-rpc-host $HOST_IP:10009 --lnd-tls-cert /lnd/tls.cert --lnd-macaroon /lnd/admin.macaroon"
+ else
+ SPEC_PORTS="8176:8176 9737:9737"
+ SPEC_ENTRYPOINT="gatewayd --data-dir /data --listen 0.0.0.0:8176 --bcrypt-password-hash $FEDI_HASH --network bitcoin --bitcoind-url http://$HOST_IP:8332 --bitcoind-username $BITCOIN_RPC_USER --bitcoind-password $BITCOIN_RPC_PASS ldk --ldk-lightning-port 9737 --ldk-alias archipelago-gateway"
+ fi
+}
+
+load_spec_immich_server() {
+ reset_spec
+ SPEC_NAME="immich_server"
+ SPEC_IMAGE="ghcr.io/immich-app/immich-server:release"
+ SPEC_NETWORK="bridge"
+ SPEC_PORTS="2283:2283"
+ SPEC_VOLUMES="/var/lib/archipelago/immich:/usr/src/app/upload"
+ SPEC_MEMORY="$(mem_limit immich_server)"
+ SPEC_ENV="DB_HOSTNAME=immich_postgres DB_DATABASE_NAME=immich DB_USERNAME=postgres DB_PASSWORD=$BTCPAY_DB_PASS REDIS_HOSTNAME=immich_redis UPLOAD_LOCATION=/usr/src/app/upload"
+ SPEC_TIER="2"
+ SPEC_DATA_DIR="/var/lib/archipelago/immich"
+ SPEC_DEPENDS="immich_postgres immich_redis"
+ SPEC_CAPS=""
+ SPEC_OPTIONAL="true"
+}
+
+# ── Tier 3: Applications ─────────────────────────────────────────────
+
+load_spec_homeassistant() {
+ reset_spec
+ SPEC_NAME="homeassistant"
+ SPEC_IMAGE="${HOMEASSISTANT_IMAGE:-ghcr.io/home-assistant/home-assistant:2024.12}"
+ SPEC_PORTS="8123:8123"
+ SPEC_VOLUMES="/var/lib/archipelago/home-assistant:/config"
+ SPEC_MEMORY="$(mem_limit homeassistant)"
+ SPEC_HEALTH_CMD="curl -sf http://localhost:8123/ || exit 1"
+ SPEC_ENV="TZ=UTC"
+ SPEC_TIER="3"
+ SPEC_DATA_DIR="/var/lib/archipelago/home-assistant"
+ SPEC_CAPS="CHOWN SETUID SETGID DAC_OVERRIDE"
+}
+
+load_spec_grafana() {
+ reset_spec
+ SPEC_NAME="grafana"
+ SPEC_IMAGE="${GRAFANA_IMAGE:-docker.io/grafana/grafana:11.4.0}"
+ SPEC_PORTS="3000:3000"
+ SPEC_VOLUMES="/var/lib/archipelago/grafana:/var/lib/grafana"
+ SPEC_MEMORY="$(mem_limit grafana)"
+ SPEC_HEALTH_CMD="curl -sf http://localhost:3000/api/health || exit 1"
+ SPEC_ENV="GF_PATHS_DATA=/var/lib/grafana GF_USERS_ALLOW_SIGN_UP=false"
+ SPEC_READONLY="true"
+ SPEC_TMPFS="/tmp:rw,noexec,nosuid,size=256m /run:rw,noexec,nosuid,size=64m"
+ SPEC_TIER="3"
+ SPEC_DATA_DIR="/var/lib/archipelago/grafana"
+ SPEC_DATA_UID="100472:100472"
+ SPEC_CAPS="CHOWN SETUID SETGID"
+}
+
+load_spec_uptime-kuma() {
+ reset_spec
+ SPEC_NAME="uptime-kuma"
+ SPEC_IMAGE="${UPTIME_KUMA_IMAGE:-docker.io/louislam/uptime-kuma:1}"
+ SPEC_PORTS="3001:3001"
+ SPEC_VOLUMES="/var/lib/archipelago/uptime-kuma:/app/data"
+ SPEC_MEMORY="$(mem_limit uptime-kuma)"
+ SPEC_HEALTH_CMD="curl -sf http://localhost:3001/ || exit 1"
+ SPEC_ENV="TZ=UTC"
+ SPEC_TIER="3"
+ SPEC_DATA_DIR="/var/lib/archipelago/uptime-kuma"
+ SPEC_CAPS="CHOWN FOWNER SETUID SETGID"
+}
+
+load_spec_jellyfin() {
+ reset_spec
+ SPEC_NAME="jellyfin"
+ SPEC_IMAGE="${JELLYFIN_IMAGE:-docker.io/jellyfin/jellyfin:10.10.3}"
+ SPEC_PORTS="8096:8096"
+ SPEC_VOLUMES="/var/lib/archipelago/jellyfin/config:/config /var/lib/archipelago/jellyfin/cache:/cache"
+ SPEC_MEMORY="$(mem_limit jellyfin)"
+ SPEC_HEALTH_CMD="curl -sf http://localhost:8096/ || exit 1"
+ SPEC_TIER="3"
+ SPEC_DATA_DIR="/var/lib/archipelago/jellyfin"
+ SPEC_CAPS=""
+}
+
+load_spec_photoprism() {
+ reset_spec
+ SPEC_NAME="photoprism"
+ SPEC_IMAGE="${PHOTOPRISM_IMAGE:-docker.io/photoprism/photoprism:240915}"
+ SPEC_PORTS="2342:2342"
+ SPEC_VOLUMES="/var/lib/archipelago/photoprism:/photoprism/storage"
+ SPEC_MEMORY="$(mem_limit photoprism)"
+ SPEC_HEALTH_CMD="curl -sf http://localhost:2342/ || exit 1"
+ SPEC_ENV="PHOTOPRISM_ADMIN_PASSWORD=archipelago PHOTOPRISM_DEFAULT_LOCALE=en"
+ SPEC_TIER="3"
+ SPEC_DATA_DIR="/var/lib/archipelago/photoprism"
+ SPEC_CAPS="CHOWN SETUID SETGID"
+}
+
+load_spec_vaultwarden() {
+ reset_spec
+ SPEC_NAME="vaultwarden"
+ SPEC_IMAGE="${VAULTWARDEN_IMAGE:-docker.io/vaultwarden/server:1.32.5}"
+ SPEC_PORTS="8082:80"
+ SPEC_VOLUMES="/var/lib/archipelago/vaultwarden:/data"
+ SPEC_MEMORY="$(mem_limit vaultwarden)"
+ SPEC_HEALTH_CMD="curl -sf http://localhost:80/ || exit 1"
+ SPEC_TIER="3"
+ SPEC_DATA_DIR="/var/lib/archipelago/vaultwarden"
+ SPEC_CAPS="CHOWN SETUID SETGID NET_BIND_SERVICE"
+}
+
+load_spec_nextcloud() {
+ reset_spec
+ SPEC_NAME="nextcloud"
+ SPEC_IMAGE="${NEXTCLOUD_IMAGE:-docker.io/library/nextcloud:29}"
+ SPEC_PORTS="8085:80"
+ SPEC_VOLUMES="/var/lib/archipelago/nextcloud:/var/www/html"
+ SPEC_MEMORY="$(mem_limit nextcloud)"
+ SPEC_HEALTH_CMD="curl -sf http://localhost:80/ || exit 1"
+ SPEC_TIER="3"
+ SPEC_DATA_DIR="/var/lib/archipelago/nextcloud"
+ SPEC_CAPS="CHOWN SETUID SETGID DAC_OVERRIDE"
+}
+
+load_spec_searxng() {
+ reset_spec
+ SPEC_NAME="searxng"
+ SPEC_IMAGE="${SEARXNG_IMAGE:-docker.io/searxng/searxng:2026.3.20-6c7e9c197}"
+ SPEC_PORTS="8888:8080"
+ SPEC_MEMORY="$(mem_limit searxng)"
+ SPEC_HEALTH_CMD="curl -sf http://localhost:8080/ || exit 1"
+ SPEC_READONLY="true"
+ SPEC_TMPFS="/tmp:rw,noexec,nosuid,size=256m /run:rw,noexec,nosuid,size=64m /etc/searxng:rw,noexec,nosuid,size=16m"
+ SPEC_TIER="3"
+ SPEC_CAPS=""
+}
+
+load_spec_onlyoffice() {
+ reset_spec
+ SPEC_NAME="onlyoffice"
+ SPEC_IMAGE="${ONLYOFFICE_IMAGE:-docker.io/onlyoffice/documentserver:8.2}"
+ SPEC_PORTS="9980:80"
+ SPEC_MEMORY="$(mem_limit onlyoffice)"
+ SPEC_HEALTH_CMD="curl -sf http://localhost:80/ || exit 1"
+ SPEC_TIER="3"
+ SPEC_CAPS="CHOWN SETUID SETGID DAC_OVERRIDE"
+}
+
+load_spec_filebrowser() {
+ reset_spec
+ SPEC_NAME="filebrowser"
+ SPEC_IMAGE="${FILEBROWSER_IMAGE:-docker.io/filebrowser/filebrowser:v2}"
+ SPEC_PORTS="8083:80"
+ SPEC_VOLUMES="/var/lib/archipelago/filebrowser:/srv"
+ SPEC_MEMORY="$(mem_limit filebrowser)"
+ SPEC_HEALTH_CMD="curl -sf http://localhost:80/ || exit 1"
+ SPEC_TIER="3"
+ SPEC_DATA_DIR="/var/lib/archipelago/filebrowser"
+ SPEC_CAPS=""
+}
+
+load_spec_nginx-proxy-manager() {
+ reset_spec
+ SPEC_NAME="nginx-proxy-manager"
+ SPEC_IMAGE="${NPM_IMAGE:-docker.io/jc21/nginx-proxy-manager:2}"
+ SPEC_PORTS="81:81 8084:80 8443:443"
+ SPEC_VOLUMES="/var/lib/archipelago/nginx-proxy-manager/data:/data /var/lib/archipelago/nginx-proxy-manager/letsencrypt:/etc/letsencrypt"
+ SPEC_MEMORY="$(mem_limit nginx-proxy-manager)"
+ SPEC_HEALTH_CMD="curl -sf http://localhost:81/ || exit 1"
+ SPEC_TIER="3"
+ SPEC_DATA_DIR="/var/lib/archipelago/nginx-proxy-manager"
+ SPEC_CAPS="CHOWN SETUID SETGID NET_BIND_SERVICE"
+}
+
+load_spec_portainer() {
+ reset_spec
+ SPEC_NAME="portainer"
+ SPEC_IMAGE="${PORTAINER_IMAGE:-docker.io/portainer/portainer-ce:2.21.5}"
+ SPEC_PORTS="9000:9000"
+ SPEC_VOLUMES="/var/lib/archipelago/portainer:/data /run/user/1000/podman/podman.sock:/var/run/docker.sock"
+ SPEC_MEMORY="$(mem_limit portainer)"
+ SPEC_HEALTH_CMD="curl -sf http://localhost:9000/ || exit 1"
+ SPEC_TIER="3"
+ SPEC_DATA_DIR="/var/lib/archipelago/portainer"
+}
+
+load_spec_ollama() {
+ reset_spec
+ SPEC_NAME="ollama"
+ SPEC_IMAGE="${OLLAMA_IMAGE:-docker.io/ollama/ollama:0.5.4}"
+ SPEC_PORTS="11434:11434"
+ SPEC_VOLUMES="/var/lib/archipelago/ollama:/root/.ollama"
+ SPEC_MEMORY="$(mem_limit ollama)"
+ SPEC_HEALTH_CMD="curl -sf http://localhost:11434/ || exit 1"
+ SPEC_READONLY="true"
+ SPEC_TMPFS="/tmp:rw,noexec,nosuid,size=256m /run:rw,noexec,nosuid,size=64m"
+ SPEC_TIER="3"
+ SPEC_DATA_DIR="/var/lib/archipelago/ollama"
+ SPEC_CAPS=""
+ SPEC_OPTIONAL="true"
+}
+
+# ── Tier 4: Frontend UIs ─────────────────────────────────────────────
+
+load_spec_archy-bitcoin-ui() {
+ reset_spec
+ SPEC_NAME="archy-bitcoin-ui"
+ SPEC_IMAGE="localhost/bitcoin-ui:local"
+ SPEC_NETWORK="host"
+ SPEC_MEMORY="$(mem_limit archy-bitcoin-ui)"
+ SPEC_TIER="4"
+ SPEC_LOCAL_IMAGE="true"
+ SPEC_CAPS=""
+ SPEC_SECURITY=""
+}
+
+load_spec_archy-lnd-ui() {
+ reset_spec
+ SPEC_NAME="archy-lnd-ui"
+ SPEC_IMAGE="localhost/lnd-ui:local"
+ SPEC_PORTS="8081:80"
+ SPEC_MEMORY="$(mem_limit archy-lnd-ui)"
+ SPEC_TIER="4"
+ SPEC_LOCAL_IMAGE="true"
+ SPEC_CAPS=""
+ SPEC_SECURITY=""
+}
+
+load_spec_archy-electrs-ui() {
+ reset_spec
+ SPEC_NAME="archy-electrs-ui"
+ SPEC_IMAGE="localhost/electrs-ui:local"
+ SPEC_NETWORK="host"
+ SPEC_MEMORY="$(mem_limit archy-electrs-ui)"
+ SPEC_TIER="4"
+ SPEC_LOCAL_IMAGE="true"
+ SPEC_CAPS=""
+ SPEC_SECURITY=""
+}
+
+# ── Registry ─────────────────────────────────────────────────────────
+# Ordered by tier, then dependency order within tier
+
+ALL_CONTAINER_SPECS=(
+ # Tier 0: Databases
+ archy-mempool-db
+ archy-btcpay-db
+ immich_postgres
+ immich_redis
+ # Tier 1: Core
+ bitcoin-knots
+ electrumx
+ # Tier 2: Services
+ lnd
+ mempool-api
+ archy-mempool-web
+ archy-nbxplorer
+ btcpay-server
+ fedimint
+ fedimint-gateway
+ immich_server
+ # Tier 3: Apps
+ homeassistant
+ grafana
+ uptime-kuma
+ jellyfin
+ photoprism
+ vaultwarden
+ nextcloud
+ searxng
+ onlyoffice
+ filebrowser
+ nginx-proxy-manager
+ portainer
+ ollama
+ # Tier 4: UIs
+ archy-bitcoin-ui
+ archy-lnd-ui
+ archy-electrs-ui
+)
+
+# Load a spec by name. Usage: load_spec "bitcoin-knots"
+load_spec() {
+ local fn="load_spec_${1}"
+ if declare -f "$fn" >/dev/null 2>&1; then
+ "$fn"
+ return 0
+ fi
+ return 1
+}
+
+# Return all spec names
+all_specs() {
+ echo "${ALL_CONTAINER_SPECS[@]}"
+}
diff --git a/scripts/reconcile-containers.sh b/scripts/reconcile-containers.sh
new file mode 100755
index 00000000..b141aebd
--- /dev/null
+++ b/scripts/reconcile-containers.sh
@@ -0,0 +1,523 @@
+#!/bin/bash
+#
+# Archipelago Container Reconciler
+# Ensures every container matches the canonical spec from container-specs.sh.
+# Safe to run repeatedly (idempotent). Run on any node.
+#
+# Usage:
+# sudo ./reconcile-containers.sh # Fix everything
+# sudo ./reconcile-containers.sh --check-only # Audit only, no changes
+# sudo ./reconcile-containers.sh --force # Override user-stopped
+# sudo ./reconcile-containers.sh --tier=2 # Only reconcile tier 2
+# sudo ./reconcile-containers.sh --container=lnd # Only reconcile lnd
+#
+set -o pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+
+# ── Parse arguments ──────────────────────────────────────────────────
+CHECK_ONLY=false
+FORCE=false
+FILTER_TIER=""
+FILTER_CONTAINER=""
+for arg in "$@"; do
+ case "$arg" in
+ --check-only) CHECK_ONLY=true ;;
+ --force) FORCE=true ;;
+ --tier=*) FILTER_TIER="${arg#*=}" ;;
+ --container=*) FILTER_CONTAINER="${arg#*=}" ;;
+ -h|--help)
+ echo "Usage: $0 [--check-only] [--force] [--tier=N] [--container=NAME]"
+ exit 0 ;;
+ esac
+done
+
+# ── Colors ───────────────────────────────────────────────────────────
+RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[0;33m'
+BLUE='\033[0;34m' CYAN='\033[0;36m' BOLD='\033[1m'
+NC='\033[0m'
+
+ok() { echo -e " ${GREEN}[OK]${NC} $*"; }
+fixed() { echo -e " ${CYAN}[FIXED]${NC} $*"; }
+skip() { echo -e " ${YELLOW}[SKIP]${NC} $*"; }
+fail() { echo -e " ${RED}[FAIL]${NC} $*"; }
+info() { echo -e " ${BLUE}[INFO]${NC} $*"; }
+header(){ echo -e "\n${BOLD}$*${NC}"; }
+
+# ── Source specs ─────────────────────────────────────────────────────
+source "$SCRIPT_DIR/container-specs.sh" || { echo "Cannot source container-specs.sh"; exit 1; }
+detect_environment
+
+# ── Podman command ───────────────────────────────────────────────────
+# Run as archipelago user — podman sees rootless containers directly.
+# Use sudo only for chown/mkdir operations.
+PODMAN="podman"
+
+# ── Pre-flight ───────────────────────────────────────────────────────
+header "╔══════════════════════════════════════════════════╗"
+header "║ ARCHIPELAGO CONTAINER RECONCILER ║"
+header "╚══════════════════════════════════════════════════╝"
+echo ""
+info "Host: $(hostname) ($HOST_IP)"
+info "Disk: ${DISK_GB}GB | RAM: ${TOTAL_MEM_MB}MB | Low-mem: $LOW_MEM"
+info "Mode: $($CHECK_ONLY && echo 'CHECK ONLY (no changes)' || echo 'APPLY FIXES')"
+echo ""
+
+# Ensure archy-net exists
+if ! $PODMAN network exists archy-net 2>/dev/null; then
+ if $CHECK_ONLY; then
+ info "archy-net missing (would create)"
+ else
+ $PODMAN network create archy-net 2>/dev/null && info "Created archy-net" || fail "Cannot create archy-net"
+ fi
+fi
+
+# Load user-stopped list
+USER_STOPPED_FILE="/var/lib/archipelago/user-stopped.json"
+USER_STOPPED=""
+if [ -f "$USER_STOPPED_FILE" ]; then
+ USER_STOPPED=$(cat "$USER_STOPPED_FILE" 2>/dev/null)
+fi
+is_user_stopped() {
+ [ "$FORCE" = "true" ] && return 1
+ echo "$USER_STOPPED" | grep -q "\"$1\"" 2>/dev/null
+}
+
+# ── Inspection helpers ───────────────────────────────────────────────
+container_exists() {
+ $PODMAN ps -a --format '{{.Names}}' 2>/dev/null | grep -qx "$1"
+}
+
+container_running() {
+ $PODMAN ps --format '{{.Names}}' 2>/dev/null | grep -qx "$1"
+}
+
+container_image() {
+ $PODMAN inspect "$1" --format '{{.ImageName}}' 2>/dev/null
+}
+
+container_network() {
+ # Use actual Networks map — NetworkMode is unreliable (always shows 'bridge' in rootless)
+ local nets
+ nets=$($PODMAN inspect "$1" --format '{{range $k,$v := .NetworkSettings.Networks}}{{$k}} {{end}}' 2>/dev/null)
+ # Return first network name, trimmed
+ echo "$nets" | awk '{print $1}'
+}
+
+container_memory() {
+ $PODMAN inspect "$1" --format '{{.HostConfig.Memory}}' 2>/dev/null
+}
+
+image_exists() {
+ $PODMAN images --format '{{.Repository}}:{{.Tag}}' 2>/dev/null | grep -q "$1"
+}
+
+# Convert memory string to bytes for comparison
+mem_to_bytes() {
+ local m="$1"
+ case "$m" in
+ *g|*G) echo $(( ${m%[gG]} * 1073741824 )) ;;
+ *m|*M) echo $(( ${m%[mM]} * 1048576 )) ;;
+ *) echo "$m" ;;
+ esac
+}
+
+# ── Build podman run command from spec ───────────────────────────────
+build_run_cmd() {
+ local cmd="$PODMAN run -d --name $SPEC_NAME"
+ cmd+=" --restart $SPEC_RESTART"
+
+ # Network
+ if [ "$SPEC_NETWORK" = "host" ]; then
+ cmd+=" --network=host"
+ elif [ "$SPEC_NETWORK" = "archy-net" ]; then
+ cmd+=" --network archy-net"
+ fi
+
+ # Memory
+ [ -n "$SPEC_MEMORY" ] && cmd+=" --memory=$SPEC_MEMORY"
+
+ # Capabilities
+ cmd+=" --cap-drop ALL"
+ for cap in $SPEC_CAPS; do
+ cmd+=" --cap-add $cap"
+ done
+
+ # Security
+ [ -n "$SPEC_SECURITY" ] && cmd+=" --security-opt $SPEC_SECURITY"
+
+ # Read-only
+ [ "$SPEC_READONLY" = "true" ] && cmd+=" --read-only"
+
+ # Tmpfs
+ for t in $SPEC_TMPFS; do
+ cmd+=" --tmpfs $t"
+ done
+
+ # Health check
+ if [ -n "$SPEC_HEALTH_CMD" ]; then
+ cmd+=" --health-cmd=\"$SPEC_HEALTH_CMD\" --health-interval=30s --health-timeout=5s --health-retries=3"
+ fi
+
+ # Ports
+ for p in $SPEC_PORTS; do
+ cmd+=" -p $p"
+ done
+
+ # Volumes
+ for v in $SPEC_VOLUMES; do
+ cmd+=" -v $v"
+ done
+
+ # Environment
+ for e in $SPEC_ENV; do
+ cmd+=" -e \"$e\""
+ done
+
+ # Image
+ cmd+=" $SPEC_IMAGE"
+
+ # Custom args
+ [ -n "$SPEC_CUSTOM_ARGS" ] && cmd+=" $SPEC_CUSTOM_ARGS"
+
+ # Entrypoint override
+ [ -n "$SPEC_ENTRYPOINT" ] && cmd+=" $SPEC_ENTRYPOINT"
+
+ echo "$cmd"
+}
+
+# ── Counters ─────────────────────────────────────────────────────────
+COUNT_OK=0 COUNT_FIXED=0 COUNT_CREATED=0 COUNT_SKIPPED=0 COUNT_FAILED=0
+FAILED_LIST=""
+
+# ── Reconcile one container ──────────────────────────────────────────
+reconcile() {
+ local name="$1"
+
+ if ! load_spec "$name"; then
+ skip "$name — no spec defined"
+ COUNT_SKIPPED=$((COUNT_SKIPPED + 1))
+ return
+ fi
+
+ # Filter by tier
+ [ -n "$FILTER_TIER" ] && [ "$SPEC_TIER" != "$FILTER_TIER" ] && return
+
+ # User-stopped
+ if is_user_stopped "$name"; then
+ skip "$name — user-stopped"
+ COUNT_SKIPPED=$((COUNT_SKIPPED + 1))
+ fix_ownership "$name"
+ return
+ fi
+
+ # Optional/local images: skip if image doesn't exist and container doesn't exist
+ if [ "$SPEC_OPTIONAL" = "true" ] || [ "$SPEC_LOCAL_IMAGE" = "true" ]; then
+ if ! image_exists "$SPEC_IMAGE" && ! container_exists "$name"; then
+ skip "$name — image not available"
+ COUNT_SKIPPED=$((COUNT_SKIPPED + 1))
+ return
+ fi
+ fi
+
+ # Check dependencies
+ for dep in $SPEC_DEPENDS; do
+ if ! container_running "$dep"; then
+ skip "$name — dependency $dep not running"
+ COUNT_SKIPPED=$((COUNT_SKIPPED + 1))
+ return
+ fi
+ done
+
+ local action="OK"
+ local reasons=""
+
+ if container_exists "$name"; then
+ local cur_image cur_network cur_memory
+ cur_image=$(container_image "$name")
+ cur_network=$(container_network "$name")
+ cur_memory=$(container_memory "$name")
+ local spec_memory_bytes expected_network
+
+ spec_memory_bytes=$(mem_to_bytes "$SPEC_MEMORY")
+
+ # Check network mismatch
+ # For archy-net and host: exact match required
+ # For bridge/default: accept any non-archy-net, non-host network
+ if [ "$SPEC_NETWORK" = "archy-net" ]; then
+ if [ "$cur_network" != "archy-net" ]; then
+ action="RECREATE"
+ reasons+="network($cur_network→archy-net) "
+ fi
+ elif [ "$SPEC_NETWORK" = "host" ]; then
+ if [ "$cur_network" != "host" ]; then
+ action="RECREATE"
+ reasons+="network($cur_network→host) "
+ fi
+ else
+ # Default/bridge: anything that isn't archy-net or host is fine
+ if [ "$cur_network" = "archy-net" ] || [ "$cur_network" = "host" ]; then
+ action="RECREATE"
+ reasons+="network($cur_network→bridge) "
+ fi
+ fi
+
+ # Check memory limit (0 = no limit)
+ if [ "${cur_memory:-0}" = "0" ] && [ "${spec_memory_bytes:-0}" != "0" ]; then
+ action="RECREATE"
+ reasons+="memory(none→$SPEC_MEMORY) "
+ fi
+
+ # Check if running
+ if ! container_running "$name" && [ "$action" = "OK" ]; then
+ action="START"
+ reasons+="not-running "
+ fi
+ else
+ action="CREATE"
+ reasons+="missing "
+ fi
+
+ # Fix ownership regardless
+ fix_ownership "$name"
+
+ case "$action" in
+ OK)
+ ok "$name"
+ COUNT_OK=$((COUNT_OK + 1))
+ ;;
+ START)
+ if $CHECK_ONLY; then
+ info "$name — would start ($reasons)"
+ else
+ if $PODMAN start "$name" >/dev/null 2>&1; then
+ fixed "$name — started ($reasons)"
+ else
+ fail "$name — start failed"
+ COUNT_FAILED=$((COUNT_FAILED + 1))
+ FAILED_LIST+=" $name"
+ return
+ fi
+ fi
+ COUNT_FIXED=$((COUNT_FIXED + 1))
+ ;;
+ RECREATE)
+ if $CHECK_ONLY; then
+ info "$name — would recreate ($reasons)"
+ else
+ info "$name — recreating ($reasons)"
+ $PODMAN stop "$name" >/dev/null 2>&1
+ $PODMAN rm "$name" >/dev/null 2>&1
+ if eval "$(build_run_cmd)" >/dev/null 2>&1; then
+ fixed "$name — recreated ($reasons)"
+ else
+ fail "$name — recreate failed: $(eval "$(build_run_cmd)" 2>&1 | tail -1)"
+ COUNT_FAILED=$((COUNT_FAILED + 1))
+ FAILED_LIST+=" $name"
+ return
+ fi
+ fi
+ COUNT_FIXED=$((COUNT_FIXED + 1))
+ ;;
+ CREATE)
+ if $CHECK_ONLY; then
+ info "$name — would create ($reasons)"
+ else
+ for v in $SPEC_VOLUMES; do
+ local host_dir="${v%%:*}"
+ [ -n "$host_dir" ] && sudo mkdir -p "$host_dir" 2>/dev/null
+ done
+ if eval "$(build_run_cmd)" >/dev/null 2>&1; then
+ fixed "$name — created"
+ else
+ fail "$name — create failed"
+ COUNT_FAILED=$((COUNT_FAILED + 1))
+ FAILED_LIST+=" $name"
+ return
+ fi
+ fi
+ COUNT_CREATED=$((COUNT_CREATED + 1))
+ ;;
+ esac
+}
+
+# ── Fix ownership ────────────────────────────────────────────────────
+fix_ownership() {
+ local name="$1"
+ [ -z "$SPEC_DATA_DIR" ] && return
+ [ ! -d "$SPEC_DATA_DIR" ] && return
+ [ "$SPEC_DATA_UID" = "100000:100000" ] && return
+
+ local expected_uid="${SPEC_DATA_UID%%:*}"
+ local current_uid
+ current_uid=$(stat -c '%u' "$SPEC_DATA_DIR" 2>/dev/null)
+
+ if [ "$current_uid" != "$expected_uid" ]; then
+ if $CHECK_ONLY; then
+ info "$name — ownership: $current_uid → $SPEC_DATA_UID"
+ else
+ sudo chown -R "$SPEC_DATA_UID" "$SPEC_DATA_DIR" 2>/dev/null
+ info "$name — fixed ownership → $SPEC_DATA_UID"
+ fi
+ fi
+}
+
+# ── Ensure secrets exist ─────────────────────────────────────────────
+ensure_secrets() {
+ local SECRETS_DIR="/var/lib/archipelago/secrets"
+ sudo mkdir -p "$SECRETS_DIR" 2>/dev/null
+ sudo chmod 700 "$SECRETS_DIR" 2>/dev/null
+
+ for svc in bitcoin-rpc-password mempool-db-password btcpay-db-password mysql-root-db-password; do
+ if [ ! -f "$SECRETS_DIR/$svc" ]; then
+ if $CHECK_ONLY; then
+ info "Would generate secret: $svc"
+ else
+ openssl rand -hex 16 | sudo tee "$SECRETS_DIR/$svc" >/dev/null
+ sudo chmod 600 "$SECRETS_DIR/$svc"
+ info "Generated secret: $svc"
+ fi
+ fi
+ done
+
+ if [ ! -f "$SECRETS_DIR/fedimint-gateway-password" ]; then
+ if ! $CHECK_ONLY; then
+ local fpass
+ fpass=$(openssl rand -base64 16)
+ echo "$fpass" | sudo tee "$SECRETS_DIR/fedimint-gateway-password" >/dev/null
+ sudo chmod 600 "$SECRETS_DIR/fedimint-gateway-password"
+ if command -v htpasswd >/dev/null 2>&1; then
+ htpasswd -bnBC 10 "" "$fpass" | tr -d ':\n' | sudo tee "$SECRETS_DIR/fedimint-gateway-hash" >/dev/null
+ sudo chmod 600 "$SECRETS_DIR/fedimint-gateway-hash"
+ fi
+ info "Generated fedimint gateway secret"
+ fi
+ fi
+
+ # Reload after generation
+ detect_environment
+}
+
+# ── Ensure bitcoin.conf ─────────────────────────────────────────────
+ensure_bitcoin_conf() {
+ local BITCOIN_CONF="/var/lib/archipelago/bitcoin/bitcoin.conf"
+ sudo mkdir -p /var/lib/archipelago/bitcoin 2>/dev/null
+ if [ ! -f "$BITCOIN_CONF" ] || ! grep -q "^rpcauth=" "$BITCOIN_CONF" 2>/dev/null; then
+ if ! $CHECK_ONLY && [ -n "$BITCOIN_RPC_PASS" ]; then
+ local salt hash rpcauth
+ salt=$(openssl rand -hex 16)
+ hash=$(echo -n "$BITCOIN_RPC_PASS" | openssl dgst -sha256 -hmac "$salt" -hex 2>/dev/null | awk '{print $NF}')
+ rpcauth="${BITCOIN_RPC_USER}:${salt}\$${hash}"
+ # Only rpcauth + printtoconsole here — all other options are in SPEC_CUSTOM_ARGS
+ # to avoid duplicate bind conflicts
+ sudo tee "$BITCOIN_CONF" >/dev/null << BTCEOF
+rpcauth=${rpcauth}
+printtoconsole=1
+BTCEOF
+ info "Generated bitcoin.conf"
+ fi
+ fi
+ # Strip duplicate server/rpc/listen lines from existing conf to avoid conflicts with custom args
+ if [ -f "$BITCOIN_CONF" ]; then
+ sudo sed -i '/^server=/d; /^rpcbind=/d; /^rpcallowip=/d; /^rpcport=/d; /^listen=/d' "$BITCOIN_CONF" 2>/dev/null
+ fi
+ sudo chown -R 100101:100101 /var/lib/archipelago/bitcoin 2>/dev/null
+}
+
+# ── Ensure lnd.conf ─────────────────────────────────────────────────
+ensure_lnd_conf() {
+ local LND_CONF="/var/lib/archipelago/lnd/lnd.conf"
+ sudo mkdir -p /var/lib/archipelago/lnd 2>/dev/null
+ if [ ! -f "$LND_CONF" ] && [ -n "$BITCOIN_RPC_PASS" ]; then
+ if ! $CHECK_ONLY; then
+ sudo tee "$LND_CONF" >/dev/null << LNDEOF
+[Application Options]
+listen=0.0.0.0:9735
+rpclisten=0.0.0.0:10009
+restlisten=0.0.0.0:8080
+debuglevel=info
+noseedbackup=true
+
+[Bitcoin]
+bitcoin.mainnet=true
+bitcoin.node=bitcoind
+
+[Bitcoind]
+bitcoind.rpchost=bitcoin-knots:8332
+bitcoind.rpcuser=$BITCOIN_RPC_USER
+bitcoind.rpcpass=$BITCOIN_RPC_PASS
+bitcoind.rpcpolling=true
+bitcoind.estimatemode=ECONOMICAL
+
+[autopilot]
+autopilot.active=false
+LNDEOF
+ info "Generated lnd.conf"
+ fi
+ fi
+}
+
+# ── Ensure BTCPay databases ─────────────────────────────────────────
+ensure_btcpay_db() {
+ if container_running "archy-btcpay-db"; then
+ $PODMAN exec archy-btcpay-db psql -U postgres -tc \
+ "SELECT 1 FROM pg_database WHERE datname='nbxplorer'" 2>/dev/null | grep -q 1 || \
+ $PODMAN exec archy-btcpay-db psql -U postgres -c \
+ "CREATE DATABASE nbxplorer;" 2>/dev/null || true
+ fi
+}
+
+# ══════════════════════════════════════════════════════════════════════
+# MAIN
+# ══════════════════════════════════════════════════════════════════════
+START_TIME=$(date +%s)
+
+header "Phase 0: Prerequisites"
+ensure_secrets
+ensure_bitcoin_conf
+ensure_lnd_conf
+
+TIER_NAMES=("Databases" "Core Infrastructure" "Services" "Applications" "Frontend UIs")
+
+for tier in 0 1 2 3 4; do
+ [ -n "$FILTER_TIER" ] && [ "$FILTER_TIER" != "$tier" ] && continue
+
+ header "Tier $tier: ${TIER_NAMES[$tier]}"
+
+ for name in "${ALL_CONTAINER_SPECS[@]}"; do
+ [ -n "$FILTER_CONTAINER" ] && [ "$name" != "$FILTER_CONTAINER" ] && continue
+
+ # Load spec to check tier before reconciling
+ if load_spec "$name" && [ "$SPEC_TIER" = "$tier" ]; then
+ reconcile "$name"
+ fi
+ done
+
+ # After databases, ensure BTCPay DB schemas exist
+ [ "$tier" = "0" ] && ensure_btcpay_db
+
+ # Brief pause between tiers
+ [ "$tier" -lt 4 ] && ! $CHECK_ONLY && sleep 2
+done
+
+# ── Summary ──────────────────────────────────────────────────────────
+ELAPSED=$(( $(date +%s) - START_TIME ))
+TOTAL=$((COUNT_OK + COUNT_FIXED + COUNT_CREATED + COUNT_SKIPPED + COUNT_FAILED))
+
+echo ""
+header "╔══════════════════════════════════════════════════╗"
+header "║ RECONCILIATION REPORT ║"
+header "╚══════════════════════════════════════════════════╝"
+echo ""
+echo -e " Total: ${BOLD}$TOTAL${NC}"
+echo -e " OK: ${GREEN}$COUNT_OK${NC}"
+echo -e " Fixed: ${CYAN}$COUNT_FIXED${NC}"
+echo -e " Created: ${CYAN}$COUNT_CREATED${NC}"
+echo -e " Skipped: ${YELLOW}$COUNT_SKIPPED${NC}"
+echo -e " Failed: ${RED}$COUNT_FAILED${NC}"
+[ -n "$FAILED_LIST" ] && echo -e " Failed: ${RED}$FAILED_LIST${NC}"
+echo -e " Duration: ${ELAPSED}s"
+echo ""
+
+[ "$COUNT_FAILED" -gt 0 ] && exit 1
+exit 0