Skip to content

Deploying to a server

The same app can run on a server, over a checkout of your vault that a git sync keeps current, behind HTTPS and a login. You run deploy.sh from your own machine; the server needs no GitHub credentials for the app, because the script ships source with rsync.

What you need

A server Ubuntu, reachable as root over ssh (an ~/.ssh/config alias is easiest)
A name A domain pointed at the server (recommended), or its bare IP
A vault checkout on the server Kept current by DEPLOY_VAULT_SYNC=1 (sync.md), or by your own cron or timer that pulls
Your laptop With the vault and the engine, and Node 22 for auth-setup

deploy.sh installs Node, Postgres with pgvector, and Caddy on first run.

First deploy

1. Create the login, on your own machine:

secondbrain auth-setup

It asks for a password, enrols an authenticator app (TOTP), and prints two lines, AUTH_PASSWORD_HASH=... and AUTH_TOTP_SECRET=.... Keep them for step 3. The password itself is never stored.

2. Add the deploy settings to your vault .env:

DEPLOY_HOST=my-server
DEPLOY_PUBLIC_HOST=brain.example.com
DEPLOY_VAULT_DIR=/srv/vault

3. Deploy:

.secondbrain/app/deploy/deploy.sh

It is idempotent: the first run installs everything, later runs ship code, rebuild, migrate and restart.

4. Fill in the server's env file, first time only. deploy.sh creates /etc/secondbrain/env from app/deploy/env.example. Paste the two AUTH_* lines, set PUBLIC_ORIGIN=https://brain.example.com, MODULES, and any keys (TOGETHER_API_KEY), then:

ssh my-server 'systemctl restart secondbrain'

The app refuses to start in server mode without AUTH_PASSWORD_HASH, AUTH_TOTP_SECRET and PUBLIC_ORIGIN. That is deliberate: there is no "auth off" for a server.

Settings

deploy.sh reads only its own keys from the vault .env (or the environment), and takes them literally: it never sources the file, so a $ in some other value can't break it.

Variable Required Default Purpose
DEPLOY_HOST yes ssh destination with root
DEPLOY_PUBLIC_HOST yes The domain or IP Caddy serves
DEPLOY_VAULT_DIR yes The vault checkout on the server
DEPLOY_APP_USER secondbrain Unix user that owns the vault and runs the app
DEPLOY_APP_DIR /opt/secondbrain-app Where the app is installed
DEPLOY_HERMES_HOME ~<app user>/.hermes Hermes home on the server, for the agents module
DEPLOY_MODULES MODULES The server's module list, when it differs from your laptop's
EXTRA_CADDY_IMPORT Caddyfile snippets for other sites on the same box, comma separated
DEPLOY_REDIRECT_FROM Another host (for example the bare IP) that redirects to the public host
DEPLOY_PAUSE_FILE A file that holds an agent dispatcher off while the build runs
DEPLOY_NO_ROLLBACK=1 off Leave a deploy in place when it does not come up. By default the previous build goes back
DEPLOY_EMBED=1 off Install the nightly embed timer (needs a local Ollama on the server)
DEPLOY_DB_BACKUP=1 off Install the nightly pg_dump backup timer (needs b2store for the app user)
DEPLOY_VAULT_SYNC=1 off Install the vault sync timer: the server commits what its agents write, pulls and pushes every 15 minutes as SYNC_ROLE=agent (sync.md). The app user needs push access to the vault repo

What runs on the server

Unit When Installed if
secondbrain.service Always Always
secondbrain-agents-sync.timer Periodic agents
secondbrain-usage-sync.timer, -credits-sync.timer, usage importer Periodic usage
secondbrain-projects-render.timer Hourly projects
secondbrain-map-build.timer Hourly at :20 map
secondbrain-metrics.timer Daily 00:30 UTC map
secondbrain-livestream.timer Every minute livestream
secondbrain-embed.timer Daily 02:30 UTC DEPLOY_EMBED=1
secondbrain-db-backup.timer Daily 03:30 UTC DEPLOY_DB_BACKUP=1
secondbrain-vault-sync.timer Every 15 minutes DEPLOY_VAULT_SYNC=1

Which version is live

deploy.sh records the commit it shipped in <app dir>/data/build-info.json (the deploy rsync excludes data/, so the file survives the next deploy). The app reads it at startup and reports it in GET /api/meta, which the ⓘ panel in the header shows as Second Brain v<version> · <short commit>; the tooltip has the full hash and the build time. On a laptop there is no build file, so the app asks git instead and shows the checkout's commit. Bump app/package.json's version when you want a new number in the panel.

Caddy is rendered from app/deploy/Caddyfile.template. If you already run Caddy for other sites, diff the rendered file against your live one before the first deploy, and use EXTRA_CADDY_IMPORT for the others.

The login

One user. The password is hashed with scrypt (128 MiB, verified one at a time); the second factor is RFC 6238 TOTP with single-use codes. Both factors are always checked and failures share one message. Sessions are random 256-bit tokens in a __Host- cookie (HttpOnly, Secure, SameSite=Strict); only their SHA-256 is stored. They end after 7 idle days or 30 days total, and POST /api/auth/logout-all ends every session. Five failures lock an IP out for 15 minutes; 100 failures in an hour lock everyone out. Every mutating request must carry the right Origin.

When a deploy does not come up

Before it builds, deploy.sh keeps the build that is running (server/dist, web/dist and the build-info file) in <app dir>/data/rollback. If the build, the migration or the app itself then fails, it puts that copy back, restarts, and checks the app again:

Outcome What you see Exit code
Normal deploy ✓ secondbrain running after /api/health answers 0
Deploy failed, previous build restored ✓ rolled back: secondbrain is serving the previous build 3
Deploy failed and the rollback did not come up ✗ the rollback did not come up either 4

DEPLOY_NO_ROLLBACK=1 turns this off, for the deploy that is meant to change something the previous build cannot run against. The health check is the app's own /api/health route on 127.0.0.1:$PORT (from /etc/secondbrain/env), polled for 30 seconds.

Two things a rollback is not:

  • It restores code, not the schema. Migrations are forward-only, so a migration this deploy applied stays applied. Additive changes are fine; a migration that removes or renames what the previous build reads is not, and the rollback then reports exit 4.
  • It is deploy-time, not a watchdog. It covers a build or migration that failed and an app that will not start. An app that runs for an hour and then dies is systemd's Restart=on-failure business, not this.

Updating a server

  1. Move your vault's engine pin (vault.md) and commit.
  2. Compare migrations first. If anything else has run migrations on the server's database, list pgmigrations there and check that every applied migration exists in app/db/migrations/. node-pg-migrate refuses to run when the database has a migration the code doesn't.
  3. Run .secondbrain/app/deploy/deploy.sh again.

Several machines, one database

You can keep the server as the only database and have your laptop use it (for example over a private network such as Tailscale or WireGuard):

  • On the laptop, set SECONDBRAIN_DB_URL in the vault .env. The launcher then starts the app with INDEXER=off and skips migrations.
  • Only the server scans and embeds. Two scanners over two checkouts would fight over the index.
  • Give the laptop its own database role with only the rights it needs.