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

Run 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.
shell
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 chown again 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.

the hardening that does apply
    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.

docker-compose.yml
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.

docker-compose.yml
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.

docker-compose.yml
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.

Two proxies mean two places to raise a timeout and two places that can buffer. If something works with the tunnel and not here, compare the response headers before changing anything in the container: the difference is almost always in front of it.

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.

PathBack it up?
/data/<vault>/**.mdYes. 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.dbNo. It is a cache; it rebuilds from the files.
shell
# 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.

docker-compose.yml
    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.

docker-compose.yml
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:

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

prometheus.yml
scrape_configs:
  - job_name: secondbrain
    metrics_path: /metrics
    authorization:
      type: Bearer
      credentials_file: /etc/prometheus/secondbrain.key
    static_configs:
      - targets: ["secondbrain:9090"]
Prefer the second listener to the key. The two compose and both are worth having, but they are not equal. A port bound inside the Docker network and never mapped cannot be reached from outside whatever the key is; a key protects an endpoint that is already reachable. With 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:

shell
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.

connector URL
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:

shell
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.

shell
# 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:

data/personal/.secondbrain/instructions.md
# 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 created or updated fields, so an imported vault does not come back with several thousand modified files.
  • Broken links surface immediately. Run vault_review once 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

SymptomCause
Container restarts in a loop, permission errors in the logThe data directory does not belong to 65532. See the top of this page.
The client registers but the login page never loadsSECONDBRAIN_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 expectedCloudflare Access, or another authenticating proxy, in front of /mcp.
Long tool calls disconnectAn idle timeout in the proxy. Raise it; the connection is held open while the model works.
Search finds nothing in a freshly imported vaultThe 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 editSome 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 restartExpected. Tokens live in memory only; the client will re-register and ask you to sign in.
Pushes to the git remote failThe token expired or lacks write access to that one repository. The commit itself succeeded locally; only the push is missing.