diff --git a/.dockerignore b/.dockerignore
index 1dea9f1ec..61a7c1262 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -5,7 +5,12 @@ node_modules
**/node_modules
dist
**/dist
+apps/web/dist
+*.tsbuildinfo
*.log
+.DS_Store
+e2e
.env
.env.*
!.env.example
+!**/.env.example
diff --git a/.env.example b/.env.example
index 1b6c19c04..171f7755c 100644
--- a/.env.example
+++ b/.env.example
@@ -27,11 +27,15 @@ HUB_ALLOW_GIT_INSIDE_WORK_TREE=1
# HUB_ADMIN_PASSWORD=password123
# 32 bytes hex each (openssl rand -hex 32). Generate your own outside a laptop.
+# BETTER_AUTH_SECRET=
CREDENTIAL_ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000
PRINCIPAL_KEY_ENCRYPTION_KEY=1111111111111111111111111111111111111111111111111111111111111111
# Kept separate from the hub's key so a sidecar compromise cannot read the hub's database.
SIDECAR_CREDENTIAL_ENCRYPTION_KEY=2222222222222222222222222222222222222222222222222222222222222222
+# Built web client directory served by the hub in single-origin deployments (set in Dockerfile).
+# WEB_DIST_DIR=
+
# Max tool-package tarball the registry accepts. Default 10 MiB.
# HUB_MAX_TARBALL_BYTES=10485760
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 000000000..ce5e7dd83
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,7 @@
+FROM oven/bun:1.4.2
+WORKDIR /app
+COPY . .
+RUN bun install --frozen-lockfile && bun run --cwd apps/web build
+ENV WEB_DIST_DIR=/app/apps/web/dist
+EXPOSE 3000
+CMD ["bun", "apps/hub/src/index.ts"]
diff --git a/IMPLEMENTATION.md b/IMPLEMENTATION.md
index f8c6e3a65..c901407e6 100644
--- a/IMPLEMENTATION.md
+++ b/IMPLEMENTATION.md
@@ -43,15 +43,6 @@ surfaced tools, qwen2.5:7b does not, so deferral needs 14b or better.
## Deployment
-Deployment mechanics are not yet settled:
-
-- Target hosting platform and process topology (one hub, how many
- sidecars, where they run) are undecided. Sidecars run as child
- processes of the hub; there is no other provisioner backend.
-- Secrets management for `CREDENTIAL_ENCRYPTION_KEY` and
- `PRINCIPAL_KEY_ENCRYPTION_KEY` in a real deployment is undecided.
-- Migration/rollout ordering across `hub`, `sidecar`, and `web` on deploy
- is undecided.
-- Backup/restore for `HUB_DATA_DIR` and the database is undecided.
-
-Do not assume any of the above from this document alone.
+The root `Dockerfile` builds a single-origin production image on `oven/bun:1.4.2`: it installs workspace dependencies, builds `apps/web` (which also bundles `@corbits/worker`), sets `WEB_DIST_DIR=/app/apps/web/dist`, and starts `apps/hub/src/index.ts` on port `3000`. When `WEB_DIST_DIR` (or `PORTAL_DIR`) is set, the hub serves the compiled web bundle and SPA fallback alongside `/api/*`, `/status`, and `/openapi.json`, and derives `DB_HOST`/`DB_PORT`/`DB_USER`/`DB_PASSWORD`/`DB_NAME` from `DATABASE_URL`. Sidecars run as child processes of the hub (`apps/sidecar/src/index.ts`) with durable state under `HUB_DATA_DIR`.
+
+`compose.yml` pairs that image with `pgvector/pgvector:pg17` and persistent volumes for Postgres and `HUB_DATA_DIR`. See [docs/SELF_HOST.md](docs/SELF_HOST.md) for Docker Compose, cloud container hosts, required secrets, and backups.
diff --git a/README.md b/README.md
index 06c99f773..0a0e9dea8 100644
--- a/README.md
+++ b/README.md
@@ -7,6 +7,20 @@ worker builds whatever workflows, tools, or skills a job needs.
## Run it
+### Docker Compose
+
+Run Postgres 17 (with `pgvector`) and the hub (serving the web client on port 3000) in containers:
+
+```sh
+cp .env.example .env # set the encryption keys outside a laptop: openssl rand -hex 32 each
+docker compose up --build -d
+docker compose logs -f hub
+```
+
+Open `http://localhost:3000`. For cloud deployment, environment variables, and backups, see [docs/SELF_HOST.md](docs/SELF_HOST.md).
+
+### Local development
+
Needs [Bun](https://bun.sh) (version in `.bun-version`) and Postgres 17
with pgvector. `docker compose -f compose.test.yml up -d` gives you the
same Postgres CI uses.
@@ -45,6 +59,7 @@ bun run test:e2e # end-to-end suites, need DATABASE_URL
## Read next
- [PRODUCT.md](PRODUCT.md): what a workbench, its worker, tools, and skills are.
+- [docs/SELF_HOST.md](docs/SELF_HOST.md): self-hosting with Docker Compose or any container host.
- [ARCHITECTURE.md](ARCHITECTURE.md) and [IMPLEMENTATION.md](IMPLEMENTATION.md): how the hub, sidecar, and web client are built.
- [docs/local-dev.md](docs/local-dev.md): running locally with Ollama, memory embeddings, and OAuth providers.
- [docs/GLOSSARY.md](docs/GLOSSARY.md): product-term to platform-term mapping.
diff --git a/apps/hub/src/index.ts b/apps/hub/src/index.ts
index dba8d5205..ca2602964 100644
--- a/apps/hub/src/index.ts
+++ b/apps/hub/src/index.ts
@@ -1,3 +1,12 @@
import { createHubServer } from "./server";
+import { populateDbEnvFromDatabaseUrl, withStaticWeb } from "./static-web";
-export default await createHubServer();
+populateDbEnvFromDatabaseUrl();
+
+const server = await createHubServer();
+const webDistDir = process.env["WEB_DIST_DIR"] ?? process.env["PORTAL_DIR"];
+
+export default {
+ ...server,
+ fetch: withStaticWeb(webDistDir, server.fetch),
+};
diff --git a/apps/hub/src/static-web.test.ts b/apps/hub/src/static-web.test.ts
new file mode 100644
index 000000000..32b6fefee
--- /dev/null
+++ b/apps/hub/src/static-web.test.ts
@@ -0,0 +1,84 @@
+import { describe, expect, test } from "bun:test";
+import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import path from "node:path";
+import {
+ createStaticWebHandler,
+ isStaticWebRequest,
+ populateDbEnvFromDatabaseUrl,
+ withStaticWeb,
+} from "./static-web";
+
+function makeDistDir(): string {
+ const dir = mkdtempSync(path.join(tmpdir(), "workbench-static-web-"));
+ mkdirSync(path.join(dir, "assets"), { recursive: true });
+ writeFileSync(path.join(dir, "index.html"), '
INDEX
');
+ writeFileSync(path.join(dir, "assets", "app-abc123.js"), "console.log('ok');");
+ return dir;
+}
+
+describe("static web serving", () => {
+ test("serves hashed assets with immutable Cache-Control and SPA routes with index.html", async () => {
+ const dir = makeDistDir();
+ const serve = createStaticWebHandler(dir);
+
+ const assetRes = await serve(new Request("http://localhost:3000/assets/app-abc123.js"));
+ expect(assetRes.status).toBe(200);
+ expect(assetRes.headers.get("cache-control")).toBe("public, max-age=31536000, immutable");
+ expect(await assetRes.text()).toBe("console.log('ok');");
+
+ const spaRes = await serve(new Request("http://localhost:3000/benches/default/threads/123"));
+ expect(spaRes.status).toBe(200);
+ expect(spaRes.headers.get("cache-control")).toBe("no-cache");
+ expect(await spaRes.text()).toContain("INDEX");
+ });
+
+ test("returns 404 for missing asset files with an extension instead of index.html", async () => {
+ const dir = makeDistDir();
+ const serve = createStaticWebHandler(dir);
+
+ const missing = await serve(new Request("http://localhost:3000/assets/missing.js"));
+ expect(missing.status).toBe(404);
+ });
+
+ test("never serves files outside the dist directory on path traversal", async () => {
+ const dir = makeDistDir();
+ writeFileSync(path.join(tmpdir(), "outside-secret.txt"), "TOP_SECRET");
+ const serve = createStaticWebHandler(dir);
+
+ const escaped = await serve(new Request("http://localhost:3000/%2e%2e%2foutside-secret.txt"));
+ expect(escaped.status).toBe(200);
+ expect(await escaped.text()).toContain("INDEX");
+ });
+
+ test("delegates /api/*, /status, /openapi.json, and non-GET/HEAD requests to the hub", async () => {
+ const dir = makeDistDir();
+ const wrapped = withStaticWeb(dir, () => Response.json({ hub: true }));
+
+ expect(isStaticWebRequest(new Request("http://localhost:3000/api/me"))).toBe(false);
+ expect(isStaticWebRequest(new Request("http://localhost:3000/status"))).toBe(false);
+ expect(isStaticWebRequest(new Request("http://localhost:3000/openapi.json"))).toBe(false);
+ expect(
+ isStaticWebRequest(new Request("http://localhost:3000/benches", { method: "POST" })),
+ ).toBe(false);
+
+ const apiRes = await wrapped(new Request("http://localhost:3000/api/me"), undefined);
+ expect(await apiRes.json()).toEqual({ hub: true });
+
+ const rootRes = await wrapped(new Request("http://localhost:3000/"), undefined);
+ expect(await rootRes.text()).toContain("INDEX");
+ });
+
+ test("populates DB_* variables from DATABASE_URL without overwriting explicit overrides", () => {
+ const env: Record = {
+ DATABASE_URL: "postgres://workbench:secret%40pass@postgres:5433/workbench_prod",
+ DB_HOST: "explicit-host",
+ };
+ populateDbEnvFromDatabaseUrl(env);
+ expect(env["DB_HOST"]).toBe("explicit-host");
+ expect(env["DB_PORT"]).toBe("5433");
+ expect(env["DB_USER"]).toBe("workbench");
+ expect(env["DB_PASSWORD"]).toBe("secret@pass");
+ expect(env["DB_NAME"]).toBe("workbench_prod");
+ });
+});
diff --git a/apps/hub/src/static-web.ts b/apps/hub/src/static-web.ts
new file mode 100644
index 000000000..8f3d1ed4f
--- /dev/null
+++ b/apps/hub/src/static-web.ts
@@ -0,0 +1,107 @@
+import { existsSync } from "node:fs";
+import path from "node:path";
+
+const HUB_API_PREFIXES = ["/api/", "/status", "/openapi.json"];
+
+/**
+ * Derive `DB_HOST`, `DB_PORT`, `DB_USER`, `DB_PASSWORD`, and `DB_NAME` from
+ * `DATABASE_URL` when `DATABASE_URL` is set and the individual variables are
+ * not, so container and self-hosted deployments only need to set `DATABASE_URL`.
+ */
+export function populateDbEnvFromDatabaseUrl(
+ env: Record = process.env,
+): void {
+ const databaseUrl = env["DATABASE_URL"];
+ if (databaseUrl === undefined || databaseUrl.trim() === "") return;
+ let url: URL;
+ try {
+ url = new URL(databaseUrl);
+ } catch {
+ throw new Error(`DATABASE_URL is not a valid URL: ${JSON.stringify(databaseUrl)}`);
+ }
+ env["DB_HOST"] ??= url.hostname;
+ env["DB_PORT"] ??= url.port === "" ? "5432" : url.port;
+ if (url.username !== "") {
+ env["DB_USER"] ??= decodeURIComponent(url.username);
+ }
+ if (url.password !== "") {
+ env["DB_PASSWORD"] ??= decodeURIComponent(url.password);
+ }
+ const dbName = url.pathname.replace(/^\//, "");
+ if (dbName !== "") {
+ env["DB_NAME"] ??= dbName;
+ }
+}
+
+export function isStaticWebRequest(req: Request): boolean {
+ if (req.method !== "GET" && req.method !== "HEAD") return false;
+ const { pathname } = new URL(req.url);
+ if (pathname === "/api") return false;
+ return !HUB_API_PREFIXES.some((prefix) => pathname.startsWith(prefix));
+}
+
+export function createStaticWebHandler(distDir: string): (req: Request) => Promise {
+ const root = path.resolve(distDir);
+ const indexPath = path.resolve(root, "index.html");
+ if (!existsSync(indexPath)) {
+ throw new Error(`WEB_DIST_DIR has no index.html: ${root}`);
+ }
+
+ function resolveAssetPath(pathname: string): string | null {
+ try {
+ const decoded = decodeURIComponent(pathname);
+ if (decoded.includes("\0")) return null;
+ const candidate = path.resolve(root, `.${decoded}`);
+ if (candidate === root || candidate === indexPath) return indexPath;
+ return candidate.startsWith(root + path.sep) ? candidate : null;
+ } catch {
+ return null;
+ }
+ }
+
+ return async function serveStaticWeb(req: Request): Promise {
+ const { pathname } = new URL(req.url);
+ const candidate = resolveAssetPath(pathname);
+ if (candidate !== null && candidate !== indexPath) {
+ const file = Bun.file(candidate);
+ if (await file.exists()) {
+ const cacheControl = pathname.startsWith("/assets/")
+ ? "public, max-age=31536000, immutable"
+ : "public, max-age=0, must-revalidate";
+ return new Response(req.method === "HEAD" ? null : file, {
+ headers: {
+ ...(file.type !== "" ? { "Content-Type": file.type } : {}),
+ "Cache-Control": cacheControl,
+ },
+ });
+ }
+ if (path.extname(pathname) !== "") {
+ return new Response("Not Found", {
+ status: 404,
+ headers: { "Content-Type": "text/plain; charset=utf-8" },
+ });
+ }
+ }
+
+ const indexFile = Bun.file(indexPath);
+ return new Response(req.method === "HEAD" ? null : indexFile, {
+ headers: {
+ "Content-Type": "text/html; charset=utf-8",
+ "Cache-Control": "no-cache",
+ },
+ });
+ };
+}
+
+export function withStaticWeb(
+ distDir: string | undefined,
+ next: (req: Request, server: Server) => Response | Promise,
+): (req: Request, server: Server) => Response | Promise {
+ const trimmed = distDir?.trim();
+ if (trimmed === undefined || trimmed === "") return next;
+ const serveStatic = createStaticWebHandler(trimmed);
+ return function fetchWithStaticWeb(req: Request, server: Server) {
+ if (isStaticWebRequest(req)) return serveStatic(req);
+ return next(req, server);
+ };
+}
diff --git a/compose.yml b/compose.yml
new file mode 100644
index 000000000..ace7cca6e
--- /dev/null
+++ b/compose.yml
@@ -0,0 +1,37 @@
+services:
+ postgres:
+ image: pgvector/pgvector:pg17
+ command: postgres -c idle_in_transaction_session_timeout=10min
+ environment:
+ POSTGRES_USER: workbench
+ POSTGRES_PASSWORD: workbench
+ POSTGRES_DB: workbench
+ volumes:
+ - postgres:/var/lib/postgresql/data
+ healthcheck:
+ test: ["CMD", "pg_isready", "-U", "workbench", "-d", "workbench"]
+ interval: 2s
+ timeout: 5s
+ retries: 5
+ restart: unless-stopped
+
+ hub:
+ build: .
+ env_file: .env
+ environment:
+ DATABASE_URL: postgres://workbench:workbench@postgres:5432/workbench
+ HUB_DATA_DIR: /data
+ PORT: "3000"
+ BETTER_AUTH_BASE_URL: http://localhost:3000
+ volumes:
+ - hub-data:/data
+ ports:
+ - "3000:3000"
+ depends_on:
+ postgres:
+ condition: service_healthy
+ restart: unless-stopped
+
+volumes:
+ postgres:
+ hub-data:
diff --git a/docs/SELF_HOST.md b/docs/SELF_HOST.md
new file mode 100644
index 000000000..5622dec7b
--- /dev/null
+++ b/docs/SELF_HOST.md
@@ -0,0 +1,137 @@
+# Self-host
+
+How to run Corbits Workbench yourself: what you need, how to run the container image, first sign-in setup, and every setting. Local development is in [local-dev.md](local-dev.md).
+
+## What you need
+
+To try it on your machine with [Docker Compose](#docker-compose):
+
+- Docker.
+- One inference provider connected after sign-in: an API key (Anthropic, OpenAI, Google, xAI, OpenRouter, or any OpenAI-compatible endpoint), a subscription login (Codex or xAI), or a local [Ollama](https://ollama.com) instance with no key.
+
+A real deployment also needs:
+
+- Postgres 17 with `pgvector` (`pgvector/pgvector:pg17`), required by `@corbits/memory`.
+- One machine or container for the hub, built from the root `Dockerfile`, with a persistent volume for `HUB_DATA_DIR` and a public HTTPS domain. When `WEB_DIST_DIR` is set (as the `Dockerfile` sets it), the hub serves the built web client, the API (`/api/...`), and inbound webhooks (`/api/hooks/...`) on that single origin.
+
+Size the hub for its sidecars: the hub spawns local sidecars as Bun child processes (`apps/sidecar/src/index.ts`), one per active workflow allocation.
+
+## Run it
+
+### Docker Compose
+
+`compose.yml` runs Postgres 17 (with `pgvector`) and the hub at `http://localhost:3000`. In `.env`, set the four 32-byte hex secrets; Compose sets `DATABASE_URL`, `HUB_DATA_DIR`, `PORT`, and `BETTER_AUTH_BASE_URL`, and applies the [database role](#database-role) timeout.
+
+```sh
+cp .env.example .env
+# Generate four distinct 32-byte hex secrets (openssl rand -hex 32 each):
+# CREDENTIAL_ENCRYPTION_KEY, PRINCIPAL_KEY_ENCRYPTION_KEY,
+# SIDECAR_CREDENTIAL_ENCRYPTION_KEY, BETTER_AUTH_SECRET
+docker compose up --build -d
+docker compose logs -f hub
+```
+
+Open `http://localhost:3000` once the hub logs `Starting server on port 3000`.
+
+### Railway
+
+1. Create a project and add a Postgres service with the `pgvector` extension enabled (for example the `pgvector/pgvector:pg17` image).
+2. Add the `hub` service from this repository. It builds the root `Dockerfile`.
+3. Generate a public domain on port `3000` and mount a persistent volume at `/data`.
+4. Set the environment variables on `hub` (each secret is a distinct `openssl rand -hex 32`):
+
+ ```sh
+ railway variable set --service hub \
+ BETTER_AUTH_SECRET= \
+ CREDENTIAL_ENCRYPTION_KEY= \
+ PRINCIPAL_KEY_ENCRYPTION_KEY= \
+ SIDECAR_CREDENTIAL_ENCRYPTION_KEY= \
+ 'DATABASE_URL=${{Postgres.DATABASE_URL}}' \
+ BETTER_AUTH_BASE_URL=https:// \
+ HUB_PUBLIC_URL=https:// \
+ HUB_DATA_DIR=/data \
+ PORT=3000
+ ```
+
+5. Apply the [database role](#database-role) setting on Postgres.
+
+### Render or any other container host
+
+1. Provision Postgres 17 with `pgvector`. Add `?sslmode=require` to `DATABASE_URL` when reached over the public internet.
+2. Build and run the root `Dockerfile` with a persistent disk mounted at `/data`.
+3. Set the [required variables](#required) and any optional ones.
+4. Terminate TLS in front of the hub on port `3000`.
+
+### Database role
+
+On every Postgres outside `compose.yml`, set `idle_in_transaction_session_timeout` on the hub's database role so wedged transactions release their locks after ten minutes:
+
+```sql
+alter role set idle_in_transaction_session_timeout = '10min';
+```
+
+## First sign-in in the app
+
+1. **Sign up.** Open the hub URL and create your account. Your first sign-in creates your workbench (a plain Interchange tenant) and deploys the default worker definition (`@corbits/worker`).
+2. **Connect an inference provider.** Onboarding prompts for one provider—paste an API key (Anthropic, OpenAI, Google, xAI, OpenRouter), sign in with Codex or xAI, or point at a local Ollama URL (`http://localhost:11434/v1`). Provider keys live encrypted in the hub's vault and are never read from environment variables.
+3. **Start a thread.** Message the workbench worker to run tasks, author workflows, or connect MCP servers and skills.
+
+## Configuration
+
+`.env.example` lists every supported variable. Secrets are runtime variables, never build arguments.
+
+### Required
+
+| Variable | Value |
+| --- | --- |
+| `DATABASE_URL` | `postgres://` or `postgresql://` connection string. The hub derives `DB_HOST`, `DB_PORT`, `DB_USER`, `DB_PASSWORD`, and `DB_NAME` from it when those individual variables are unset. |
+| `HUB_DATA_DIR` | Directory for durable git repositories, workflow assets, and sidecar state. Mount a persistent volume here (e.g. `/data`). |
+| `CREDENTIAL_ENCRYPTION_KEY` | 32-byte hex key (`openssl rand -hex 32`) that seals tenant credentials at rest. |
+| `PRINCIPAL_KEY_ENCRYPTION_KEY` | 32-byte hex key (`openssl rand -hex 32`) that seals per-principal Ed25519 signing keys at rest. |
+| `SIDECAR_CREDENTIAL_ENCRYPTION_KEY` | 32-byte hex key (`openssl rand -hex 32`) that sidecars use to seal their local run credential store, kept separate from the hub keys. |
+| `BETTER_AUTH_SECRET` | 32-byte hex secret (`openssl rand -hex 32`) used by Better Auth to sign session cookies in production. |
+| `BETTER_AUTH_BASE_URL` | Public URL users open in the browser (for example `http://localhost:3000` or `https://workbench.example.com`). Sign-up rejects requests from other origins with `Invalid origin`. |
+
+### Web UI and networking
+
+| Variable | Default | Effect |
+| --- | --- | --- |
+| `PORT` | `3000` | HTTP port the hub listens on. |
+| `WEB_DIST_DIR` | unset | Path to the built `apps/web/dist` bundle. Set to `/app/apps/web/dist` in the `Dockerfile` so the hub serves the SPA and static assets on the same origin. (`PORTAL_DIR` is accepted as an alias.) |
+| `HUB_PUBLIC_URL` | `http://127.0.0.1:` | Public hub origin that OAuth callbacks (such as MCP server OAuth logins) redirect back to. Set to your `https://` origin in production. |
+| `HUB_SIDECAR_WEBSOCKET_URL` | `ws://127.0.0.1:/api/sidecars/ws` | WebSocket URL local sidecars dial back to. |
+| `HUB_MAX_TARBALL_BYTES` | `10485760` | Maximum tool-package tarball size accepted by the hub registry (bytes). |
+
+### Memory plane (`@corbits/memory`)
+
+Optional. When `EMBED_BASE_URL` and `OLLAMA_BASE_URL` are both unset, the hub boots normally without mounting vector recall.
+
+| Variable | Default | Effect |
+| --- | --- | --- |
+| `EMBED_BASE_URL` | falls back to `OLLAMA_BASE_URL` | OpenAI-compatible or Ollama embeddings endpoint URL. |
+| `EMBED_MODEL` | `nomic-embed-text` | Embedding model name. |
+| `EMBED_API_KEY` | unset | Bearer key for the embeddings endpoint, if required. |
+| `RERANK_BASE_URL`, `RERANK_MODEL` | unset | Optional Text Embeddings Inference reranker; set both or neither. |
+| `RERANK_API_KEY` | unset | Bearer key for the reranker endpoint, if required. |
+
+### Sign-in and OAuth adapters
+
+| Variable | Default | Effect |
+| --- | --- | --- |
+| `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET` | unset | Enable "Continue with Google" sign-in. Set callback URI to `/api/auth/callback/google`. |
+| `SIDECAR_ADAPTER_MANIFEST` | Codex + xAI adapters (see `.env.example`) | JSON array of `{provider, specifier, export}` adapters loaded by sidecars for non-stock providers. |
+
+### Web UI hosted separately
+
+If you host `apps/web/dist` on a static CDN instead of serving it from the hub via `WEB_DIST_DIR`:
+
+- Unset `WEB_DIST_DIR` on the hub.
+- Proxy `/api/*` from the web host to the hub's origin so requests stay same-origin, and set `BETTER_AUTH_BASE_URL` to the public URL users open in their browser.
+
+## Backups
+
+Back up all three together:
+
+- **Postgres**: tenants, accounts, principals, grants, mailbox threads, artifacts, cron schedules, and encrypted credentials.
+- **The `HUB_DATA_DIR` volume**: git repositories for workflow definitions, run history, and sidecar state.
+- **The encryption keys** (`CREDENTIAL_ENCRYPTION_KEY`, `PRINCIPAL_KEY_ENCRYPTION_KEY`, `SIDECAR_CREDENTIAL_ENCRYPTION_KEY`, and `BETTER_AUTH_SECRET`): store them separately from the database backup. A database backup without the keys that sealed its vault cannot decrypt stored credentials or signing keys.