# Docker operator guide The canonical install for Rembric. Versioned image at `ghcr.io/susomejias/rembric` (public), one process, one SQLite file, one bind-mount. ## Topologies Rembric supports two deployment shapes; the only difference is one line of compose config and where you point the plugin URL. ### Remote host (LXC, NAS, server) — the canonical case Server lives on a different machine than your agent. The compose file ships configured for this — port `8787` is published on **all interfaces** of the host so the agent on your laptop can reach it. Don't expose 8787 to the public internet though — front it with Tailscale, WireGuard, or your VPN. ``` ┌─ your laptop ──────────────────┐ ┌─ LXC / NAS / server ──────────────────┐ │ your coding agent │ │ docker container "rembric" │ │ │ │ │ bind: 0.0.0.0:8787:8787 (default) │ │ │ Tailscale / VPN / LAN │ ─► │ volume: ./data → /data │ │ │ │ │ env_file: .env │ │ ▼ │ │ │ │ http://rembric.tailnet:8787 │ │ │ └────────────────────────────────┘ └───────────────────────────────────────┘ ``` Set `REMBRIC_SERVER_URL` on the plugin side to the reachable hostname: - Tailscale: `http://rembric.your-tailnet.ts.net:8787` - LAN: `http://192.168.x.x:8787` (only if your LAN is trusted) - Reverse proxy with TLS: `https://memory.example.com` (then drop `:8787` if behind 443) The bearer token is the real security boundary; every endpoint requires `Authorization: Bearer `. Belt-and-suspenders defenses (firewall the port to your trust boundary, Tailscale ACLs, reverse-proxy with mTLS, etc.) are operator's call. ### Same host as your agent — loopback override If Rembric and your agent live on the same machine and you want the stricter posture, restrict the publish to loopback via an override file. **This is opt-in** — the default compose binds to all interfaces. ``` ┌─ your laptop / dev box ───────────────────────────┐ │ │ │ your coding agent │ │ │ │ │ │ bridge stdio↔HTTP │ │ ▼ │ │ http://127.0.0.1:8787/mcp/ │ │ │ │ ┌─ docker container "rembric" ───────────────┐ │ │ │ bind: 127.0.0.1:8787:8787 (via override) │ │ │ │ volume: ./data → /data │ │ │ │ env_file: .env │ │ │ └────────────────────────────────────────────┘ │ └───────────────────────────────────────────────────┘ ``` Drop a `docker-compose.override.yml` next to the canonical compose: ```yaml services: rembric: ports: !override - '127.0.0.1:${REMBRIC_PORT:-8787}:${REMBRIC_PORT:-8787}' ``` Compose auto-merges `docker-compose.override.yml` on every `up`. The `!override` tag replaces the ports list rather than appending — without it you'd get two bindings (`0.0.0.0:8787` AND `127.0.0.1:8787`) and the loopback one would be redundant. ## Private GHCR access (during the closed-repo phase) While the image is private, you authenticate against GHCR with a personal access token (classic PAT) that has at least `read:packages` scope. ```bash # create a classic PAT at https://github.com/settings/tokens (scopes: read:packages) echo "$GH_PAT" | docker login ghcr.io -u --password-stdin docker compose pull ``` Once the project goes public, this step disappears — `docker compose pull` works without auth. ## Volumes The compose file bind-mounts `./data:/data` (relative to the compose file's directory). This is **deliberate**: the SQLite file is visible to the operator, easy to back up with regular tools, and easy to inspect with `sqlite3 ./data/data.db`. ### Named volume alternative If you prefer Docker-managed volumes (more portable, but the data is hidden inside the Docker root): ```yaml services: rembric: volumes: - rembric-data:/data volumes: rembric-data: ``` ### Don't bind-mount onto network filesystems SQLite's POSIX-locking guarantees don't hold over NFS / SMB / CIFS. You will eventually corrupt the database. Stick to a local filesystem — Btrfs / ZFS / ext4 / APFS / XFS are all fine. ### UID mismatch on Linux The container runs as `UID 10001` (non-root). If you bind-mount a pre-populated `./data/` whose files are owned by your host user, the container can't write to them and will fail at startup with `EACCES`. Fix: ```bash sudo chown -R 10001:10001 ./data ``` On macOS via Docker Desktop, this is usually transparent (the bind-mount layer handles UID translation). ## Backups The DB is one file: `./data/data.db` (plus `.data/data.db-shm` and `./data/data.db-wal` if you grab the backup while the server is running). The runtime image is [distroless](#the-container-has-no-shell) — there is no shell and no `sqlite3` binary inside it, so `docker compose exec rembric sqlite3 ...` cannot work against the published image. Two recipes that do: **Online backup** (server stays up, no shell needed): Open the dashboard → **Maintenance** → **Backup now**. This runs SQLite's online backup API (`VACUUM INTO`) in-process — safe against concurrent writes, WAL checkpointed atomically — and writes the snapshot to `./data/backups/` on the same bind mount. Download it from the same page (see [backup.md](./backup.md) for the full flow and the restore procedure). **Cold backup** (a few seconds of downtime, works against any image): ```bash docker compose down cp ./data/data.db ./backups/data-$(date +%Y%m%d).db cp ./data/data.db-* ./backups/ 2>/dev/null || true docker compose up -d ``` Always copy the `-shm` and `-wal` siblings — they hold transactions not yet checkpointed into the main file. ### The container has no shell `FROM gcr.io/distroless/nodejs22-debian12` (see `apps/web/Dockerfile`, the published runtime image) ships only the Node runtime and the compiled app — no `/bin/sh`, no package manager, no `sqlite3`. `docker compose exec rembric ` will fail with "executable file not found". Anything you need to run against the database goes through the dashboard (backup/restore, diagnostics) or the host-side `cp` recipe above, never `exec`. ## Upgrade & rollback ### Upgrade (rolling, with `:latest`) ```bash docker compose pull # fetches the new manifest for :latest docker compose up -d # recreates the container with the new image ``` Portainer / Arcane detect the new digest automatically and offer a "Recreate" button — this is the canonical click for the GUI flow. The bind-mounted `./data/` is untouched; migrations are applied at startup. ### Pinning a specific version Edit `.env`: ```ini REMBRIC_VERSION=0.13.0 ``` `docker compose up -d` now uses that exact tag. Useful when you want to gate upgrades manually or run in production-like reproducibility mode. ### Rollback Change `REMBRIC_VERSION` in `.env` to the previous tag and recreate: ```bash docker compose up -d ``` If the new server version included a schema migration that your old data hadn't seen, the rolled-back version boots on the migrated file and loses nothing — but the rollback is not always transparent. Most migrations are additive; the ones that rewrite rows (the global-scope retirement in `0.26.0` moves every user-wide memory into the `default` project) leave the old version reading the new shape, with version-specific consequences. Check the one-time upgrade notes in [updates.md](./updates.md) before rolling back across one of those. Worst case: restore from a backup. ### Watchtower (auto-update) Not recommended yet. Watchtower would auto-pull `:latest` and recreate the container without your involvement — if a release ships a breaking server↔plugin API change (e.g. the `/healthz` auth hardening in `0.13.0`), Watchtower would update the server while your plugin still runs the old `0.5.x` and break silently. Stick to the manual "click after release" pattern until the project stabilizes. ## Embeddings are in-image The embedding model (`gte-multilingual-base`, ONNX q8) ships inside the image and runs in-process — no Ollama, no API keys, no outbound calls. The container works air-gapped. Budget ~1 GB RAM minimum (2 GB recommended); the model loads at boot (~1 s from image files) and a broken model fails the boot — see [docs/embeddings.md](./embeddings.md) for the full flow. ## What's in the image (workspace packages) The image builds from the monorepo root, not from `apps/web/`, because the web app's runtime closure spans four workspace packages: `@rembric/web` (the app), `@rembric/db`, `@rembric/core` and `@rembric/mcp`. Build order is load-bearing: 1. Copy the workspace manifests (`apps/web`, `apps/plugin`, `packages/{config,db,core,mcp}`) plus the lockfile, then `pnpm install --frozen-lockfile --filter @rembric/web... --config.node-linker=hoisted` — that closure only. The flat, npm-style tree is what lets Next's externalised native modules (`better-sqlite3`, `sqlite-vec`, `onnxruntime-node`) resolve from the server chunks. 2. Bake the embedding model from `packages/core/scripts/fetch-model.mjs` **before any source copy**, so the large model layer invalidates only on lockfile or script changes. 3. Copy the source, then build `packages/db`, `packages/core` and `packages/mcp` **before** the app: the app type-imports `@rembric/*` through their `exports` maps to real `dist/` output, so Next's build-time type check fails on missing declarations. `packages/db`'s build also stages `src/migrations` into `dist/`. 4. Assemble the runtime tree: the app code and traced assets come from `apps/web/.next/standalone`, while `node_modules` comes from the flat install (the only layout that resolves the native chunks). A build-time gate then asserts the assembled `packages/db/dist/migrations` holds **exactly 37** `*.sql` files — a drop that would otherwise ship an image booting against a volume it cannot migrate. 5. Prune `onnxruntime-node`'s non-target prebuilt libs (~185 MB), asserting the target binding survives. One consequence worth knowing: - **In-image migrations resolve from the assembled runtime tree**, under `/app/packages/db/dist/migrations`, read back by `defaultMigrationsDir()` from the bundle's `import.meta.url`. Your data path is unchanged: the same migrations run at startup against `./data/data.db`. ## Healthchecks The container's healthcheck calls `GET /healthz` every 30s with `Authorization: Bearer $REMBRIC_ADMIN_TOKEN`. The endpoint runs a `SELECT 1` against SQLite and returns: - `200 {ok:true, version:""}` on success - `503 {ok:false, code:"db_unavailable"}` if the DB is closed / locked / IO-errored - `401` without/with invalid auth External monitoring (Uptime Kuma, Healthchecks.io, Grafana, etc.) needs to send the same bearer header. Use a project-scoped token if you don't want your admin token in those tools — `/healthz` accepts any valid bearer. ## Troubleshooting | Symptom | Cause / Fix | | ------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `pull access denied` / `manifest unknown` on `docker compose pull` | The image is private and you're not authenticated. Run `docker login ghcr.io` with a PAT that has `read:packages`. | | `Error: REMBRIC_ADMIN_TOKEN is not set` on startup | Add the token to `.env`. The server intentionally refuses to start without it. | | Container goes `unhealthy` after upgrade | Check `docker compose logs rembric`. If it's `db_unavailable`, the WAL might be locked by a stale process — `docker compose down && docker compose up -d` clears it. | | Hermes provider says memory is unavailable | The plugin must be on `0.6.0+` to talk to a Rembric `0.13.0+` server. The `/healthz` endpoint now requires auth; older plugins probe without it and see 401. Upgrade the plugin. | | Bind-mount `EACCES` errors in container logs | On Linux: `sudo chown -R 10001:10001 ./data`. On macOS Docker Desktop: usually a permission issue with the parent directory; ensure your user owns the parent of `./data/`. | | Dashboard reachable but the agent can't connect | The agent's `REMBRIC_SERVER_URL` doesn't match the host's published port. Verify with `curl -H "Authorization: Bearer $TOKEN" http:///healthz` and adjust the plugin config. | | Memory list shows nothing after upgrade | You're looking at a freshly-bind-mounted `./data/` that's empty. The previous data is wherever the old install kept it — check the npm-install path `~/.rembric/` and migrate. | ## Local dev (host) When you need to hack on Rembric itself — change source, see the result, iterate — run the Next.js dev server on the host. The Docker dev stack is retired: `docker-compose.dev.yml` (tsx watch + `seed-dev --reset`) went with `apps/server`, and the compose files above describe only the published image. For prod, follow the Quickstart at the top of this doc. ### Quickstart ```bash pnpm install # `next dev` starts the web app (apps/web). Point it away from any real # deployment's database first, or first-run bootstrap mints and prints a token: REMBRIC_DATA_DIR=./data-dev REMBRIC_ADMIN_TOKEN=<16+-char-token> pnpm run dev # → http://127.0.0.1:3000/dashboard (log in with the admin token) ``` The app opens the SQLite file under `REMBRIC_DATA_DIR` (default `~/.rembric`) on boot and applies migrations. `apps/web/src/scripts/seed-dev.ts` populates a demo project, 3 tokens, ~23 memories across 5 `topic_key` clusters, 3 ended sessions with summaries, 2 active sessions and 1 pending judgment so every dashboard surface renders meaningfully on first login: ```bash REMBRIC_DATA_DIR=./data-dev REMBRIC_ALLOW_DESTRUCTIVE_SEED=1 \ pnpm --filter @rembric/core exec tsx ../../apps/web/src/scripts/seed-dev.ts --reset ``` ### Isolated state - **Data.** `REMBRIC_DATA_DIR` (default `~/.rembric`) selects the SQLite file. Point it at a scratch directory (e.g. `./data-dev/`, gitignored) so a dev run never touches a real deployment. - **Loopback only.** `next dev` binds the local interface; nothing reaches your LAN unless you ask it to. - **Never point a dev server and a real deployment at the same `REMBRIC_DATA_DIR`.** Two processes on one SQLite file is a data-safety violation, not a convenience. The `--reset` seed wipes the protected tables first and prints a one-line stderr warning. It deletes from `memory_relations`, `confirmations`, `consolidation_ops`, `consolidation_runs`, `prompts`, `memory`, `sessions`, `tokens`, `projects` (children-first, in one transaction); triggers clean up `memory_vec` and `memory_fts` automatically. Without `--reset` the seed is idempotent and skips when the `demo` project already exists. `--reset` is the only path outside the operator-only purges (`packages/core/src/services/memory.ts::purgeDisconnectedArchived` and `packages/core/src/services/agent-sessions.ts::purgeEmpty`) permitted to `DELETE FROM` the protected tables. The invariant test (`apps/web/src/test/invariants.test.ts`) pins this allow-list and asserts that `apps/web/src/scripts/seed-dev.ts` actually contains the expected `DELETE FROM` strings — so removing the script's seed wouldn't silently expand the allow-list. ### When NOT to run dev on the host - **For a fully packaged image**, build it directly: `docker build -f apps/web/Dockerfile -t rembric-web .` (the context is the repo root). There is no compose build override any more. - **For running unit/integration tests**, use `pnpm test`. Vitest spins up isolated DBs in seconds; the dev server is a long-running sandbox, not a test runner.