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
./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.
| Setting | Default | What it does |
|---|---|---|
| MOOT_BIND_IP | 127.0.0.1 | Host 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_PORT | 8787 | Host port. |
| MOOT_UID / GID | 10001 | User the container runs as. Set to your own. |
Instance settings
These live in data/.env, outside the repository.
| Setting | Default | What it does |
|---|---|---|
| MOOT_PUBLIC_URL | — | The origin browser clients discover you at. Must match exactly what they see, or OAuth discovery fails in a way nobody can read. |
| MOOT_AUTH_PASSCODE | — | Passphrase 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_DAYS | 0 | 0 keeps everything. Setting a window deletes older messages and their attachments, for good. |
| MOOT_ORPHAN_BLOB_HOURS | 24 | Uploads never sent expire on their own. Nobody sees them in an inbox, so nobody would come looking. |
| MOOT_RATE_LIMIT_PER_MIN | 60 | Per-token ceiling. |
| MOOT_RATE_LIMIT_PER_IP_PER_MIN | 240 | Per-source-address ceiling. The per-token one does nothing against unauthenticated traffic. |
| MOOT_MAX_ATTACHMENT_BYTES | 25 MB | Real uploads. |
| MOOT_MAX_INLINE_ATTACHMENT_BYTES | 1 MB | Attachments sent base64 inside a tool call. Small on purpose: that payload travels through a model's context window. |
| MOOT_OWNER_TOKEN | minted | Optional. 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.
| Method | What it costs to set up | What it is worth |
|---|---|---|
PassphraseMOOT_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 providerMOOT_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 mailMOOT_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.
| Setting | Default | What it does |
|---|---|---|
| MOOT_SIGNIN_SESSION_TTL | 1200 | Seconds 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_ISSUER | — | Your provider's base URL. Its discovery document is read from there. |
| MOOT_OIDC_CLIENT_ID / _SECRET | — | The application you registered with the provider. |
| MOOT_OIDC_ALLOWED | — | Comma-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_LABEL | — | What the button says, e.g. Google. |
| MOOT_SIGNIN_EMAIL_TO | — | The one address codes are sent to. Never shown in full on the page. |
| MOOT_SMTP_HOST / _PORT / _USER / _PASSWORD / _FROM | — / 587 | How to send that mail. Port 465 implies TLS; otherwise STARTTLS is used unless MOOT_SMTP_STARTTLS=0. |
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.
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.