# Obsidian vault on a Raspberry Pi: obsidian-headless sync + FastMCP server for Claude (Tailscale Funnel, GitHub OAuth)

> Source: <https://gist.github.com/mrmartineau/475dc3e8ffc6908f1493a05989a116ff>
> Published: 2026-09-09 08:34:40+00:00

Two containers keep a live copy of the Obsidian vault on the Pi and let Claude read and write it:

- **obsidian-sync** — the official`obsidian-headless` client (`ob sync --continuous` ) in a
community arm64 image. It joins Obsidian Sync as one more device, so the Pi's copy is always
current, and anything Claude edits on the Pi shows up on the laptop and phone within seconds.
- **obsidian-mcp** — a ~100-line FastMCP server (`obsidian-mcp/server.py` ) with five tools
(`list_notes` ,`read_note` ,`search_notes` ,`write_note` ,`append_note` ) over that same folder.
Login is GitHub OAuth, and only the GitHub user in`MCP_ALLOWED_GITHUB_LOGIN` gets through.`MCP_UNTRUSTED_FOLDERS` (web clippings, third-party text) are read-only for Claude and search
returns no text from them, so a clipped page that reads like an instruction can't ride into a
chat unseen. Claude Code
on the laptop and the Claude app on the phone both reach it over HTTPS via Tailscale.[`obsidian-mcp/README.md`](2-obsidian-mcp-README.md) has the full story: tools, login,
connecting each Claude, revoking access, troubleshooting.

The vault lives on the **Pi's SD card** at `config/obsidian/vault`, not on the NAS: it's ~50 MB,
inotify doesn't fire over NFS (so continuous sync would miss edits made on the Pi), and Obsidian
Sync already keeps version history, so `backup.sh` ignores it on purpose. `.obsidian/` (plugin
state, workspace layout) is hidden from the MCP tools.

```
laptop: Claude Code  ──tailnet──▶  https://mypi….ts.net/mcp  ──▶ obsidian-mcp ──▶ vault ◀── obsidian-sync ◀──▶ Obsidian Sync
phone:  Claude app   ──▶ Anthropic servers ──public (Funnel)──▶  (same URL)                                       ▲
phone:  Claude app   ──▶ Anthropic relay  ──▶ `claude remote-control` running ON the Pi in the vault folder ─────┘
```

The Claude app never talks to an MCP server itself — Anthropic's servers do, on its behalf. A
tailnet-only URL is invisible to them, so the phone connector needs **Tailscale Funnel** (a public
HTTPS URL). The GitHub login + allowlist is what actually guards the notes: the URL alone gets a
stranger a GitHub consent page and then "not allowed". (To take it off the public internet later:
`sudo tailscale funnel --https=443 off`, then `sudo tailscale serve --bg --https=443 127.0.0.1:8100`
for tailnet-only, and use **Remote Control** for the phone.)

1. 
**Obsidian Sync token** — on the Pi, interactive (asks email, password, 2FA code). Run this*before* the first deploy: it also creates`config/obsidian/{vault,sync}` as`pi` , which the
sync container needs (see Gotchas).

```
make notes-token
```

 Put it in `.env` as`OBSIDIAN_AUTH_TOKEN` , with`OBSIDIAN_VAULT_NAME` (the vault's name in
Obsidian → Settings → Sync) and`OBSIDIAN_VAULT_PASSWORD` (its end-to-end password). See`.env.example` .
2. 
**GitHub OAuth app** — github.com → Settings → Developer settings → OAuth Apps → New:
  - Homepage URL: `https://mypi.your-tailnet.ts.net`
  - Authorization callback URL: `https://mypi.your-tailnet.ts.net/auth/callback`
 Put the Client ID + a generated client secret in `.env` (`MCP_GITHUB_CLIENT_ID` ,`MCP_GITHUB_CLIENT_SECRET` ), plus`MCP_JWT_SIGNING_KEY=$(openssl rand -hex 32)` .
3. Homepage URL: 
4. 
**Deploy** from the laptop (`./deploy.sh` ), then watch the first sync pull the vault down:

```
make logs svc=obsidian-sync     # first run: sync-setup, then "watching for changes"
ls config/obsidian/vault
```

5. 
**Public HTTPS via Tailscale Funnel** — on the Pi, once. Needs**MagicDNS** and**HTTPS
certificates** turned on in the Tailscale admin console (DNS page) first.

```
make notes-funnel     # = sudo tailscale funnel --bg --https=443 127.0.0.1:8100
```

 The first time, it prints a `login.tailscale.com/f/funnel?node=...` link and then**waits** (it looks stuck). Open the link, click Allow, and it finishes by itself. Then**wait five
minutes** before connecting anything: the name only enters public DNS once Funnel is on, and
resolvers that asked earlier cache "no such name" for that long. This is host state, not in
git: after a fresh OS install, run it again. Check from anywhere (phone on 4G is a good test):

```
curl https://mypi.your-tailnet.ts.net/.well-known/oauth-authorization-server   # JSON = alive
claude mcp add --transport http --scope user obsidian https://mypi.your-tailnet.ts.net/mcp
```

`--scope user` = available in every project, not just the folder you ran it in. Then inside Claude Code: `/mcp` → **obsidian** → Authenticate → GitHub login in the browser.

Connectors belong to the account, so add it once and every app has it. Mac app or claude.ai →
Settings → **Connectors** → **Add custom connector** → URL
`https://mypi.your-tailnet.ts.net/mcp` → Add → **Connect** → GitHub login. Then in any
chat (Mac, web, or phone): **+** → **Connectors** → switch **obsidian** on, per chat.
Step-by-step with the phone specifics: [`obsidian-mcp/README.md`](2-obsidian-mcp-README.md#connect-claude).

Claude Code can run *on the Pi* in the vault folder and be driven from the phone or
claude.ai/code — the full agent, editing files directly. No MCP, no Funnel, works on Pro/Max.

```
# one-time, on the Pi
curl -fsSL https://claude.ai/install.sh | bash     # native arm64 build
sudo apt install -y tmux
claude                                             # then /login (claude.ai account), then exit

# every time (survives SSH disconnect, not a reboot)
make notes-rc                                      # then: tmux attach -t notes-rc, scan the QR
```

The session shows up in the Claude app under Code. Edits land in `config/obsidian/vault` as `pi`
and obsidian-sync pushes them to Obsidian Sync straight away. `--permission-mode default` is set
on purpose: every edit and every command asks for a tap. This session has none of the MCP's
guards (no writable-folder rule, no hidden-character filter), it can write anywhere in the vault,
and it reads the vault's `CLAUDE.md` as instructions. The tap is the guard. See
[Prompt injection](2-obsidian-mcp-README.md#prompt-injection-whats-guarded-and-what-isnt).

Clipped web pages and pasted transcripts land in the vault, and a note whose text reads like an
instruction can steer a later Claude turn (that's why `MCP_UNTRUSTED_FOLDERS` are read-only and
text-less in search). [`./scan-vault.py`](./scan-vault.py) runs **on the Pi** once a day
and looks in the usual hiding places: invisible Unicode, HTML comments, CSS-hidden text,
chat-template markers, "ignore previous instructions" phrasing, long encoded blobs. No AI in the
loop, on purpose: a checker that reads the notes with an LLM can be tricked by the very text it
is looking for.

The MCP handles the hidden-text trick itself at both edges: invisible Unicode is stripped from what Claude reads and refused in what it writes, and chat-template markers are refused on write. The scan is for what those can't catch: visible text that reads like an order.

It writes `Scratchpad/Injection scan.md` (note, line number and a label; never the text, so the
report can't carry a payload into a chat) and Obsidian Sync brings it to the laptop. Expect a
handful of harmless hits (clipped articles, templates, notes *about* AI); look at anything you
don't recognise.

```
make scan                          # run now; exit 1 if anything was flagged
./scan-vault.py --self-test        # the built-in check
```

Cron, as `pi` (`crontab -e`):

```
10 4 * * *  /home/pi/pi-nas/scan-vault.py >> /home/pi/pi-nas/scan.log 2>&1   # daily 04:10
```

- **Two writers, one file.** Claude on the Pi and you on the laptop editing the same note at the
same moment →`CONFLICT_STRATEGY=merge` (Obsidian's default) merges line by line rather than
forking the file. Version history in Obsidian → Sync has every prior state.
- **Changed the MagicDNS name?** It's in four places:`BASE_URL` in`docker-compose.yml` , the
GitHub OAuth app's callback URL, every connected Claude (re-add the connector), and the`obsidian-mcp` health link in`pi-dashboard/public/index.html` (`WEBUI` map).
- **New `MCP_JWT_SIGNING_KEY`** = every Claude has to log in again. Same if you remove the`<project>_obsidian-mcp-config` Docker volume (it holds the OAuth registrations + tokens; it's a
named volume, not`./config/` , because it must be owned by`pi` and Docker creates missing
bind-mount dirs as root).
- **Vault password wrong** → obsidian-sync exits at start with a decryption error; check`make logs svc=obsidian-sync` . It's the vault's own E2E password, not your Obsidian account one.
- **obsidian-sync dies at "Logging in" with a permission error** →`config/obsidian/sync` is
owned by root. Docker creates missing bind-mount dirs as root, and the image only fixes
ownership when`PUID` differs from its built-in 1000 (ours is 1000). Fix:`sudo chown -R pi:pi config/obsidian && make restart svc=obsidian-sync` .`make notes-token` pre-creates the dirs so a normal first run never hits this.
