diff --git a/apps/bitcoin-core/manifest.yml b/apps/bitcoin-core/manifest.yml
index 6cdd5faa..c8619f7e 100644
--- a/apps/bitcoin-core/manifest.yml
+++ b/apps/bitcoin-core/manifest.yml
@@ -38,6 +38,16 @@ app:
RPC_CONF="/tmp/rpc.conf";
umask 077;
{ echo "rpcuser=$RPC_USER"; echo "rpcpassword=$RPC_PASS"; } > "$RPC_CONF";
+ # A stray bitcoin.conf in the datadir is FATAL when -conf points
+ # elsewhere: bitcoind refuses to start with "contains a bitcoin.conf
+ # file which is ignored", and the app crash-loops (100.82.34.38,
+ # 2026-08-05 — Exited(1) every few seconds). Our -conf carries the
+ # RPC credentials and the flags below are the authoritative config,
+ # so the datadir file is legacy debris; say so out loud rather than
+ # failing, and let bitcoind start.
+ if [ -f /home/bitcoin/.bitcoin/bitcoin.conf ]; then
+ echo "archipelago: ignoring legacy /home/bitcoin/.bitcoin/bitcoin.conf; RPC config comes from $RPC_CONF and the flags below" >&2;
+ fi;
RPC_TXRELAY_AUTH="$(printenv BITCOIN_RPC_TXRELAY_RPCAUTH || true)";
DISK_GB_VALUE="$(printenv DISK_GB || true)";
RPC_HEADROOM="-rpcthreads=16 -rpcworkqueue=256";
@@ -46,9 +56,9 @@ app:
RPC_TXRELAY_FLAGS="$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips";
fi;
if [ "${DISK_GB_VALUE:-0}" -lt 1000 ]; then
- exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -printtoconsole=0 -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=1024 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
+ exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -allowignoredconf=1 -printtoconsole=0 -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=1024 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
else
- exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -printtoconsole=0 -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 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
+ exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -allowignoredconf=1 -printtoconsole=0 -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 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
fi
derived_env:
- key: DISK_GB
diff --git a/apps/bitcoin-knots/manifest.yml b/apps/bitcoin-knots/manifest.yml
index 9d7967d4..cdc492c5 100644
--- a/apps/bitcoin-knots/manifest.yml
+++ b/apps/bitcoin-knots/manifest.yml
@@ -38,6 +38,16 @@ app:
RPC_CONF="/tmp/rpc.conf";
umask 077;
{ echo "rpcuser=$RPC_USER"; echo "rpcpassword=$RPC_PASS"; } > "$RPC_CONF";
+ # A stray bitcoin.conf in the datadir is FATAL when -conf points
+ # elsewhere: bitcoind refuses to start with "contains a bitcoin.conf
+ # file which is ignored", and the app crash-loops (100.82.34.38,
+ # 2026-08-05 — Exited(1) every few seconds). Our -conf carries the
+ # RPC credentials and the flags below are the authoritative config,
+ # so the datadir file is legacy debris; say so out loud rather than
+ # failing, and let bitcoind start.
+ if [ -f /home/bitcoin/.bitcoin/bitcoin.conf ]; then
+ echo "archipelago: ignoring legacy /home/bitcoin/.bitcoin/bitcoin.conf; RPC config comes from $RPC_CONF and the flags below" >&2;
+ fi;
RPC_TXRELAY_AUTH="$(printenv BITCOIN_RPC_TXRELAY_RPCAUTH || true)";
DISK_GB_VALUE="$(printenv DISK_GB || true)";
RPC_HEADROOM="-rpcthreads=16 -rpcworkqueue=256";
@@ -46,9 +56,9 @@ app:
RPC_TXRELAY_FLAGS="$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips";
fi;
if [ "${DISK_GB_VALUE:-0}" -lt 1000 ]; then
- exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -printtoconsole=0 -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=2048 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
+ exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -allowignoredconf=1 -printtoconsole=0 -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=2048 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
else
- exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -printtoconsole=0 -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 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
+ exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -allowignoredconf=1 -printtoconsole=0 -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 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
fi
derived_env:
- key: DISK_GB
diff --git a/apps/portainer/manifest.yml b/apps/portainer/manifest.yml
index 70fdac89..868e114e 100644
--- a/apps/portainer/manifest.yml
+++ b/apps/portainer/manifest.yml
@@ -6,7 +6,7 @@ app:
category: development
container:
- image: 146.59.87.168:3000/lfg2025/portainer:2.19.4
+ image: 146.59.87.168:3000/lfg2025/portainer:2.39.1
pull_policy: if-not-present
data_uid: "1000:1000"
diff --git a/core/archipelago/src/appgate/mod.rs b/core/archipelago/src/appgate/mod.rs
index dee3e7e0..9dc4090b 100644
--- a/core/archipelago/src/appgate/mod.rs
+++ b/core/archipelago/src/appgate/mod.rs
@@ -154,6 +154,11 @@ impl AppGate {
action: &str,
client_ip: IpAddr,
) -> Response
{
+ // Assets are GET and pre-auth by nature: the login page cannot
+ // render its own background or logo without them.
+ if let Some(name) = action.strip_prefix("asset/") {
+ return self.serve_asset(name);
+ }
if req.method() != Method::POST {
return login_page(app, None, StatusCode::OK);
}
@@ -187,6 +192,26 @@ impl AppGate {
}
}
+ /// Static assets the login page needs, served from the gate's own origin.
+ ///
+ /// The backgrounds are ~1 MB each, so inlining them as data URIs would
+ /// bloat every challenge response. Serving them here keeps the page
+ /// byte-identical to the dashboard's login while the CSP stays tight:
+ /// `img-src 'self' data:` and nothing else.
+ fn serve_asset(&self, name: &str) -> Response {
+ let Some((bytes, mime)) = read_ui_asset(name) else {
+ return not_found();
+ };
+ Response::builder()
+ .status(StatusCode::OK)
+ .header(header::CONTENT_TYPE, mime)
+ // Immutable art; caching it costs nothing and keeps the login
+ // instant on a repeat challenge.
+ .header(header::CACHE_CONTROL, "public, max-age=86400")
+ .body(Body::from(bytes))
+ .expect("asset response builds")
+ }
+
async fn do_login(&self, app: &GatedPort, form: &Form, client_ip: IpAddr) -> Response {
let password = field(form, "password").unwrap_or_default();
@@ -428,43 +453,158 @@ fn esc(s: &str) -> String {
/// none. Inlined as a data URI rather than linked: the gate is answering on
/// the app's own port, so any asset URL would either hit the unauthenticated
/// app behind it or a different origin the browser may not reach.
+/// One stacked layer per background, each delayed so they cross-fade in turn.
+fn background_layers() -> String {
+ let step = LOGIN_BACKGROUNDS.len() as u32 * 9 / LOGIN_BACKGROUNDS.len() as u32;
+ LOGIN_BACKGROUNDS
+ .iter()
+ .enumerate()
+ .map(|(i, name)| {
+ format!(
+ r#""#,
+ prefix = GATE_PREFIX,
+ delay = i as u32 * step,
+ )
+ })
+ .collect()
+}
+
fn icon_markup(app: &GatedPort) -> String {
- if let Some(path) = &app.icon {
- if let Some(data_uri) = read_icon_data_uri(path) {
- return format!(r#"
"#, esc(&data_uri));
- }
- }
- let letter = app
- .app_name
- .chars()
- .next()
- .map(|c| c.to_uppercase().to_string())
- .unwrap_or_else(|| "?".to_string());
- format!(r#"{}
"#, esc(&letter))
+ let inner = app
+ .icon
+ .as_deref()
+ .and_then(read_icon_data_uri)
+ // A manifest that names no icon still gets one: the dashboard already
+ // ships icons named after the app, so fall back to those before
+ // giving up. Without this EVERY gated app showed a lettermark,
+ // because no manifest declares metadata.icon (archi-dev-box,
+ // 2026-08-05).
+ .or_else(|| {
+ icon_candidates(&app.app_id)
+ .iter()
+ .find_map(|c| read_icon_data_uri(c))
+ })
+ .map(|data_uri| format!(r#"
"#, esc(&data_uri)))
+ .unwrap_or_else(|| {
+ let letter = app
+ .app_name
+ .chars()
+ .find(|c| c.is_alphanumeric())
+ .map(|c| c.to_uppercase().to_string())
+ .unwrap_or_else(|| "?".to_string());
+ format!(r#"{}
"#, esc(&letter))
+ });
+ format!(r#"{inner}
"#)
}
/// Icons live with the web UI. Only files under the icon directory are read,
/// and only known image extensions — the path comes from a manifest, which is
/// signed, but treating it as untrusted costs nothing.
-fn read_icon_data_uri(icon_path: &str) -> Option {
- let name = std::path::Path::new(icon_path).file_name()?.to_str()?;
- let mime = match name.rsplit_once('.')?.1.to_ascii_lowercase().as_str() {
- "svg" => "image/svg+xml",
- "png" => "image/png",
- "webp" => "image/webp",
- "jpg" | "jpeg" => "image/jpeg",
- _ => return None,
+/// Icon basenames to try for an app id, best first.
+///
+/// The shipped icon set is named for the *product*, while app ids carry
+/// packaging detail — `filebrowser` vs `file-browser`, `morphos-server` vs
+/// `morphos` — and the per-app screens (`lnd-ui`, `bitcoin-ui`, `electrs-ui`)
+/// have no icon of their own but obviously belong to the app they front.
+/// Resolving those here keeps the mapping in one readable place instead of
+/// adding a `metadata.icon` line to every manifest, which would have to be
+/// re-signed into the catalog to take effect.
+fn icon_candidates(app_id: &str) -> Vec {
+ let mut out = vec![app_id.to_string()];
+ let alias = match app_id {
+ "filebrowser" => Some("file-browser"),
+ "home-assistant" => Some("homeassistant"),
+ "morphos-server" => Some("morphos"),
+ "barkd" => Some("bark"),
+ "archy-mempool-web" | "mempool-api" => Some("mempool"),
+ "lnd-ui" | "lightning-stack" => Some("lnd"),
+ "bitcoin-ui" => Some("bitcoin-core"),
+ "electrs-ui" => Some("electrumx"),
+ "fips-ui" | "aiui" | "did-wallet" => Some("archipelago-a"),
+ "fedimint-gateway" | "fedimint-clientd" => Some("fedimint"),
+ _ => None,
};
+ out.extend(alias.map(str::to_string));
+ // `-ui` / `-server` / `-web` front an app whose icon is the bare name.
+ for suffix in ["-ui", "-server", "-web"] {
+ if let Some(base) = app_id.strip_suffix(suffix) {
+ out.push(base.to_string());
+ }
+ }
+ out
+}
+
+/// Backgrounds the login cycles through, matching the dashboard's own
+/// `/login` art. Cross-faded by CSS alone — the CSP forbids script, and a
+/// rotation that needs JavaScript would not survive it.
+const LOGIN_BACKGROUNDS: [&str; 4] = [
+ "bg-intro.jpg",
+ "bg-intro-4.webp",
+ "bg-intro-6.webp",
+ "bg-intro-3.jpg",
+];
+
+/// Assets the gate will serve, by exact name. An allowlist rather than a path
+/// join: the name arrives in a URL, and the gate answers before any
+/// authentication, so nothing here may be caller-controlled beyond this set.
+fn read_ui_asset(name: &str) -> Option<(Vec, &'static str)> {
+ let allowed = LOGIN_BACKGROUNDS.contains(&name) || name == "logo-archipelago.svg";
+ if !allowed {
+ return None;
+ }
+ let mime = icon_mime(name.rsplit_once('.')?.1)?;
for root in [
- "/opt/archipelago/web-ui/assets/img/app-icons",
- "web/dist/neode-ui/assets/img/app-icons",
+ "/opt/archipelago/web-ui/assets/img",
+ "web/dist/neode-ui/assets/img",
+ "neode-ui/public/assets/img",
] {
- let candidate = std::path::Path::new(root).join(name);
- if let Ok(bytes) = std::fs::read(&candidate) {
- if bytes.len() > 512 * 1024 {
- return None;
+ if let Ok(bytes) = std::fs::read(std::path::Path::new(root).join(name)) {
+ return Some((bytes, mime));
+ }
+ }
+ None
+}
+
+const ICON_ROOTS: [&str; 2] = [
+ "/opt/archipelago/web-ui/assets/img/app-icons",
+ "web/dist/neode-ui/assets/img/app-icons",
+];
+
+fn icon_mime(ext: &str) -> Option<&'static str> {
+ match ext.to_ascii_lowercase().as_str() {
+ "svg" => Some("image/svg+xml"),
+ "png" => Some("image/png"),
+ "webp" => Some("image/webp"),
+ "jpg" | "jpeg" => Some("image/jpeg"),
+ _ => None,
+ }
+}
+
+/// Read an app icon as a `data:` URI.
+///
+/// `icon_ref` may be a filename or path with an extension (a manifest's
+/// `metadata.icon`), or a bare name such as an app id — in which case the
+/// known extensions are tried in turn. Only the file name is used; the
+/// directories searched are fixed, so a manifest cannot point the gate at an
+/// arbitrary path.
+fn read_icon_data_uri(icon_ref: &str) -> Option {
+ let name = std::path::Path::new(icon_ref).file_name()?.to_str()?;
+ let candidates: Vec<(String, &str)> = match name.rsplit_once('.') {
+ Some((_, ext)) => vec![(name.to_string(), icon_mime(ext)?)],
+ None => ["svg", "png", "webp", "jpg"]
+ .iter()
+ .filter_map(|ext| Some((format!("{name}.{ext}"), icon_mime(ext)?)))
+ .collect(),
+ };
+ for (file, mime) in candidates {
+ for root in ICON_ROOTS {
+ let candidate = std::path::Path::new(root).join(&file);
+ if let Ok(bytes) = std::fs::read(&candidate) {
+ if bytes.len() > 512 * 1024 {
+ continue;
+ }
+ return Some(format!("data:{mime};base64,{}", base64_encode(&bytes)));
}
- return Some(format!("data:{mime};base64,{}", base64_encode(&bytes)));
}
}
None
@@ -484,29 +624,79 @@ fn page(title: &str, app: &GatedPort, body: &str, status: StatusCode) -> Respons
{title} — {app_name}
-{body}"#,
+{backgrounds}{body}
"#,
title = esc(title),
app_name = esc(&app.app_name),
body = body,
+ backgrounds = background_layers(),
+ cycle = LOGIN_BACKGROUNDS.len() as u32 * 9,
+ hold = 100 / LOGIN_BACKGROUNDS.len() as u32,
+ fade = 100 / LOGIN_BACKGROUNDS.len() as u32 + 4,
);
Response::builder()
.status(status)
@@ -514,10 +704,18 @@ button:hover {{ background:#2f6fd6; }}
// The gate answers on the app's own port for an unauthenticated
// caller; nothing here should be cached or framed.
.header(header::CACHE_CONTROL, "no-store")
- .header("X-Frame-Options", "DENY")
+ // NOT X-Frame-Options: DENY. My Apps opens an app in an embedded
+ // frame, so a blanket DENY made every gated app render as "app is
+ // not responding" the moment the gate challenged it (reported on
+ // 100.82.34.38, 2026-08-05). frame-ancestors is the modern control
+ // and can be precise: only pages from this same node may frame the
+ // login, on any port or scheme, which is exactly the dashboard.
+ // Anything else — another site embedding it to harvest the node
+ // password — is still refused.
.header(
"Content-Security-Policy",
- "default-src 'none'; img-src data:; style-src 'unsafe-inline'; form-action 'self'",
+ "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
+ form-action 'self'; frame-ancestors 'self' http://*:* https://*:*",
)
.body(Body::from(html))
.expect("static response builds")
@@ -528,7 +726,8 @@ button:hover {{ background:#2f6fd6; }}
/// password by an unexplained page.
fn login_page(app: &GatedPort, error: Option<&str>, status: StatusCode) -> Response {
let body = format!(
- r#"{icon}
+ r#"
+{icon}
Sign in to open {name}
This app is protected by your node password.
{err}
@@ -636,11 +835,61 @@ mod tests {
assert!(!html.contains("
bool {
}
}
+/// Primary OTA origin. Named host over TLS rather than the bare IP it used
+/// to be: the IP pinned the fleet to one machine and one plaintext port, so
+/// moving or fronting the origin meant an OTA to change where OTAs come
+/// from — the one update you cannot ship if the origin is unreachable. The
+/// signature is what establishes trust (see `trust::anchor`), not the
+/// transport, but HTTPS also stops a network observer seeing which version
+/// a node runs.
const DEFAULT_UPDATE_MANIFEST_URL: &str =
+ "https://source.archipelago-foundation.org/lfg2025/archy/raw/branch/main/releases/manifest.json";
+
+/// The previous IP-based origin, kept as an automatic fallback so a node
+/// whose DNS or TLS is broken still updates. Dropped from the mirror list
+/// once the fleet has moved.
+const LEGACY_UPDATE_MANIFEST_URL: &str =
"http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/releases/manifest.json";
const UPDATE_STATE_FILE: &str = "update_state.json";
const UPDATE_MIRRORS_FILE: &str = "update-mirrors.json";
@@ -113,10 +126,19 @@ fn mirrors_path(data_dir: &Path) -> std::path::PathBuf {
}
fn default_mirrors() -> Vec {
- vec![UpdateMirror {
- url: DEFAULT_UPDATE_MANIFEST_URL.to_string(),
- label: "Server 1 (OVH)".to_string(),
- }]
+ vec![
+ UpdateMirror {
+ url: DEFAULT_UPDATE_MANIFEST_URL.to_string(),
+ label: "Archipelago Foundation".to_string(),
+ },
+ // Fallback, tried only if the named origin fails: a node whose DNS
+ // or clock is wrong (both break TLS) must still be able to update
+ // itself, and the signature check is what makes either source safe.
+ UpdateMirror {
+ url: LEGACY_UPDATE_MANIFEST_URL.to_string(),
+ label: "Direct (fallback)".to_string(),
+ },
+ ]
}
/// Load the operator-configured mirror list. Returns defaults if the
@@ -186,15 +208,18 @@ fn force_ovh_update_primary(list: &mut Vec) {
}
for mirror in list.iter_mut() {
if mirror.url == DEFAULT_UPDATE_MANIFEST_URL {
- mirror.label = "Server 1 (OVH)".to_string();
+ mirror.label = "Archipelago Foundation".to_string();
+ } else if mirror.url == LEGACY_UPDATE_MANIFEST_URL {
+ mirror.label = "Direct (fallback)".to_string();
}
}
- list.sort_by_key(|m| {
- if m.url == DEFAULT_UPDATE_MANIFEST_URL {
- 0
- } else {
- 1
- }
+ // Named origin first, its IP fallback second, anything the operator
+ // added after that. Ordering matters: the list is tried in order, so a
+ // stale entry sitting first costs a timeout on every check.
+ list.sort_by_key(|m| match m.url.as_str() {
+ u if u == DEFAULT_UPDATE_MANIFEST_URL => 0,
+ u if u == LEGACY_UPDATE_MANIFEST_URL => 1,
+ _ => 2,
});
}
@@ -2373,8 +2398,16 @@ mod tests {
async fn test_load_mirrors_returns_defaults_when_absent() {
let dir = tempfile::tempdir().unwrap();
let list = load_mirrors(dir.path()).await.unwrap();
- assert_eq!(list.len(), 1);
- assert!(list[0].url.contains("146.59.87.168"));
+ // The named origin leads, its IP fallback follows. A node with broken
+ // DNS or a wrong clock (both break TLS) must still have a way to
+ // update; the signature is what makes either source trustworthy.
+ assert_eq!(list.len(), 2);
+ assert!(
+ list[0].url.starts_with("https://source.archipelago-foundation.org/"),
+ "the named origin must be primary, got {}",
+ list[0].url
+ );
+ assert!(list[1].url.contains("146.59.87.168"));
assert!(
!list.iter().any(|m| m.url.contains("git.tx1138.com")),
"tx1138 was retired as a release server and must not be a default mirror"
diff --git a/scripts/image-versions.sh b/scripts/image-versions.sh
index 6857c803..5988c471 100644
--- a/scripts/image-versions.sh
+++ b/scripts/image-versions.sh
@@ -45,7 +45,13 @@ SEARXNG_IMAGE="$ARCHY_REGISTRY/searxng:latest"
CRYPTPAD_IMAGE="$ARCHY_REGISTRY/cryptpad:2024.12.0"
FILEBROWSER_IMAGE="$ARCHY_REGISTRY/filebrowser:v2.27.0"
NPM_IMAGE="$ARCHY_REGISTRY/nginx-proxy-manager:latest"
-PORTAINER_IMAGE="$ARCHY_REGISTRY/portainer:2.19.4"
+# 2.39.1 is what the fleet has actually been running via the moving :latest
+# tag, and it is the version that wrote their databases. Pinning back to
+# 2.19.4 (2 years older) made Portainer refuse to start the moment a
+# container was recreated: "database schema version does not align with the
+# server version" — it migrates a DB forward, never backward. Pinned
+# forward and published as a concrete tag so this is reproducible.
+PORTAINER_IMAGE="$ARCHY_REGISTRY/portainer:2.39.1"
# Networking
TAILSCALE_IMAGE="$ARCHY_REGISTRY/tailscale:stable"