Deployment
End-to-end guide for a production install — one host serving the MCP server, dashboard, and database, with watcher daemons on each developer machine.
A production(-ish) zero-memory install is one server host running three things — the MCP server, the dashboard, and Supabase — behind a TLS edge, plus a small client footprint (MCP registration, hooks, optionally a watcher daemon) on each developer machine. Detail lives next to the artifacts in the repository; this page is the map.
developer machines server host
┌─────────────────────────┐ HTTPS ┌──────────────────────────────────┐
│ MCP clients (OAuth) │──────────►│ edge (caddy/nginx, TLS) │
│ watcher (systemd user) │──────────►│ ├─ zm-server :8787 (MCP+OAuth) │
│ SessionStart/Stop hooks │ │ ├─ zm-web :3100 (dashboard) │
└─────────────────────────┘ │ ├─ zm-docs :3200 (this site) │
│ └─ supabase (kong/db/auth/…) │
└──────────────────────────────────┘What you are taking on
Running an instance makes you responsible for the data in it. The software carries no warranty and no liability for lost, corrupted, or disclosed data — that is what the licence says, and before 1.0 it is a practical warning as much as a legal one. Set up the backups described under Operations on day one, restore one into a scratch database before you are ever forced to, and treat every upgrade as something to snapshot first.
If you run an instance for other people, whatever you promise them — about
availability, retention, deletion, or how their data is handled — is between
you and them. This project ships the mechanism, not the terms: publish your
own documents and point ZM_TERMS_URL / ZM_PRIVACY_URL at them, and the
sign-up form will require an explicit acceptance before it creates an account
(see Legal documents and consent). Configure
neither and the form asks for nothing, which is the right answer for an
instance whose only user is you.
Prerequisites
- A host with Docker + Docker Compose and ports 80/443 reachable.
- DNS records for the public hosts (e.g.
zm.example.comfor the MCP server,memory.example.comfor the dashboard,docs.example.comfor this documentation; optionallysupabase.example.comfor Kong). - No model key is required for recall, remember, local embeddings, or search.
Configure an Anthropic/OpenAI-compatible provider only for model-backed
extraction, hygiene, translation, or rule distillation. Transcript
extraction is metrics-only by default (
ZM_INGEST_EXTRACT=off). - A mail provider account and a sender domain whose DNS you control — see the mail step below. Without it registration and password recovery do not work at all, so it is a prerequisite rather than a finishing touch.
Database: Supabase
The database is not part of this deployment — the app reaches it over the network — so pick either:
- a hosted Supabase project: create it, note its URL and keys, done;
- your own self-hosted stack: Supabase's self-hosting compose stack is
vendored at
infra/dev/supabase/. The directory name says where we happen to run it, not what it is worth — that is the stack production runs; follow its README and configuration notes. (The Supabase CLI stack is the dev-only one: it ships fixed development keys.)
Either way, record the values you will need later: the Kong URL, the anon
key, the service-role key, and the auth SITE_URL. The SITE_URL must be
the external dashboard URL so that confirmation and recovery emails link to
the right place.
Apply the migrations from supabase/migrations/ — with the Supabase CLI
(db push) or through the stack's own migration path.
Mail — required, not optional
Email confirmation is on, so an instance without a working relay is one where nobody can register or recover a password. Three dashboard flows depend on it: signup confirmation, password recovery, and address change. Nothing in the product sends mail itself — the database's Auth service does, through what you configure here.
-
Sender domain. Verify a domain (or a subdomain such as
mail./notifications.) with a mail provider and add its SPF, DKIM and DMARC records to that DNS zone. Mail from an unverified domain is filed as spam when it is delivered at all. Do not rely on a hosted Supabase project's built-in sender: it is a test facility, rate-limited to a few messages an hour and usually only to project members. -
SMTP on the database side. A hosted project: its Auth → SMTP settings. Your own stack:
SMTP_HOST/PORT/USER/PASS,SMTP_ADMIN_EMAIL(the From address — it must be on the verified domain) andSMTP_SENDER_NAMEin the stack's.env. -
SITE_URLand the redirect allow-list must be the external dashboard URL — that is where/auth/callbacklives. The dashboard asks Auth to return the user to${window.location.origin}/auth/callback, and an origin missing from the allow-list is replaced bySITE_URLsilently, with no error anywhere: recovery mail then sends people to the wrong host. Include every origin the dashboard is reached by. -
Templates. Export them, then give Auth the result:
bun run --cwd packages/email mail:export # → apps/web/public/email-templates/How Auth receives them depends on how it is hosted, because GoTrue fetches templates over HTTP and cannot read a mounted file:
Hosting Wiring Hosted Supabase project paste each exported .htmlinto the project's Auth → Email Templates, subjects includedYour own stack add the mail overlay: docker compose --project-directory infra/dev/supabase -f infra/dev/supabase/docker-compose.yml -f infra/prod/docker-compose.supabase-mail.yml up -d— it points Auth at the copies the dashboard serves fromSITE_URLSupabase CLI stack already wired through [auth.email.template.*]inconfig.toml(dev and the e2e stand)The templates contain no secrets: Auth substitutes
{{ .ConfirmationURL }}and the addresses at send time. They are cached forGOTRUE_MAILER_TEMPLATE_MAX_AGE(default 10 minutes), so an edit is not instant. -
Accept it on a real address outside your own domain. Register a new user and run a password recovery end to end: both mails arrive, the links land on the dashboard, and neither is in the spam folder. This is the only step that proves the whole chain — provider, DNS, SMTP,
SITE_URL, templates. The failure modes are no longer silent: a recovery link that is expired or otherwise refused renders an explicit refusal screen instead of a dead password form, and a fresh sign-up is told to check its inbox rather than being dropped on a reset form — so if you see either screen, read what it says before digging into logs.
Application images and compose
infra/prod/README.md in the repository is the authoritative walkthrough.
Before provisioning the real host, run its isolated production-path
rehearsal: it builds the same Dockerfiles and Caddy topology against a
dedicated, isolated review database restored from a snapshot, verifies the
external OAuth issuer through TLS, and cannot create or modify a live
snapshot. In outline:
-
Build
apps/server/Dockerfile,apps/web/Dockerfileandapps/docs/Dockerfile— or skip building entirely and pull published images by settingZM_IMAGE_PREFIXandZM_IMAGE_TAG. The server and dashboard images are not deployment-specific: the browser-facing Supabase URL and key are runtime environment, not build arguments. The documentation image is the exception — the addresses its pages state (ZM_HOSTED_MCP_URL,ZM_HOSTED_WEB_URL,ZM_CLIENT_BUNDLE_URL) are rendered into the HTML when it is built, so changing one is a rebuild. -
cp infra/prod/.env.example infra/prod/.env,chmod 600 infra/prod/.env, and fill it in. The mode matters:cpleaves the copy world-readable, and this file ends up holding the secret API key, the model-provider key and a service-account password — every secret the serving stack has. The critical invariant:ZM_PUBLIC_URLmust equal the external HTTPS URL of the MCP server exactly. It is the OAuth issuer embedded in all metadata and challenges, so a mismatch invalidates every client at once. WithZM_TRUST_PROXY=true(which is what "deployed behind the edge" means) the server refuses to start on a missing or non-HTTPS public URL rather than booting into a silently broken issuer. KeepZM_TRUST_PROXY=trueonly when the server sits behind the edge. -
Bring the stack up:
docker compose --project-directory infra/prod \ -f infra/prod/docker-compose.yml \ -f infra/prod/docker-compose.caddy.yml up -dDrop the caddy overlay if you terminate TLS at your own gateway — an nginx example ships in
infra/prod/nginx/. Add-f infra/prod/docker-compose.supabase-network.ymlonly if your Supabase stack runs in Docker on the same host and you address Kong by service name.
Edge and TLS
The caddy overlay handles ACME certificates automatically. If you run your
own gateway instead, proxy /mcp with buffering off (the transport uses
SSE) and long read timeouts; see infra/prod/nginx/zm.conf.example. A full
verification checklist (DNS → certs → env → build → up → verify) lives in
infra/prod/README.md.
Legal documents and consent
An instance that strangers can register on usually needs terms of service and
a privacy policy, and needs to be able to show that each account accepted
them. Three variables in infra/prod/.env decide it, and leaving them empty
is a valid choice:
ZM_TERMS_URL,ZM_PRIVACY_URL— where the documents are. Either an https URL, or a site-root path such as/termswhen the edge serves them next to the dashboard. Setting either one turns on a required checkbox in the sign-up form, with both names rendered as links; setting neither leaves the form exactly as it is today.ZM_TERMS_VERSION— an optional label (a date does the job) stored with every acceptance alongside its timestamp, so "which text did they agree to" has an answer later.
The documents themselves are yours and never enter this repository. The Caddy
edge serves whatever is in ZM_LEGAL_DIR (an empty infra/prod/legal by
default) on the dashboard hostname, so a file terms.html there answers at
/terms. Nothing about them is baked into an image: publishing, editing or
withdrawing a document is a file change and a reload, never a rebuild.
Client machines
The short path for a developer machine is Install the client: download the per-platform bundle, run one installer, sign in. The pieces it installs:
- MCP client — register
{"type":"http","url":"https://zm.example.com/mcp"}. The client discovers OAuth via the 401 challenge, self-registers through dynamic client registration, and opens the login page. Users sign up and reset passwords in the dashboard. - Watcher (auto-capture of session transcripts) — run
zero-memory-watcher login https://zm.example.comonce: it stores the server address in~/.config/zero-memory/config.json— the one answer every client path reads — and completes OAuth, so the watcher and hooks need no Supabase keys orZM_EMAIL/ZM_PASSWORD. Install it as a systemd user unit perinfra/prod/watcher.service; the unit's~/.config/zero-memory/watcher.envis optional and exists only to override the stored answer withZM_SERVER_URLin scripted setups. - Hooks and plugin (auto-context at session start) — see
Claude Code for the plugin that wires
hooks, MCP registration, and the memory-first rule in one install, and
scripts/plugin-bundle/README.mdin the repository for the per-client installers (Claude Code, Codex, Cursor) and the multi-client bundle builder for offline installs.
Operations
infra/prod/RUNBOOK.md covers the operational surface:
- health probes — the server's
/healthzfor liveness and/readyzfor readiness, plus the dashboard's own/healthz, which answers503when the dashboard cannot reach the MCP server. Monitor both: the web-to-server leg is the one that has failed while every outside check stayed green; - built-in Prometheus-format counters at
/metrics, including the rule-delivery failure signal — see Observability for their semantics; - daily backups and a restore drill in the two forms a deployment can take —
a logical dump you hold yourself when the database is a managed project
(
infra/prod/scripts/db-dump.sh/db-restore.sh), a physical cluster snapshot when it runs on your host. The systemd timer units live ininfra/prod/backup/. A backup you never restored is a hope: the runbook's drill restores into a scratch database and checks row counts before you ever need it in anger; - backup alerting: the dump script pings a dead-man URL
(
ZM_DUMP_PING_URL) on success and its/failvariant (ZM_DUMP_PING_FAIL_URL) immediately on failure, so a silently broken backup becomes an incident within hours rather than at restore time; - key, JWT, and password rotation with the exact re-seed and restart order, including procedures for a managed database project and the measured client impact of each rotation — including the credentials that never reach the containers (the mail provider, the image registry, the deployment checkout), whose failure surfaces at the next deployment rather than in a health check;
- incident quick checks;
- the security posture: Row Level Security as the data boundary, a rate-limited OAuth surface, deny-all token tables, PKCE-only flows, and single-use authorization codes.
The deployment scripts under infra/prod/scripts/ are inventoried in
infra/prod/README.md — from host-bootstrap.sh through db-provision.sh
and auth-config.sh to verify-instance.sh. Run verify-instance.sh as
the acceptance step after any deploy or upgrade: it is read-only and safe
against a live instance.
Releases and upgrades
A release is a version bump in the root package.json: when it merges into
the main branch, the publishing pipeline materializes the tree in the public
repository and cuts the v<version> tag there. The published repository's
tags are the authority for what has been released — that list is where the
[tag] arguments below come from. An optional SSH deploy hook
(ZM_DEPLOY_HOST / ZM_DEPLOY_KEY / ZM_DEPLOY_HOST_KEY, a forced command
on the host) can turn a merge into an automatic upgrade; see
infra/prod/README.md.
To upgrade, pull the release and run infra/prod/scripts/upgrade.sh [tag] on
the host — it updates the checkout, pins the tag in infra/prod/.env, pulls
the images and waits for the containers to report healthy. Compose recreates
only what changed. By default, apply any new migrations separately; they are
forward-only.
A host can manage the migrations itself: set ZM_MIGRATE_ON_DEPLOY=1 in
infra/prod/.env and point ZM_DB_ENV_FILE at an env file carrying
SUPABASE_DB_URL (default /etc/zero-memory/backup.env, the same file the
backup timer reads). Every deploy then takes a pre-schema dump into its own
corner (/var/backups/zero-memory/pre-deploy, kept separate from the daily
backups and never mirrored off-site), applies the pending migration tail
strictly forward with the bookkeeping the Supabase CLI expects, verifies the
watermark, and only then switches containers. Any failure — an unreadable
credential, a diverged series, a failed statement — aborts the deploy while
the previous containers still serve. A host that never sets the flag is
unaffected.
Rolling back is the same command with the previous tag, and it is fast: the
rehearsed procedure measured under a minute in each direction. The pin
persists — a later bare upgrade.sh re-run stays on the rolled-back version
until you name a newer tag. The database does not roll back with it, and
does not need to: the schema is ADD-only by design, so an older server
against a newer schema is the designed case rather than a gamble.
infra/prod/RUNBOOK.md covers the full procedure, including what a rollback
does not undo.
Install the client
Connect a coding agent to a zero-memory instance someone else runs — an account, one MCP registration, and the optional client bundle that makes memory automatic.
Claude Code
Connect Claude Code to zero-memory so every session starts briefed from persistent memory and durable facts are captured automatically.