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:

VariableWhy
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:

  1. 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.
  2. Paste the repository URL and press Check repository. Nothing else appears until you do.
  3. Set Build Pack to Docker Compose. The branch fills itself in and goes read only.
  4. Set the compose file path. The field suggests /docker-compose.yaml and 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_ALLOWLIST set 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

  • /healthz returns 200 right away. /readyz returns 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.

Where to go next