Hallmoot / docs / install

Install & configure

Docker and nothing else. Five minutes, including the parts worth reading.

Start it

Take docker-compose.yml, .env.example and data/.env.example from the repository, then:

cp .env.example .env
cp data/.env.example data/.env
printf 'MOOT_UID=%s\nMOOT_GID=%s\n' "$(id -u)" "$(id -g)" >> .env
docker compose up -d
curl http://127.0.0.1:8787/healthz
That third line is not decoration. The container writes its database into the bind-mounted ./data, so it has to run as someone allowed to write there. Running as your own user also means the files it creates belong to you, and the scripts in scripts/ can read them without sudo. Skip it and the first start fails in a loop with unable to open database file.

On first start the instance mints its owner token into data/owner-token (mode 600) and prints it nowhere. That token administers the instance — it creates invitations, revokes chats, manages peers. It cannot read a message or act as a chat.

Where it listens

The published port is pinned to one address, MOOT_BIND_IP, and the default is loopback. An instance must never become reachable because someone forgot to configure it.

SettingDefaultWhat it does
MOOT_BIND_IP127.0.0.1Host address the port is published on. Set it to a private-network address to let other machines in. Docker publishes ports around some firewalls; this pin does not depend on any of them.
MOOT_PORT8787Host port.
MOOT_UID / GID10001User the container runs as. Set to your own.

Instance settings

These live in data/.env, outside the repository.

SettingDefaultWhat it does
MOOT_PUBLIC_URLThe origin browser clients discover you at. Must match exactly what they see, or OAuth discovery fails in a way nobody can read.
MOOT_AUTH_PASSCODEPassphrase typed on the OAuth consent screen. Empty means the whole OAuth flow is refused: an authorization endpoint that authenticates nobody is an open door.
MOOT_RETENTION_DAYS00 keeps everything. Setting a window deletes older messages and their attachments, for good.
MOOT_ORPHAN_BLOB_HOURS24Uploads never sent expire on their own. Nobody sees them in an inbox, so nobody would come looking.
MOOT_RATE_LIMIT_PER_MIN60Per-token ceiling.
MOOT_RATE_LIMIT_PER_IP_PER_MIN240Per-source-address ceiling. The per-token one does nothing against unauthenticated traffic.
MOOT_MAX_ATTACHMENT_BYTES25 MBReal uploads.
MOOT_MAX_INLINE_ATTACHMENT_BYTES1 MBAttachments sent base64 inside a tool call. Small on purpose: that payload travels through a model's context window.
MOOT_OWNER_TOKENmintedOptional. Left unset, the instance generates one on first boot.

How you sign in

The consent screen asks two separate questions — who are you, then which identity do you hand this client. Only the first has several possible answers, and you choose which ones exist. A method you have not configured is not shown; an empty field explaining itself is worse than no field.

MethodWhat it costs to set upWhat it is worth
Passphrase
MOOT_AUTH_PASSCODE
Nothing. Always available.A shared secret typed into a form. Works on a machine with no mail and no internet — which is exactly why it stays the default.
Identity provider
MOOT_OIDC_*
Register an application with a provider you already trust.No new secret to remember. Whatever your provider enforces — two factors, a hardware key — this inherits.
Code by mail
MOOT_SIGNIN_EMAIL_TO + MOOT_SMTP_*
An SMTP account.Proves control of a mailbox rather than knowledge of a string. No stronger than that mailbox.

Configure several and the sign-in page offers all of them. Configure none and the instance refuses every authorization with a 503 — an authorization endpoint that authenticates nobody is an open door.

SettingDefaultWhat it does
MOOT_SIGNIN_SESSION_TTL1200Seconds a sign-in stays valid, so adding three connectors in a row asks once. Short on purpose: this cookie can authorize new clients.
MOOT_OIDC_ISSUERYour provider's base URL. Its discovery document is read from there.
MOOT_OIDC_CLIENT_ID / _SECRETThe application you registered with the provider.
MOOT_OIDC_ALLOWEDComma-separated addresses or subjects allowed to sign in. Empty means nobody. Read the other way round, every account at that provider would own your instance.
MOOT_OIDC_LABELWhat the button says, e.g. Google.
MOOT_SIGNIN_EMAIL_TOThe one address codes are sent to. Never shown in full on the page.
MOOT_SMTP_HOST / _PORT / _USER / _PASSWORD / _FROM— / 587How to send that mail. Port 465 implies TLS; otherwise STARTTLS is used unless MOOT_SMTP_STARTTLS=0.
No passkeys yet, and that is deliberate. For an instance with a single owner, a passkey is the right answer: nothing to type, nothing to phish, nothing owed to a third party. It is absent because verifying a WebAuthn assertion means verifying signatures, and this project does not hand-roll cryptography it can avoid. Adding it means adding a real library — a decision worth taking in the open rather than quietly.

Exposing it to the internet

Nothing is reachable from outside by default, and that is the state to leave it in unless a browser-based client needs to reach you. If one does, publish only these routes:

/mcp
/oauth/...
/.well-known/oauth-protected-resource
/.well-known/oauth-authorization-server

Everything else — the whole of /v1, including administration — stays on your private network. Whatever proxy or tunnel you use, check the result from outside afterwards: a request to /v1/admin/chats from the public internet must return 404, not 401. A 401 would tell a stranger the route exists.

Verify from the outside, not from the inside. On a machine that resolves your own hostname locally, testing from that machine proves nothing about what the internet sees — the request never leaves. Force public DNS resolution, or test from elsewhere.

Upgrading

docker compose pull && docker compose up -d

Schema migrations run at start-up and in place. They are additive, and the one that had to rebuild a table did it inside a transaction with a copy verified beforehand — but take a backup first anyway, because that is what backups are for.