Deployment
secondbrain speaks plain HTTP on port 2020 and never terminates TLS. Something has to sit in front of it. Here are three setups that work, and the details that bite — starting with the one that bites almost everybody exactly once.
Ready-to-run files for all of this live in deploy/ in the repository.
The volume, first
chown -R 65532:65532 on the data directory before the first start. The image is distroless and the process runs as the nonroot user, UID 65532. A bind mount created by Docker belongs to root:root, the first attempt to create the index fails with a permission error, and the container restarts in a loop with a message that does not obviously say "chown". This is the most common way a first deployment fails.
mkdir -p /srv/secondbrain/data sudo chown -R 65532:65532 /srv/secondbrain/data sudo chmod 700 /srv/secondbrain/data # these are your private notes
Two consequences worth knowing before you build habits around it:
- Anything you copy in later needs the same treatment. Unpacking a backup or an Obsidian vault into the directory as root recreates the problem for the new files. Run the
chownagain afterwards; it is cheap and idempotent. - A named Docker volume avoids it entirely, because Docker initialises the volume from the image and the ownership comes out right. Bind mounts are still worth it if you want the notes at a path you can open in an editor — which, for this project, is usually the point.
What the container cannot do
Unlike its sibling aegis, this container cannot run with read_only: true. It writes notes, an index and a git repository; that is its job. Everything else still applies: drop all capabilities, forbid privilege escalation, and run as the non-root user.
user: "65532:65532"
cap_drop: [ALL]
security_opt: ["no-new-privileges:true"]
Traefik with Let's Encrypt
The straightforward setup: Traefik holds the certificate, secondbrain never sees the outside world, and port 2020 is not published at all. Note the absence of a ports: key on the secondbrain service — publishing it would make the plain-HTTP port reachable next to the TLS one, which defeats the exercise.
services:
traefik:
image: traefik:v3
command:
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--entrypoints.web.address=:80"
- "--entrypoints.web.http.redirections.entrypoint.to=websecure"
- "--entrypoints.websecure.address=:443"
- "--certificatesresolvers.le.acme.email=you@example.com"
- "--certificatesresolvers.le.acme.storage=/letsencrypt/acme.json"
- "--certificatesresolvers.le.acme.httpchallenge.entrypoint=web"
ports: ["80:80", "443:443"]
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./letsencrypt:/letsencrypt
restart: unless-stopped
secondbrain:
image: ghcr.io/andreaskasper/secondbrain:latest
volumes:
- ./data:/data
environment:
SECONDBRAIN_PUBLIC_URL: https://brain.example.com
SECONDBRAIN_USERNAME: andreas
SECONDBRAIN_PASSWORD: "bcrypt:$2a$12$..."
SECONDBRAIN_GIT: "true"
user: "65532:65532"
cap_drop: [ALL]
security_opt: ["no-new-privileges:true"]
labels:
- "traefik.enable=true"
- "traefik.http.routers.sb.rule=Host(`brain.example.com`)"
- "traefik.http.routers.sb.entrypoints=websecure"
- "traefik.http.routers.sb.tls.certresolver=le"
- "traefik.http.services.sb.loadbalancer.server.port=2020"
restart: unless-stopped
SECONDBRAIN_PUBLIC_URL must be the URL a browser sees, not the container address. The OAuth discovery documents are generated from it, and a client that is told to fetch a token from http://secondbrain:2020/token will not get very far.
Cloudflare Tunnel
Nothing is published, no port is open, and the connection to Cloudflare is outbound. For a knowledge base on a machine behind a home router this is usually the right answer: there is no listening socket on the internet to find.
services:
cloudflared:
image: cloudflare/cloudflared:latest
command: tunnel --no-autoupdate run
environment:
TUNNEL_TOKEN: "${CLOUDFLARE_TUNNEL_TOKEN}"
depends_on: [secondbrain]
restart: unless-stopped
secondbrain:
image: ghcr.io/andreaskasper/secondbrain:latest
volumes:
- ./data:/data
environment:
SECONDBRAIN_PUBLIC_URL: https://brain.example.com
SECONDBRAIN_USERNAME: andreas
SECONDBRAIN_PASSWORD: "bcrypt:$2a$12$..."
user: "65532:65532"
cap_drop: [ALL]
security_opt: ["no-new-privileges:true"]
restart: unless-stopped
In the Cloudflare dashboard the tunnel's public hostname maps to the service http://secondbrain:2020 on the Compose network. Two settings deserve attention:
- Do not put Cloudflare Access in front of
/mcp. Access answers unauthenticated requests with an HTML login page, and an MCP client that expects JSON gets an HTML document with a 200 status and no useful error. If you want Access, scope it to paths this server does not use. - Raise the idle timeout. Streamable HTTP holds a connection open while the model thinks. A proxy that closes it at 30 seconds turns a long tool call into a disconnect.
Cloudflare in front of Traefik
The third arrangement keeps Traefik and its certificate but turns Cloudflare's orange cloud on, so the origin address is hidden and TLS terminates twice. It is a reasonable middle ground when the machine already runs Traefik for other services.
services:
traefik:
image: traefik:v3
command:
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--entrypoints.websecure.address=:443"
- "--entrypoints.websecure.http.tls.certresolver=le"
- "--certificatesresolvers.le.acme.email=you@example.com"
- "--certificatesresolvers.le.acme.storage=/letsencrypt/acme.json"
- "--certificatesresolvers.le.acme.dnschallenge=true"
- "--certificatesresolvers.le.acme.dnschallenge.provider=cloudflare"
environment:
CF_DNS_API_TOKEN: "${CF_DNS_API_TOKEN}"
ports: ["443:443"]
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./letsencrypt:/letsencrypt
restart: unless-stopped
secondbrain:
image: ghcr.io/andreaskasper/secondbrain:latest
volumes:
- ./data:/data
environment:
SECONDBRAIN_PUBLIC_URL: https://brain.example.com
SECONDBRAIN_USERNAME: andreas
SECONDBRAIN_PASSWORD: "bcrypt:$2a$12$..."
user: "65532:65532"
cap_drop: [ALL]
security_opt: ["no-new-privileges:true"]
labels:
- "traefik.enable=true"
- "traefik.http.routers.sb.rule=Host(`brain.example.com`)"
- "traefik.http.routers.sb.entrypoints=websecure"
- "traefik.http.services.sb.loadbalancer.server.port=2020"
restart: unless-stopped
Set the Cloudflare SSL mode to Full (strict). The ACME challenge has to be DNS-based here, because the HTTP challenge would be answered by Cloudflare rather than by Traefik. Cloudflare's default proxy timeouts are longer than a tunnel's but still finite; if long tool calls are cut off, that is where to look first.
Volumes and backups
There is exactly one directory to back up, and it is readable without this software. That is the property worth preserving: a backup you can restore with tar and read with cat is a backup you will still be able to use in five years.
| Path | Back it up? |
|---|---|
/data/<vault>/**.md | Yes. This is everything that matters. |
/data/<vault>/attachments/ | Yes. Images and PDFs are not reproducible. |
/data/<vault>/.git/ | Yes, if you want history. Losing it costs the past, not the present. |
/data/<vault>/.secondbrain/trash/ | Optional. It is a thirty-day safety net, not an archive. |
/data/<vault>/.secondbrain/index.db | No. It is a cache; it rebuilds from the files. |
# Stop nothing. Writes are atomic and commits are small, so a copy taken # while the server runs is at worst missing the last few seconds. tar czf brain-$(date +%F).tar.gz \ --exclude='.secondbrain/index.db' \ -C /srv/secondbrain data # Restoring: unpack, then fix ownership again. tar xzf brain-2026-07-30.tar.gz -C /srv/secondbrain sudo chown -R 65532:65532 /srv/secondbrain/data
Do not run two containers against the same data directory. The index reconciles against the filesystem and the watcher will keep both copies broadly correct, but two git repositories committing into one working tree will not end well.
Git remote
With SECONDBRAIN_GIT_REMOTE set, every commit is pushed as it is made. That is an off-site copy of a knowledge base with no scheduler, no backup agent and no window during which the last hour of work exists in one place only.
environment:
SECONDBRAIN_GIT: "true"
SECONDBRAIN_GIT_REMOTE: "https://github.com/andreaskasper/brain-personal.git"
SECONDBRAIN_GIT_TOKEN: "${GITHUB_TOKEN}"
SECONDBRAIN_GIT_AUTHOR: "secondbrain"
SECONDBRAIN_GIT_EMAIL: "brain@example.com"
- Use a private repository. This is being said because it will otherwise be forgotten once: the push contains the full text of every note.
- Scope the token to that one repository and give it write access to nothing else. A fine-grained token costs a minute and bounds what a compromised container can reach.
- One remote per vault. Vaults are separate repositories, so a work vault and a private vault can push to different places, or one can push and the other can stay on the machine.
- Pulling is yours. The server pushes; it does not pull on a timer. If you edit the repository elsewhere, pull into the data directory yourself — the watcher will reindex what changes.
Prometheus
Metrics are off until you ask for them, and when they are on the right place for them is a port that never leaves the Docker network. Bind them to a second listener with SECONDBRAIN_METRICS_LISTEN, publish only the MCP port, and let the scraper reach the metrics port over the Compose network. The alternative — adding a route to the reverse proxy that already faces the internet — means the endpoint's only protection is a shared key on a host you also have to keep patched, instead of a port that was never mapped in the first place.
services:
secondbrain:
image: ghcr.io/andreaskasper/secondbrain:latest
ports: ["2020:2020"] # only the MCP port is published
expose: ["9090"] # reachable inside this network, not from the host
volumes:
- ./data:/data
environment:
SECONDBRAIN_PUBLIC_URL: https://brain.example.com
SECONDBRAIN_METRICS: "true"
SECONDBRAIN_METRICS_LISTEN: ":9090"
SECONDBRAIN_METRICS_KEY: "file:/run/secrets/metrics_key"
secrets: [metrics_key]
user: "65532:65532"
cap_drop: [ALL]
restart: unless-stopped
prometheus:
image: prom/prometheus:latest
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml:ro
- ./metrics.key:/etc/prometheus/secondbrain.key:ro
restart: unless-stopped
secrets:
metrics_key:
file: ./metrics.key
The key is a file on both sides, which keeps it out of docker inspect and out of your shell history. It must be at least sixteen characters or the server refuses to start; a trailing newline is trimmed at both ends, so the obvious command is correct:
openssl rand -hex 32 > metrics.key chmod 600 metrics.key sudo chown 65532:65532 metrics.key
Prometheus reads the same file and sends it as a bearer token. credentials_file rather than credentials, so the key is not in the scrape configuration you might one day paste into an issue:
scrape_configs:
- job_name: secondbrain
metrics_path: /metrics
authorization:
type: Bearer
credentials_file: /etc/prometheus/secondbrain.key
static_configs:
- targets: ["secondbrain:9090"]
metrics_listen set, the route is registered on that listener alone, so the public URL answers a genuine 404 on /metrics rather than a 401 — there is nothing there to guess at. Because that decision is made at startup, switching metrics on is a restart of the container, not a reload.
Check it from inside the network before pointing Prometheus at it, and confirm from outside that the path is not there at all:
docker compose exec prometheus wget -qO- \
--header="Authorization: Bearer $(cat metrics.key)" \
http://secondbrain:9090/metrics | head
curl -s -o /dev/null -w '%{http_code}\n' https://brain.example.com/metrics # expect 404
The metrics documentation lists every gauge and counter, and says which of them are worth an alert.
Pointing Claude at it
Add the server URL with /mcp appended. There is nothing to paste beyond that: the client fetches the discovery documents, registers itself, and opens the login page in a browser.
https://brain.example.com/mcp
The first connection is worth watching. On initialize the server returns the vault's .secondbrain/instructions.md, so the client is told the conventions of your knowledge base — where raw material goes, where distilled notes go, how journal entries are named — before it does anything. If that text is wrong, edit the file; it is a Markdown file like any other, and it is the highest-leverage file in the vault.
Verifying by hand, before involving a client:
curl -s https://brain.example.com/.well-known/oauth-protected-resource | head
curl -s -o /dev/null -w '%{http_code}\n' https://brain.example.com/mcp # expect 401
A 401 with a WWW-Authenticate header is the correct answer there. An HTML page instead means something in front of the container is intercepting the request — Cloudflare Access, most often.
Migrating an Obsidian vault in
The file format is the one you already have, so migration is a copy. The only work is deciding what not to copy.
# 1. Create the vault directory. The name must match [a-z0-9][a-z0-9_-]{0,63}
mkdir -p /srv/secondbrain/data/personal
# 2. Copy the notes. Leave Obsidian's own state behind: it is machine
# specific, and no tool here can read inside a dot directory anyway.
rsync -a --exclude '.obsidian/' --exclude '.trash/' --exclude '.DS_Store' \
~/Documents/MyVault/ /srv/secondbrain/data/personal/
# 3. Ownership, again.
sudo chown -R 65532:65532 /srv/secondbrain/data/personal
# 4. Start the container. The index builds on first start; a few thousand
# notes take seconds.
docker compose up -d
Then create the instructions file the vault would otherwise have got from a layout. An imported vault has conventions, but they are in your head:
# This vault Raw material lives in `sources/` and is never edited after capture. Distilled notes live in `wiki/`, one subject per note, titled as a noun. Journal entries are `journal/YYYY/YYYY-MM-DD.md`. Search before creating. If a note about the subject exists, add a section to it with note_section_edit rather than starting a second one.
A few notes on what to expect afterwards:
- Existing frontmatter is left alone. Notes without any are never given
createdorupdatedfields, so an imported vault does not come back with several thousand modified files. - Broken links surface immediately. Run
vault_reviewonce after the import; a vault that has been carried across two apps usually has some. - Obsidian can stay open on the same directory. The watcher exists for exactly that. Both programs writing the same note at the same second is still a bad idea, but ordinary use is not a conflict.
- Plugins that store data outside Markdown do not come across. Anything a plugin kept in
.obsidian/is not part of the notes, and this server will never see it.
Troubleshooting
| Symptom | Cause |
|---|---|
| Container restarts in a loop, permission errors in the log | The data directory does not belong to 65532. See the top of this page. |
| The client registers but the login page never loads | SECONDBRAIN_PUBLIC_URL does not match the URL the browser uses. It must be the external URL, with no trailing slash. |
| An HTML page where JSON was expected | Cloudflare Access, or another authenticating proxy, in front of /mcp. |
| Long tool calls disconnect | An idle timeout in the proxy. Raise it; the connection is held open while the model works. |
| Search finds nothing in a freshly imported vault | The first index build is still running, or the notes landed one directory deeper than intended. vault_stats answers both. |
| Search results are stale after an external edit | Some filesystems do not deliver inotify events — network mounts in particular. Run secondbrain reindex, and prefer local storage for the data directory. |
| Every session is gone after a restart | Expected. Tokens live in memory only; the client will re-register and ask you to sign in. |
| Pushes to the git remote fail | The token expired or lacks write access to that one repository. The commit itself succeeded locally; only the push is missing. |