Self-hosting the server
The deployment reference. If you just want to see it work first, start with Getting started and come back here when you put it on a real machine.
What you need
- Somewhere to run Docker. A small VPS is plenty.
- A brain repository. Start from the template, keep it private.
- A Claude credential. An API key, or a token from a Claude subscription you already pay for. No API billing either way.
- A domain, if you want this reachable from anywhere but your desk.
The environment it needs
Two variables are required. That is the whole list:
POSTGRES_PASSWORD=<any long random string>
ALLOWED_HOSTS=brain.example.com
ALLOWED_HOSTS takes no scheme and no slashes. Comma
separate it if you have more than one name. It stays in the environment
rather than in the settings UI on purpose. Getting it wrong returns 400
on every request, including the page that would let you fix it.
Four more get created for you on first boot and written to the state
volume: SECRET_KEY, FIELD_ENCRYPTION_KEY,
MCP_LOOPBACK_SECRET, and the admin URL path. Setting any
of them yourself still wins.
Back up the state volume with the database. Lose
FIELD_ENCRYPTION_KEY and every stored credential becomes
unreadable. The database survives, the secrets inside it do not.
Worth setting once you are past first boot:
| Variable | Why |
|---|---|
ADMIN_IP_ALLOWLIST |
CIDRs allowed to reach the ops UI. Empty means no IP check. Read the proxy section below before you set it. |
BRAIN_COMMIT_NAME, BRAIN_COMMIT_EMAIL |
Author on approval commits, so the server's writes stay separate from yours. |
GITHUB_WEBHOOK_SECRET |
Needed for the webhook, which you do want. See below. |
APP_NAME |
Branding on the tab and the login page. The settings UI overrides it, so set it here only to brand the very first boot. |
An empty value counts as unset. That is deliberate, and it bites on hosting panels that leave a blank row behind when you clear a field. Delete the line, do not blank it.
Bringing it up
Five containers: web, mcp, worker, Postgres, Redis. Four named volumes, so a redeploy never wipes them.
docker compose up -d --build
A cold first build takes 121 to 126 seconds on a laptop, and the setup
page answers about 23 seconds after up. A small VPS will
be slower. The build is the slow part, and it happens once per version.
On Coolify
Coolify clones the repo and builds the image itself. There is no published image yet, so it needs a repository it can reach.
Do not look for a Docker Compose option in the resource
picker. The one that is there, Docker Compose
Empty, is for pasting a compose file with no git behind it, and
this stack builds from source. The route is:
- New Resource, then Public Repository under Git Based. Private repos have their own options there, one for a GitHub App and one for a deploy key.
- Paste the repository URL and press Check repository. Nothing else appears until you do.
- Set Build Pack to Docker Compose. The branch fills itself in and goes read only.
- Set the compose file path. The field suggests
/docker-compose.yamland this repo ships/docker-compose.yml, so type it.
Coolify then reads the compose file and gives you a
Generate Domain button for every service:
web, mcp and worker. Only
web gets one. It is the only service the compose file
publishes, and the other two are reachable on the internal network.
Coolify terminates TLS itself.
Add the two environment variables from above, then deploy. A deploy with the image layers already cached finished in 40 seconds.
An empty value counts as unset, deliberately. Hosting
panels leave a blank row behind when you clear a field, and Compose
passes every unset ${VAR} through as an empty string, so
the two are indistinguishable by design. Delete the row rather than
blanking it.
Health checks and the first screen
Testing locally over plain http? Set
SECURE_SSL_REDIRECT_ENABLED=0. It defaults on, which is
right behind a TLS proxy and fatal on localhost. Every request
redirects to https, including the setup page. On a real
deployment with TLS, leave it alone.
For the health check use /healthz, which answers as soon
as the app is up. Do not use /readyz. It
returns 503 until a valid brain is cloned, so a blank instance would
never come up healthy and you would never reach the wizard.
Then open your domain. It redirects to /setup/, and the
six steps are walked through in
Getting started.
Behind a proxy or a CDN, do this first
This is the one most deploys get wrong, and the failure is nasty rather than cosmetic.
Coolify fronts the app with Traefik, and plenty of deploys put Cloudflare on top of that. In that shape, the address the app sees is the proxy, not the caller. Every per IP control then treats the whole internet as one address. The admin login lockout is per IP, so an attacker's failed attempts trip the sentinel on that shared address and lock you out of your own admin, while they keep going from another edge node.
Set both, never one:
TRUSTED_PROXY_IP_HEADER=CF-Connecting-IP
TRUSTED_PROXY_IPS=10.0.0.0/8
TRUSTED_PROXY_IPS is the address your proxy connects
from, which is the hop next to this app. Behind Coolify that is
the Traefik container on the Docker network, a private address. It is
not Cloudflare's published ranges. Do not guess it. The health page
prints the address the app actually observes, and which of the three
states you are in.
The header is trusted only when the peer is in that list, so a caller reaching your origin directly cannot forge it. Setting one without the other refuses to boot in production, because a half configuration looks exactly like a working one while every per IP control sits inert.
ADMIN_IP_ALLOWLIST compares against the resolved address,
so configure this section first. Otherwise you are allowlisting your
proxy, and the first request 404s you out of your own server.
Lock down the ops UI
The ops UI can approve feeds and read every private note. It is staff
only, but put a network boundary in front of it as well. It lives at
the path in ADMIN_PANEL_URL_PATH, which defaults to
/ops/.
Pick one:
-
Tailscale on the box, with
ADMIN_IP_ALLOWLISTset to your tailnet range. The middleware returns 404 to everyone else. -
Cloudflare Access covering the ops path,
/setup/and/login/.
The open internet should reach only /,
/api/, /mcp,
/webhooks/github, and the health endpoints. Putting Access
in front of those breaks your agents and quietly kills the webhook.
The dashboard warns you when the ops UI is unrestricted and reachable from a non local address.
The webhook is not optional
In your brain repo: Settings, Webhooks. Point it at
https://your-domain/webhooks/github, content type JSON,
secret matching GITHUB_WEBHOOK_SECRET, push events only.
There is no periodic pull. Three things sync the
server's clone: this webhook, the "Pull from GitHub" button on the
health page, and manage.py sync_brain. Without the
webhook, the server keeps serving your brain as of the last manual
pull, indefinitely, and nothing warns you that it is behind.
Backups
Two things, nightly, on the host:
docker exec <postgres> pg_dump -U brain brain \
| gzip > /backups/brain-$(date +%F).sql.gz
docker run --rm -v <project>_brain-state:/s -v /backups:/b alpine \
tar czf /b/brain-state-$(date +%F).tgz -C /s .
Work out what you need by asking what your repo can rebuild. Entities,
sync runs and the per tier snapshots all come back from a rebuild.
Feeds, events, the token ledger and chat history do not. Nor does
boot-secrets.json, which is why the state volume is on the
list.
Do a restore drill into a scratch Postgres once. A backup you have never restored is a guess.
Updating
Pull the new version and rebuild. Migrations and
collectstatic run in the web container on start, so there
is no separate migrate step.
git pull
docker compose up -d --build
The volumes are named, so your database, your clone, your snapshots and your generated secrets all survive. Take the database and state backup first anyway.
One thing that does not update itself: your brain repo is a copy of the template made at a point in time. Updating the server does not touch it, and it is your repo, so nothing is going to reach in and migrate it.
The contract in CLAUDE.md carries a
contract-version field, currently 1.0, so a
later version can tell a stale contract from a current one. Today
nothing checks it for you. What the server does check on every boot is
that the contract paths are all present, and it refuses to
serve a clone that is missing any of them.
After you deploy
/healthzreturns 200 right away./readyzreturns 200 once the wizard is done.- The ops UI is unreachable from the open internet, and reachable through your boundary.
- Test connection on the settings page returns a model and a latency.
- Push to your brain repo, and the server reindexes within seconds.
- A restore drill into a scratch Postgres comes back green.