Manual deployment
The one-command self-host path covers most people. This page is for operators who want to drive Docker themselves: the published all-in-one image, image-only Compose, the source-build Compose stack, or Fly.io.
The managed CLI path is deliberately stricter than these manual examples: stable
server up and server update never use latest. They select an exact vX.Y.Z,
match its source commit to the GitHub tag and its digest to the release receipt,
and run the immutable digest. See Self-host for
its pull, provenance, and update-recovery contract.
Single container (manual)
Section titled “Single container (manual)”One public image runs both services (the MCP server and the dashboard) under a
small supervisor. Pull latest for a quick first run; for a durable deployment,
replace it with a release tag such as v1.20.1 and keep that tag in your
configuration:
docker pull ghcr.io/code-ministry-ltd/the-librarian:latestumask 077printf 'LIBRARIAN_AGENT_TOKEN=%s\nLIBRARIAN_SECRET_KEY=%s\n' \ "$(openssl rand -base64 48)" "$(openssl rand -hex 32)" > librarian.envdocker run -d --name the-librarian \ --restart unless-stopped \ -p 127.0.0.1:3042:3000 -p 127.0.0.1:3838:3838 \ -v librarian_data:/data \ --env-file librarian.env \ ghcr.io/code-ministry-ltd/the-librarian:latestKey points:
- The dashboard is at
http://<host>:3042(the host side of-p 3042:3000— the container always listens on 3000 internally); the MCP endpoint ishttp://<host>:3838/mcp. /datais your vault and settings — back it up (see Backups & restore). It must be writable by the image’s user (UID 1000); on platforms that mount volumes root-owned,chownit.- Keep
librarian.envmode0600, back it up securely, and read its agent token when connecting clients. Docker stores container environment values in its own metadata too, so restrict access to the Docker daemon. - There is no admin token. The admin API runs only on an internal listener inside
the container; the published port carries only the agent surface, gated by
LIBRARIAN_AGENT_TOKEN. (More on this model in Authentication & secrets.) - The master key auto-generates if unset. On first boot the server writes it to
/data/secret.keyand logs it once — copy it somewhere safe. Supplying it via the environment (as above) is the recommended posture, because an env-supplied key is never written to the data volume. A 32-byte hex key (openssl rand -hex 32) is what the secret key wants — not the base64 value used for tokens. - Put the published port behind TLS on any host reachable beyond loopback, and do
not set
LIBRARIAN_ALLOW_NO_AUTH=trueon a publicly reachable host. - If the curator’s LLM provider is only reachable on a tailnet (for example a
Tailscale-Serve hostname), add
--dns 100.100.100.100to thedocker runcommand — Docker’s default nameservers cannot resolve tailnet-only names. CLI-managed deploys (librarian server up/update) take--dns 100.100.100.100instead; that choice is stored soserver updatedoes not drop it. The Image-only Compose section below covers the why (first-resolver-wins, and IP-based access failing on Tailscale Serve). - The image crash-fasts if either service dies, so your orchestrator restarts the pair.
Image-only Compose (recommended manual path)
Section titled “Image-only Compose (recommended manual path)”This path needs Docker Compose but no source checkout or local image build. Download the deployment files, protect the env file, and replace both credential placeholders:
mkdir the-librarian && cd the-librariancurl -fsSLO https://raw.githubusercontent.com/code-ministry-ltd/the-librarian/main/docker/docker-compose.image.ymlcurl -fsSLO https://raw.githubusercontent.com/code-ministry-ltd/the-librarian/main/docker/all-in-one.env.examplecp all-in-one.env.example .envchmod 0600 .envGenerate an agent token with openssl rand -base64 48 and a master key with
openssl rand -hex 32, then put their outputs in .env. Keep latest for the
first run or set LIBRARIAN_VERSION=vX.Y.Z to pin a release. Render the
configuration without printing its interpolated credentials, then start it:
docker compose --env-file .env -f docker-compose.image.yml config --quietdocker compose --env-file .env -f docker-compose.image.yml up -d
curl http://127.0.0.1:3838/healthzcurl http://127.0.0.1:3042/api/healthBoth ports bind to loopback by default. If clients connect over a private network,
set the two *_PUBLISHED_HOST values in .env; if the dashboard is reachable
beyond loopback, terminate TLS in front of it and treat network access as admin
access. The default named volume is librarian_data. For a bind mount, set
LIBRARIAN_DATA_SOURCE to an absolute host path and set LIBRARIAN_DATA_UID /
LIBRARIAN_DATA_GID to its owner.
If the curator’s LLM provider is only reachable on a tailnet (for example a
Tailscale-Serve hostname), give the container the Tailscale resolver in .env:
LIBRARIAN_DNS=100.100.100.100LIBRARIAN_DNS_FALLBACK=8.8.8.8 # optional second nameserverBoth values are opt-in — leave them unset for Docker’s default DNS. Two gotchas apply to that order: the container’s resolver (c-ares) never consults a later nameserver after an NXDOMAIN, so the Tailscale resolver must come first; and reaching the provider by IP fails, because Tailscale Serve TLS needs the hostname (SNI), not the address. Tailscale MagicDNS forwards public lookups, so the single resolver is usually enough.
Use exact version tags for controlled upgrades and rollbacks:
# Edit LIBRARIAN_VERSION in .env, then:docker compose --env-file .env -f docker-compose.image.yml pulldocker compose --env-file .env -f docker-compose.image.yml up -dTo roll back, put the previous vX.Y.Z in .env and run the same two commands.
The data volume is preserved. Run bundled admin commands directly in the container,
for example docker compose --env-file .env -f docker-compose.image.yml exec the-librarian the-librarian backup. Do not use docker compose down -v unless
you intend to delete the vault.
Fly.io
Section titled “Fly.io”A starter fly.toml is included. It enables single-port mode on Fly’s HTTPS
dashboard service. Edit app, primary_region, and LIBRARIAN_PUBLIC_URL together,
then configure the agent token and the browser origins:
fly volumes create librarian_data --size 1fly secrets set \ LIBRARIAN_AGENT_TOKEN=… \ LIBRARIAN_SECRET_KEY=… \ LIBRARIAN_ALLOWED_ORIGINS=https://your-app.fly.dev,chrome-extension://<extension-id>fly deployThe starter temporarily leaves TLS port 3838 published for existing clients. Point
clients at https://your-app.fly.dev/mcp, migrate them, then remove the
[[services]] block from fly.toml so 443 is the only public port. The admin tRPC
listener is never published.
One published port
Section titled “One published port”Single-port mode keeps the MCP server on its internal listener and proxies its public agent routes through the dashboard. A reverse proxy or hosting platform then needs to expose only the dashboard listener:
GET /healthzandGET /primer.mdremain public, matching the MCP listener./mcp,/transcript, and/ingestrequire the same bearer tokens and origin checks as direct port 3838 traffic. Dashboard cookies are stripped.- The dashboard refuses those three protected routes if the MCP server is running
without agent authentication. Set
LIBRARIAN_AGENT_TOKENorLIBRARIAN_AGENT_TOKENS, and do not setLIBRARIAN_ALLOW_NO_AUTH=true.
All four deployment settings matter:
LIBRARIAN_SINGLE_PORT=trueLIBRARIAN_PUBLIC_URL=https://memory.example.comLIBRARIAN_ALLOWED_ORIGINS=https://memory.example.com,chrome-extension://<extension-id>LIBRARIAN_AGENT_TOKEN=<long-random-agent-token>LIBRARIAN_PUBLIC_URL keeps the Connect page on
https://memory.example.com/mcp instead of advertising port 3838. The origin list
must contain both entries: once a non-empty allow-list exists, the capture
extension’s scheme is no longer admitted automatically.
For the all-in-one image, publish only the dashboard port and put it behind TLS:
docker run -d --name the-librarian \ -p 127.0.0.1:3042:3000 \ -v librarian_data:/data \ -e LIBRARIAN_SINGLE_PORT=true \ -e LIBRARIAN_PUBLIC_URL=https://memory.example.com \ -e LIBRARIAN_ALLOWED_ORIGINS=https://memory.example.com,chrome-extension://<extension-id> \ -e LIBRARIAN_AGENT_TOKEN=<long-random-agent-token> \ -e LIBRARIAN_SECRET_KEY=<64-hex-master-key> \ ghcr.io/code-ministry-ltd/the-librarian:vX.Y.ZTerminate HTTPS at the reverse proxy and forward the public origin to container port
3000. Do not publish container port 3838. For image-only Compose, put the four
values above in .env and remove the MCP port mapping from
docker-compose.image.yml, then expose only dashboard port 3042 through TLS. For
the source-build stack below, remove the mcp-server service’s ports: mapping
(or keep its default loopback-only binding during migration) and expose only
dashboard port 3839.
Two-container Compose stack (advanced)
Section titled “Two-container Compose stack (advanced)”The Compose stack runs two Node services — mcp-server (the agent surface on the
published port 3838, plus the admin API on a separate unpublished internal port)
and dashboard (the Next.js admin UI on port 3000) — sharing one named volume.
Copy the repository to your host, create an env file, and set an agent token (the one network credential):
cp .env.example .envopenssl rand -base64 48 # generate an agent tokenPut it in .env:
LIBRARIAN_AGENT_TOKEN=<long-random-agent-token>There is no admin token to set. The master key (LIBRARIAN_SECRET_KEY) is
optional — it auto-generates if unset; set it to keep the key off the data volume.
If you want each agent’s writes attributed to a distinct identity, use per-agent
tokens:
LIBRARIAN_AGENT_TOKENS=codex:<token-a>,claude:<token-b>By default both services bind to 127.0.0.1 only. For tailnet access, set the
published hosts:
LIBRARIAN_MCP_PUBLISHED_HOST=100.x.y.zLIBRARIAN_DASHBOARD_PUBLISHED_HOST=100.x.y.zIf the curator’s LLM provider is also reachable only on the tailnet, set the
Tailscale resolver in .env too (first-resolver-wins — see the Image-only
Compose section above): LIBRARIAN_DNS=100.100.100.100. (CLI-managed
all-in-one deploys use librarian server update --dns 100.100.100.100 instead.)
Build and start, then verify:
docker compose --env-file .env -f docker/docker-compose.yml up -d --build
curl http://100.x.y.z:3838/healthzcurl http://100.x.y.z:3839/health(If the dashboard health check fails the first time, give it ~15 seconds — Next.js
cold-boots slower than the MCP server. If you see permission denied under /data,
stop the stack, chown -R 1000:1000 the volume’s data directory, and start again.)
Endpoints
Section titled “Endpoints”- Dashboard:
http://<host>:3042/(single-container default; the Compose stack above publishes it on:3839instead) - MCP endpoint (
/mcp):http://<host>:3838/mcp— agents POST JSON-RPC here with anAuthorization: Bearer <token>header. The server offers no standalone server-push SSE stream, so authenticated GET probes receive405 Method Not Allowed. - Healthcheck (
/healthz):http://<host>:3838/healthz - Primer (
/primer.md):http://<host>:3838/primer.md— the agent briefing, served without authentication by design so tools like OpenCode can load it from a URL. It is the only unauthenticated route; keep the briefing generic (never secret) content. - Transcript (
/transcript) and capture (/ingest): protected agent routes used by the harness integrations and browser capture extension.
Treat dashboard network access as admin access, and keep the published host on a
private network. If you front the dashboard with a reverse proxy on another hostname,
add that exact origin to LIBRARIAN_ALLOWED_ORIGINS.
Source-build Compose operations
Section titled “Source-build Compose operations”# View logsdocker compose --env-file .env -f docker/docker-compose.yml logs -f
# Upgradegit pulldocker compose --env-file .env -f docker/docker-compose.yml up -d --build
# Stopdocker compose --env-file .env -f docker/docker-compose.yml downKeep the data volume on local disk, not NFS or another unreliable network
filesystem, and push vault backups off-server. Stopping with down -v wipes the
data volume — only do that when you mean to destroy everything.