Files
ai-memory/docs/deploy.md
T
AkitaOnRails fad242a4fd docs: catch up with the v0.8.x feature surface
Stale-docs audit across the operator-facing surface. None of the
underlying behaviour changed — these are doc fixes for behaviour that
already shipped over the last week of PRs (#60 move-project,
#65 base-path, #68 wikilinks, #70 openai-compat strict, plus the
audit-cleanup follow-ups in the 65682dc…0d32be1 range).

Operator docs
- `README.md`: status badge bumped from v0.2 to v0.8, project
  derivation paragraph rewritten to describe the CLI's main-repo-root
  resolution (worktrees share one project) vs the hook router's
  basename default, write-page example switched to the H1-in-body
  convention (passing `--title` still works but invites issue #67's
  JSON-escape footgun).
- `docs/install.md`: bootstrap `--project` default is now described as
  "derived from cwd" instead of the stale `"scratch"`. Added an
  "Optional serve flags" table covering `--base-path`, `--web-slug`,
  `--web-ui-dir`, `--cors-allow-origin`.
- `docs/deploy.md`: cross-link to the "Hosting under a subpath"
  section in `https-via-proxy.md`.

Admin / lifecycle docs
- `docs/admission-webhooks.md`: payload sample includes the new
  `partial_failure` field (purge-project only, skipped on the wire
  when false). Spelled out that admission now fires BEFORE the SQL
  destruction in both `/admin/purge-project` and the
  `/admin/move-project` copy-purge path, so a `Reject` webhook on
  `purge_project` leaves the source intact. Documented that
  copy-purge fires two distinct webhook events from one request.
- `docs/lifecycle-ops.md`: failure-mode table covers
  `WikiError::DestinationExists` (409) and the block-policy 409 with
  `conflicts` body. `--on-conflict` documented alongside the JSON
  `on_conflict` field for direct `/admin/move-project` callers.
  Dedup naming cites the `DEDUP_FROM_TOKEN` const so the doc
  round-trips with the source. Safety-matrix `move-project` row
  notes the reject-policy escape hatch.

Frontend / web docs
- `docs/frontend-api.md`: removed two stale "Known gaps" claims —
  Cache-Control + ETag and CORS both ship today; §5 documents the
  cache headers and §9 is a new CORS section. Added `/api/v1/graph`
  to the endpoint reference (§4.9). §6 covers the base-path
  normaliser's safety rules (dot-segment rejection, fall-back-to-root
  on unsafe chars, query-preserving trailing-slash redirect).
- `docs/usage.md`: wikilink description spells out the "literal in
  code" guarantee (fence-glyph tracking, indented code, inline code)
  and the external-scheme allowlist; cross-links to the base-path
  flag docs.
- `docs/https-via-proxy.md`: subpath section now mentions the
  dot-segment rejection and the silent root-fallback warning.

LLM / MCP docs
- `docs/llm-provider-comparison.md`: new section on
  `AI_MEMORY_LLM_COMPAT_STRICT`, including the narrowed parse-shape
  fallback (5xx / auth / transport errors propagate now), the
  two-call cost of fallback, and a per-engine recommendation table.
  "When to revisit" entry on strict-JSON-schema availability rewritten
  to reflect that the feature exists today.
- `docs/mcp-install.md`: verify-it-works tool list now includes
  `memory_read_page` and `memory_delete_page` (their omission would
  have made users think their install was broken).

863 workspace tests still pass; fmt + clippy clean. No code touched.
2026-06-02 17:16:28 -03:00

12 KiB

Deploying ai-memory to a homelab

This walks through the pattern documented in bin/deploy. The end state is: a long-lived ai-memory container on your homelab host, reachable on your LAN at http://<host>:49374/mcp, configured with your LLM/embedding API keys, with backups handled by whatever you already use for /var/opt/docker/....

If you want a native Linux service instead of Docker, use the Arch/AUR systemd path in docs/install.md. That install mode uses /var/lib/ai-memory plus /etc/ai-memory/ for the system service, or XDG user paths for the user service. The Docker deployment below remains /data inside the container and /var/opt/docker/... on the host.

Published Docker images include linux/amd64 and linux/arm64 manifests, so homelab hosts on x86_64 and ARM64 can pull the same tag natively.

What gets committed vs. what stays local

The repo ships templates only. The real files with your homelab specifics and API keys live next to them with the .example suffix stripped, and are gitignored.

Committed (template) Live (gitignored) What it holds
bin/deploy (the script itself; safe to commit) The build/push/restart logic
bin/deploy.env.example bin/deploy.env SERVER, DEPLOY_DIR, IMAGE
docker/docker-compose.prod.yml.example docker/docker-compose.prod.yml Image tag, port mapping, volume path
docker/.env.production.example docker/.env.production LLM + embedding API keys

.gitignore excludes the live files. If you ever see one staged, something has drifted - unstage before committing.

First-time setup (one-time)

# 1. Stamp your homelab values into the local config.
cp bin/deploy.env.example bin/deploy.env
$EDITOR bin/deploy.env                # fill SERVER / DEPLOY_DIR / IMAGE

cp docker/docker-compose.prod.yml.example docker/docker-compose.prod.yml
$EDITOR docker/docker-compose.prod.yml   # set the image tag + adjust ports if needed

cp docker/.env.production.example docker/.env.production
$EDITOR docker/.env.production        # fill API keys; pick LLM provider + model

# 2. Create the deploy dir on the homelab. Source bin/deploy.env so
#    SERVER/DEPLOY_DIR are exported in this shell.
source bin/deploy.env
ssh "$SERVER" "sudo mkdir -p $DEPLOY_DIR/data && \
               sudo chown -R 1000:1000 $DEPLOY_DIR"

# 3. Copy the compose + env to the homelab.
scp docker/docker-compose.prod.yml "$SERVER:$DEPLOY_DIR/docker-compose.yml"
scp docker/.env.production         "$SERVER:$DEPLOY_DIR/.env.production"

# 4. Run the first deploy.
bin/deploy

After step 4, the container should be running. Verify:

curl http://<homelab>:49374/mcp
# Expect a JSON-RPC error (which means the port is reachable and the
# server is responding). "Connection refused" means the container
# isn't up or the port mapping is wrong.

ssh "$SERVER" "docker inspect --format='{{.State.Health.Status}}' ai-memory"
# Expect: healthy

Security - bearer-token auth + encrypted transport

The default docker-compose.prod.yml.example binds to 0.0.0.0:49374 so the LAN can reach the MCP endpoint. A LAN-bound server with no auth lets anyone on the network call destructive MCP tools (delete all pages, inject fake observations, drain your LLM budget). ai-memory ships a built-in bearer-token check; turn it on before the first deploy.

# 1. Generate a token (32 bytes / 64 hex chars).
ai-memory generate-auth-token >> docker/.env.production
$EDITOR docker/.env.production    # prefix the new line with AI_MEMORY_AUTH_TOKEN=

# 2. Sync to the homelab + restart.
scp docker/.env.production "$SERVER:$DEPLOY_DIR/.env.production"
ssh "$SERVER" "cd $DEPLOY_DIR && docker compose up -d"

The startup log will now show auth=true. Verify from the laptop:

curl -sI http://homelab:49374/handoff             # → HTTP/1.1 401 Unauthorized
curl -sI http://homelab:49374/handoff \
     -H "Authorization: Bearer $TOKEN"            # → HTTP/1.1 200 OK

Then update every MCP client to send the same token. ai-memory install-mcp --client <name> --auth-token <token> prints the exact snippet per client (Claude Code, Codex, OpenCode, Cursor, Claude Desktop, Gemini CLI, OpenClaw, OMP/Pi, Antigravity CLI). The agent CLI sends an Authorization: Bearer <token> header on every call; ai-memory's middleware validates with a constant-time comparison.

Encrypted transport. Plain HTTP on the LAN means anyone with a packet capture can read the bearer token (and per-user tokens once multi-user mode is on) in transit. Add a TLS-terminating reverse proxy in front of ai-memory — Caddy with Let's Encrypt, Caddy with its internal CA, Cloudflare Tunnel, nginx, or external cert files — when you bind beyond loopback or turn on multi-user.

See docs/https-via-proxy.md for the full deployment guide, including:

  • When to add TLS and when to skip it (the loopback + stdio cases honestly don't need it).
  • Copy-paste docker compose templates in docker/compose.tls.caddy.yml and docker/compose.tls.cloudflared.yml.
  • Per-OS trust-store install for the internal-CA path (the load-bearing manual step).
  • Hosting under a subpath via --base-path / AI_MEMORY_BASE_PATH when ai-memory shares a hostname with other apps.
  • The explicit "what can go wrong" sections so you don't ship security theatre by accident.

For the single-user-on-loopback Quick Start, the bearer token alone remains acceptable — the token is what stops the LAN neighbour, and loopback is what stops the packet capture. TLS earns its keep once the deployment shape stops being "single user, single machine."

Routine deploys

After the first-time setup, every subsequent deploy is just:

bin/deploy

It builds the image locally, pushes to your registry, pulls on the homelab, and restarts. The compose file + env file on the homelab are unchanged between deploys; if you ever need to change them, scp the new copy + re-run bin/deploy.

Updating API keys

$EDITOR docker/.env.production
scp docker/.env.production "$SERVER:$DEPLOY_DIR/.env.production"
ssh "$SERVER" "cd $DEPLOY_DIR && docker compose up -d"

docker compose up -d reads the env file and recreates the container with the new values. No rebuild needed.

LLM provider choices

The .env.production.example defaults to Kimi 2.6 via OpenRouter (openai-compat transport, $0.73/$3.49 per million tokens). Reasonable alternatives:

Provider Model Approx. cost / consolidation Notes
anthropic claude-haiku-4-5 ~$0.02 Recommended default. Best balance of speed, restraint, and classification quality. Not a reasoning model.
openai-compat (OpenRouter) moonshotai/kimi-k2.6 ~$0.013 Reasoning model; latency ~2-3 min per consolidation. Fine because consolidation is fire-and-forget.
openai gpt-5.4-mini ~$0.002 Cheaper, faster alternative. Decent quality.
openai-oauth gpt-5.5 ChatGPT subscription ChatGPT/Codex backend. Run docker exec -it ai-memory ai-memory auth login openai-oauth on the server host so <data_dir>/auth.json lands in the mounted data volume.
copilot gpt-5.5 GitHub Copilot subscription GitHub Copilot Chat backend. Run docker exec -it ai-memory ai-memory auth login copilot on the server host or set COPILOT_GITHUB_TOKEN.
gemini gemini-2.5-flash free tier covers personal use Google hosted, native responseSchema structured output. Set GEMINI_API_KEY (or GOOGLE_API_KEY).
openai-compat (Ollama) qwen3:32b $0 Self-hosted. Set AI_MEMORY_LLM_BASE_URL=http://host.docker.internal:11434/v1. Quality depends on the model.

What we don't recommend: reasoning-mode models (Kimi-K2.6 in reasoning mode, Claude with extended thinking, GPT-o3, Gemini "thinking" variants) — they burn token budget on internal reasoning before emitting output and hang or emit empty responses with the strict-JSON consolidation prompt. If you must use one, turn reasoning off.

ai-memory's hosted OpenAI-family providers use json_schema strict mode for structured output. The OpenAI provider normalizes schemars output into OpenAI's supported subset (additionalProperties: false, complete required, generated enum anyOf, and plain $ref nodes). For openai-compat local or gateway endpoints, the tolerant parser stays the default; set AI_MEMORY_LLM_COMPAT_STRICT=true only after confirming the endpoint honours OpenAI-style response_format=json_schema. If you switch to a niche local model, run a quick ai-memory llm-test before trusting it.

Backups

The data dir is whatever you mounted in docker-compose.prod.yml (default: /var/opt/docker/utils/ai-memory/data/). It contains:

data/
├── wiki/    # markdown — back up with rsync or git push to a remote
├── raw/    # immutable session log archive
├── db/     # memory.sqlite (FTS5 + page_embeddings)
├── logs/   # daily rolling tracing
└── models/ # reserved for future local embedders

For point-in-time consistency:

ssh "$SERVER" "docker exec ai-memory /usr/local/bin/ai-memory backup --to /data/snapshot-$(date +%F).tar.gz"
scp "$SERVER:$DEPLOY_DIR/data/snapshot-$(date +%F).tar.gz" ./backups/

The ai-memory backup command uses SQLite's online backup API so writes during the snapshot are coherent.

Rolling back

ssh "$SERVER" "cd $DEPLOY_DIR && \
               docker tag $IMAGE $IMAGE-rollback && \
               docker pull $IMAGE@sha256:<old-digest>"
ssh "$SERVER" "cd $DEPLOY_DIR && docker compose up -d"

The simplest rollback is to bring back an older image by digest. We don't ship a bin/rollback because the right way is to keep the prior image tag handy before each deploy (Docker Hub keeps every push by digest for free).

Watching logs

ssh "$SERVER" "docker logs -f --tail 100 ai-memory"

Or browse the daily rolling logs on the host:

ssh "$SERVER" "ls -la $DEPLOY_DIR/data/logs/"
ssh "$SERVER" "tail -100 $DEPLOY_DIR/data/logs/ai-memory.log.$(date +%F)"

Troubleshooting

  • Connection refused on curl http://<host>:49374/mcp: the container isn't up, or the port mapping is bound to 127.0.0.1 instead of 0.0.0.0. Check docker ps on the homelab.
  • unhealthy status: the container is running but its embedded ai-memory status healthcheck is failing. Most likely the data dir's permissions don't match the container's user (uid 1000). Fix with sudo chown -R 1000:1000 $DEPLOY_DIR/data on the host.
  • Embedding mismatch after a model change: startup logs a warning when stored (provider, model, dim) triples differ from config. Hybrid search ignores stale rows until they are re-embedded. Start the server normally, then run ai-memory embed --force to rebuild every project in the workspace, or add --project <name> to scope the rebuild. Scheduled embedding backfill can also fill missing rows when enabled.
  • Provider failures: ai-memory status reports passive LLM and embedding health from the last real provider call. A fresh process reports unknown until the server actually uses that role; it does not probe providers or spend tokens for health reporting.
  • Container restart loop: check docker logs ai-memory - the ai-memory starting line at the top reports the resolved config; a missing required env var (e.g. LLM_API_KEY with openai-compat selected but no model) will fail here with a clear error.