BrainwriteDocs
Self-hosting

Deploy on a VPS

From a blank Linux server to Brainwrite running around the clock, reachable from your laptop, the desktop app and your phone.

Three ways in, one login

A public address with one command, your own domain with Docker, or your Tailscale network. Whichever you pick, devices pair once with a short code and stay signed in: a session lasts 30 days, and using it with half that or less left renews it to a full 30, up to 180 days from pairing. A device in regular use re-pairs twice a year; one that goes quiet for a month re-pairs. No password.

From a blank Linux server to Brainwrite running on it around the clock, reachable from your laptop, the desktop app and your phone, with your bots working while every laptop is closed. It assumes nothing beyond being able to open a terminal and paste commands. About twenty minutes, most of it waiting.

Three ways to make the server reachable are covered. Pick one; the rest of the guide is the same.

You needWho can reach itBest for
A. Public address, no domain (serve --tunnel)a Brainwrite account (email code)anyone with a pairing code, over HTTPSthe fastest path; a phone on cellular
B. Your own domain (Docker + Caddy)a domain name, ports 80/443anyone with a pairing code, over HTTPSa permanent address you own
C. Your Tailscale network (serve --tailscale)Tailscale on the server and your devicesonly your tailnetthe most private; nothing public at all

Whichever you pick, the login is the same: you pair each device once with a short code and it stays signed in. A session lasts 30 days; using it with half that or less left renews it to a full 30, up to 180 days from pairing (BWB_SESSION_TTL_DAYS and BWB_SESSION_MAX_DAYS change both numbers). There is no password.

What runs on a server, and what does not

Runs fully on the server: every engine CLI (Claude Code, Codex, Grok, custom ACP engines), chats, rooms, bot-to-bot coordination, routines, connected apps and custom MCP servers, webhooks, Company Brain, computer use on cloud or container computers, text-to-speech, and the web UI (the server serves it itself).

Needs the desktop app instead: the skill recorder, dictation, and controlling the server's own desktop. Bots browse on a server too, once the browser engine is installed (below).

Before you start

  1. A server. Any Linux VPS: Ubuntu 22.04 or 24.04, 2 CPUs and 4 GB of RAM is plenty. You need sudo or root. Hetzner, DigitalOcean, Lightsail, a Mac mini in a cupboard: all fine.

  2. Node 24 or newer for paths A and C (node --version). On Ubuntu:

    curl -fsSL https://deb.nodesource.com/setup_24.x | sudo -E bash - && sudo apt-get install -y nodejs

    Path B uses Docker instead and needs no Node on the host.

  3. Your engine accounts. Bots run on the same engine CLIs you use on your laptop, with your subscriptions. You sign them in on the server once, the same way.

Connect to the server for everything below:

ssh root@YOUR_SERVER_IP

Path A: a public address with one command

No domain, no proxy, no open port. The server gets an address like https://c-7f3a9c.brainwrite.in through a Cloudflare tunnel; only traffic through the tunnel reaches it, and that traffic still has to pair.

npx brainwritebot setup          # once: choose AI access, connect, and choose a model
npx brainwritebot login          # once: an emailed code signs this machine in and reserves its address
npx brainwritebot serve --tunnel # runs the server there and prints the pairing link with a QR code

setup connects an AI provider; it is separate from the Brainwrite account. Use Codex's device-code option over SSH, or enter a hidden API key for a chat-only connection. More engines can be added later.

login asks for your email, sends an 8-digit code, and prints the address it reserved for this machine. serve --tunnel downloads cloudflared on the first run (a pinned version with a verified digest, into ~/.brainwrite), starts the server, connects the tunnel, and after a few seconds prints tunnel: live at https://…. Leave it running; see "Keep it running" for a service.

The account credentials live in ~/.brainwrite/tunnel-account.json, readable only by your user. npx brainwritebot logout releases the address.

Skip to "Sign the engines in".

Path B: your own domain, with Docker

One container for the server plus Caddy for HTTPS at https://brainwrite.example.com.

  1. Point a name at the server. In your DNS provider add an A record (name matching your chosen subdomain, value the server's public IP). After a few minutes ping brainwrite.example.com should answer with that IP. Ports 80 and 443 must be open; most providers open them by default.

  2. Install Docker:

    curl -fsSL https://get.docker.com | sh
    docker compose version   # prints a version; if "command not found", log out and back in
  3. Get the deploy files and set the name:

    git clone https://github.com/palzinllc/brainwrite-app && cd brainwrite-app/deploy
    cp .env.example .env                # DOMAIN=brainwrite.example.com ; ENGINES=@anthropic-ai/claude-code @openai/codex
    nano .env

    ENGINES lists the engine CLIs baked into your image, separated by spaces. Change it later and rebuild if you add one.

  4. Start it:

    docker compose pull bwb && docker compose up -d
    docker compose ps        # bwb "healthy", caddy "running"

    Caddy requests the certificate on its own; give it a minute. Then https://brainwrite.example.com shows a page asking for a pairing code. That is correct: nothing works until you pair.

In this path, every npx brainwritebot … command below is run inside the container instead:

docker compose exec bwb node dist-server/pair-cli.js --label "My MacBook"

Path C: only your Tailscale network

Install Tailscale on the server and sign in (curl -fsSL https://tailscale.com/install.sh | sh && sudo tailscale up), enable HTTPS certificates for your tailnet once in the admin console (DNS → HTTPS Certificates), then:

npx brainwritebot serve --tailscale

Tailscale terminates HTTPS with its own certificate and the pairing link uses the server's MagicDNS name (https://brainwrite.tail1234.ts.net). Only devices on your tailnet can reach it, which is a very good property for a server that can run tools.

Sign the engines in

Bots run on the engine CLIs installed on the server, using your accounts. Sign each one in once; the logins live under the server's data directory and survive restarts and updates.

claude                       # path A/C: on the server; path B: docker compose exec bwb claude

Claude Code prints a link to open in your laptop's browser; approve it and the sign-in finishes. /exit to leave. Repeat for each engine you use (codex, …).

Pair your first device

serve already printed a pairing link and QR code when it started. For another device later:

npx brainwritebot pair --label "Kitchen iPad"
pairing code:  RR8Y-BLR6-H939
expires:       10:59:45 AM (single use)
open or scan:  https://c-7f3a9c.brainwrite.in/pair#code=RR8Y-BLR6-H939
  • A browser: open the link. The code is filled in; press Connect. That browser is paired for 30 days.
  • The desktop app: copy the link, then in the app's Server menu choose Add Server from Copied Pairing Link…. The menu switches between your own machine and every server you added.
  • The phone: scan the QR code from the iOS app's pairing screen, or paste the whole link into its address field. The phone can chat, approve, and read; creating bots, changing models, and connecting apps stay with you in the server's UI.

Worth knowing: a code works once and expires after five minutes; the link only works on that address (typing the code by hand: open …/pair and enter it); ten wrong codes in a minute from one address pause pairing for that address for a minute; --client mints a code for a device that may chat and approve but not change settings or pair others.

Manage devices

Every paired device is a session:

npx brainwritebot sessions              # id, device, scope, last seen, expires
npx brainwritebot sessions revoke ID    # signs that device out and closes its stream at once
npx brainwritebot status                # what the server says about itself

Keep it running

npx brainwritebot serve is a plain foreground process. Under systemd (paths A and C):

# /etc/systemd/system/brainwritebot.service
[Unit]
Description=Brainwrite server
After=network-online.target

[Service]
User=brain                   # an unprivileged user that owns ~/.brainwrite and the engine logins
WorkingDirectory=/home/brain
ExecStart=/usr/bin/npx brainwritebot serve --tunnel --no-pair
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload && sudo systemctl enable --now brainwritebot
journalctl -u brainwritebot -f            # the server's log, including "tunnel: live at …"

Use --tailscale instead of --tunnel for path C. --no-pair skips printing a code at every restart; mint one with npx brainwritebot pair when you need it. Docker (path B) restarts on its own (restart: unless-stopped).

Update

  • Paths A and C: npx brainwritebot@latest serve … always runs the newest published version; with the service above, sudo systemctl restart brainwritebot picks it up.
  • Path B: cd brainwrite-app/deploy && docker compose pull bwb && docker compose up -d.

Routines and queued work survive a restart; a turn running at that moment does not, so update between runs.

Back up

Everything that matters (bots, chats, routines, engine logins, paired sessions) is in one place: ~/.brainwrite for paths A and C, the deploy_data Docker volume for path B.

tar czf brainwrite-data.tgz -C ~ .brainwrite                                  # A, C
docker run --rm -v deploy_data:/data -v "$PWD":/b alpine tar czf /b/brainwrite-data.tgz -C /data .   # B

Keep the file somewhere else. Restore by stopping the server and untarring into the same place.

The rules the setup relies on

Read this before putting anything else in front of the server.

  • The server only ever listens on loopback (127.0.0.1:8799). Never publish that port yourself. The tunnel, Caddy and Tailscale each reach it from the same machine.
  • A request that arrives through a proxy or the tunnel is treated as remote and needs a session, whatever headers it carries. A proxy of your own (nginx, Traefik, Cloudflare Tunnel) must forward the real Host and add X-Forwarded-For and X-Forwarded-Proto, must not buffer the event stream, and must not rewrite Host to 127.0.0.1.
  • Pairing is the login. Want a second wall in front of it? Path B's Caddyfile has a commented basic_auth block for a shared password.
  • The session cookie is marked Secure; do not serve this over plain HTTP on the public internet.
  • The one thing a stranger can read is /.well-known/brainwrite/environment (the server's id, label, version, capabilities) and /api/health (only the app name). Everything else answers "pair this device".

Troubleshooting

serve --tunnel says "no account on this machine yet". Run npx brainwritebot login on this machine first; the credentials are per machine.

The tunnel stays on "retrying". The server is running and usable locally; the public hop is not verified yet. Wait a minute (Cloudflare needs a moment on a fresh address), then check journalctl/the terminal for the reason. If it never comes up, npx brainwritebot logout && npx brainwritebot login issues a fresh address.

Path B: the page never loads or shows a certificate error. Caddy could not get a certificate. Check that the name resolves to the server and that ports 80 and 443 are open; docker compose logs caddy shows the reason.

"forbidden: pair this device to use the server remotely" / "this request came through a proxy". Expected before pairing, and for tools that call the API without a session. Pair the device. Scripts send Authorization: Bearer <token> with the token from the pairing response instead of a cookie.

Pairing code refused. It expired (five minutes) or was used. Mint a new one. "Too many failed pairing attempts" means wait a minute.

A bot says the engine is not signed in. Sign that engine in again on the server.

What does the server think it is? https://<address>/.well-known/brainwrite/environment is public and shows its id, label, version and capabilities; npx brainwritebot status prints the same on the server.

Something else. journalctl -u brainwritebot --since -10m (or docker compose logs bwb --tail 100) shows the server's startup lines. Paste them with your question in the community channel.

On this page