diff --git a/README.md b/README.md
index 6fe01c1..27c6468 100644
--- a/README.md
+++ b/README.md
@@ -5,29 +5,49 @@ Source of truth for the LibreChat evaluation instance at
appstore package `ai.librechat.cloudronapp@1.4.1`, which bundles **LibreChat
v0.8.8** on image `cloudron/ai.librechat.cloudronapp:202610150224320000`).
-This repo exists so the sandbox never drifts from what is deployed: the config
-file here is byte-identical to `/app/data/librechat.yaml`, the MCP server here
-is byte-identical to `/app/data/mcp/server.py`, and the Docker image is built
-from this directory.
+The deployment has **two independent layers** — know which one you are changing:
+
+1. **Persistent volume** (`/app/data/…`) — behavior: `librechat.yaml`, `env`,
+ `mcp/*.py`. Survives image updates. Edited via `cloudron exec`, then
+ mirrored into this repo (see "Volume changes" below).
+2. **Overlay image** (this repo's Dockerfile) — appearance only: logo,
+ favicons, `custom.css`, `custom2.js`, `
`. Rebuilt and redeployed to
+ change branding.
+
+The md5s of `librechat.yaml`, `mcp/server.py`, and `mcp/image_server.py` in
+this repo are kept byte-identical to the live volume files. Verify with:
+
+```bash
+APP=4023ccbf-d57a-4500-97df-14de9cf3516e
+cloudron --server my.inference.coop --token "$CLOUDRON_TOKEN" exec --app $APP \
+ -- md5sum /app/data/librechat.yaml /app/data/mcp/server.py /app/data/mcp/image_server.py
+md5sum librechat.yaml mcp/server.py mcp/image_server.py
+```
## Architecture: what lives where
| Layer | Location | Survives image update? | Repo mirror |
|---|---|---|---|
| App config | `/app/data/librechat.yaml` (volume) | yes | `librechat.yaml` |
-| Env vars (env-file wins!) | `/app/data/env` (volume, sourced LAST by start.sh) | yes | changed lines documented below |
+| Env vars (env-file wins!) | `/app/data/env` (volume, sourced LAST by start.sh) | yes | documented below |
| Env vars (Cloudron-managed) | `cloudron env` (process env) | yes | documented below |
-| MCP servers | `/app/data/mcp/server.py` + `/app/data/mcp/image_server.py` (volume) | yes | `mcp/` |
-| Brand assets (logo, favicons) | baked into image overlay | no — rebuilt per image | `assets/` |
-| CloudronManifest.json | repo only (used by `cloudron update`) | n/a | byte-identical copy of the OFFICIAL appstore manifest |
+| MCP servers | `/app/data/mcp/server.py` + `mcp/image_server.py` (volume) | yes | `mcp/` |
+| Brand assets + branding CSS/JS | baked into image overlay | no — rebuilt per image | `assets/` |
+| `CloudronManifest.json` | repo only (controls what `cloudron update` deploys) | n/a | official appstore manifest **except `dockerImage`** |
-**Precedence gotcha:** `start.sh` does `source /run/librechat/env` AFTER the
+**Precedence gotcha:** `start.sh` sources `/run/librechat/env` AFTER the
process env, and `/app/data/env` is appended into it — so a value defined in
`/app/data/env` **overrides** the same `cloudron env` variable. That is why
`ALLOW_EMAIL_LOGIN=false` must be set in `/app/data/env`, not (only) via
`cloudron env`.
-## Deploy a config change (no image rebuild needed)
+## Volume changes (librechat.yaml, mcp/*.py, env)
+
+> **Volume warning:** edits you make to the files in this repo do **not**
+> deploy with the image. The image never touches `/app/data`. Apply changes
+> to the live files first, then mirror them here.
+
+Deploy a config change (no image rebuild needed):
```bash
B64=$(base64 -w0 librechat.yaml)
@@ -35,45 +55,102 @@ cloudron --server my.inference.coop --token "$CLOUDRON_TOKEN" exec \
--app 4023ccbf-d57a-4500-97df-14de9cf3516e \
-- bash -c "echo '$B64' | base64 -d > /app/data/librechat.yaml"
cloudron --server my.inference.coop --token "$CLOUDRON_TOKEN" restart \
- --app 4023ccbf-d57a-4500-97df-14de9cf3516e
+ --app 4023ccbf-d57a-4500-97df-14de9cf3516e --timeout 900
```
-Always `diff` the repo file against the live one after deploying. Always
-deploy via base64 — heredocs and `sed` inserts have corrupted this file twice
-(literal `$(...)` / bad indentation → LibreChat silently falls back to
-defaults with only one log line).
+- Always deploy via base64 — heredocs and `sed` inserts have corrupted this
+ file twice (literal `$(...)` / bad indentation → LibreChat silently falls
+ back to defaults with only one log line).
+- Always `diff` the repo file against the live one after deploying.
+- **Reading large live files back over `cloudron exec`** cannot be done with
+ a plain `cat` (a single large stdout stream resets the connection,
+ `ECONNRESET`). Pipe it small instead:
-Verify after every change:
+ ```bash
+ cloudron --server my.inference.coop --token "$CLOUDRON_TOKEN" exec \
+ --app 4023ccbf-d57a-4500-97df-14de9cf3516e \
+ -- bash -c "gzip -c /app/data/librechat.yaml | base64 -w0 | fold -w 100" > /tmp/live.b64
+ tr -d '\n' < /tmp/live.b64 | base64 -d | gunzip > /tmp/live-librechat.yaml
+ ```
+
+Verify after every config change:
```bash
-cloudron logs --app --lines 60 | grep -iE 'custom config|error|invalid|MCP'
-curl -s https://librechat-test.inference.coop/api/config # appTitle, emailLoginEnabled, socialLogins
+cloudron logs --app 4023ccbf-d57a-4500-97df-14de9cf3516e --lines 60 | grep -iE 'custom config|error|invalid|MCP'
+curl -s https://librechat-test.inference.coop/api/config # appTitle, emailLoginEnabled
```
-## Build + deploy the image (brand assets only)
+## Build + deploy the image (branding only)
+
+> **Two traps have each silently eaten deploys of this overlay.** Both look
+> like success. Run the verification block below after every deploy.
+
+1. **`cloudron update --image ` is IGNORED when this directory contains
+ `CloudronManifest.json`.** The CLI deploys `manifest.dockerImage` instead —
+ while still printing your `--image` value in its output, so it *looks*
+ right. The tag you build MUST be written into
+ `CloudronManifest.json.dockerImage` before updating.
+2. **The remote builder's cache can serve stale COPY layers even when file
+ content changed** (observed 2026-10-19: six consecutive "Build successful"
+ runs produced images with old assets). Build with `--no-cache` whenever
+ anything under `assets/` changed.
+
+Full deploy sequence:
```bash
-cd /tmp/librechat-custom # THIS dir: cloudron update reads CloudronManifest.json here
-git add -A && git commit -m "..." && git push
+cd /path/to/librechat-custom # the dir containing CloudronManifest.json
+# 1. edit assets/ … then:
+git add -A && git commit -m "…" && git push
SHA=$(git rev-parse --short HEAD)
-cloudron builder build --repository registry.inference.coop/co-op/librechat-custom \
+# 2. build fresh layers (no-cache after asset edits)
+cloudron builder build --no-cache \
+ --repository registry.inference.coop/co-op/librechat-custom \
--tag 20261019-$SHA
+# 3. pin the manifest to the new tag — the ONLY field you may change
+# (edit "dockerImage" in CloudronManifest.json to :20261019-$SHA)
+git add CloudronManifest.json && git commit -m "Pin manifest to $SHA" && git push
+# 4. deploy — the manifest drives it
cloudron --server my.inference.coop --token "$CLOUDRON_TOKEN" update \
- --app 4023ccbf-d57a-4500-97df-14de9cf3516e \
- --image registry.inference.coop/co-op/librechat-custom:20261019-$SHA
+ --app 4023ccbf-d57a-4500-97df-14de9cf3516e --force
```
-Rollback: rerun the `update` with the previous tag (or the appstore image
-`cloudron/ai.librechat.cloudronapp:`).
+Rollback: repoint `CloudronManifest.json.dockerImage` to the previous tag
+(every build is tagged `20261019-`; the base image
+`cloudron/ai.librechat.cloudronapp:` is the pre-overlay fallback) and
+rerun step 4.
-**NEVER edit `CloudronManifest.json`.** `cloudron update` reads it from this
-directory and applies it to the app. A hand-edited manifest that omits the
-addons (`postgresql`, `mongodb`, `oidc`, `sendmail`, `localstorage`) strips
-them from the running app and crash-loops it (`mkdir: Read-only file system`).
-That is what broke the sandbox on 2026-10-18 — the manifest, not the overlay.
-When the appstore package updates, refresh this file with a new copy pulled
-from the Cloudron API (`GET /api/v1/apps/ → .manifest`) and bump the
-`FROM` tag in the Dockerfile to the new `manifest.dockerImage`.
+### Verify after every image deploy (all four, or it didn't happen)
+
+```bash
+APP=4023ccbf-d57a-4500-97df-14de9cf3516e
+# 1. What is ACTUALLY running? (CLI update output is not evidence)
+curl -s -H "Authorization: Bearer $CLOUDRON_TOKEN" \
+ https://my.inference.coop/api/v1/apps/$APP \
+ | python3 -c "import json,sys; print(json.load(sys.stdin)['app']['manifest']['dockerImage'])"
+# 2. Container files match the repo (catches stale builder cache)
+cloudron --server my.inference.coop --token "$CLOUDRON_TOKEN" exec --app $APP \
+ -- md5sum /app/code/librechat/client/dist/assets/custom.css
+md5sum assets/custom.css
+# 3. Served HTML carries the injection
+curl -s https://librechat-test.inference.coop/login | grep -Eo 'assets/custom2?\.js|assets/custom\.css'
+# 4. Rendered DOM shows it (browser devtools or a browser tool):
+# computed background of div.bg-surface-primary == rgb(250, 248, 245);
+# the "Don't have an account?"
computes display: none.
+```
+
+### `CloudronManifest.json` rules
+
+- The **only** field you may change is `dockerImage` — it must point at the
+ overlay tag you want deployed.
+- Everything else stays a byte-identical copy of the OFFICIAL appstore
+ manifest. A hand-edited manifest that omits the addons (`postgresql`,
+ `mongodb`, `oidc`, `sendmail`, `localstorage`) strips them from the running
+ app and crash-loops it (`mkdir: Read-only file system`). That broke the
+ sandbox on 2026-10-18 — the manifest, not the overlay, was the bug.
+- When the appstore package updates, refresh the file with a new copy from
+ the Cloudron API (`GET /api/v1/apps/` → `.manifest`), repoint
+ `dockerImage` to your rebuilt overlay, and bump the `FROM` tag in the
+ Dockerfile to the new appstore `dockerImage` (pinned — never a floating tag).
## Env vars (live state)
@@ -93,42 +170,68 @@ Set via `cloudron env set` (process env):
`POST /key/generate {"key_alias":"librechat-speech","models":[...],"max_budget":5,"budget_duration":"30d"}`
— never put the master key here.
- `IMAGE_GEN_OAI_BASEURL=https://gateway.inference.coop/v1` and
- `IMAGE_GEN_OAI_MODEL=regolo/qwen-image` — the agent "OpenAI Image Tools"
- points at the co-op gateway. No `IMAGE_GEN_OAI_API_KEY`: members enter their
- own co-op key in the tool UI (per-member billing, same pattern as the chat
- endpoint's `apiKey: user_provided`).
+ `IMAGE_GEN_OAI_MODEL=regolo/qwen-image` — the built-in agent "OpenAI Image
+ Tools" points at the co-op gateway. No admin key is configured; members
+ using the AGENT path paste their own key in the tool UI (v0.8.8
+ limitation). The MCP path below needs no key at all.
- `SEARXNG_INSTANCE_URL=https://search.inference.coop` — referenced from the
YAML as `${SEARXNG_INSTANCE_URL}` (self-hosted search; keyless JSON API).
+## Branding overlay (custom.css / custom2.js)
+
+Injected into the built SPA by the Dockerfile (`` + `