Files
Schulcloud-MCP/docs/PI.md
MechaCat02 bac91303bf Add a setup guide for the Pi, and a compose file its .env selects
docs/PI.md goes from a Pi with Docker to a working claude.ai connector:
configuration, the first token, Caddy, DNS, the VPS forwarding raw TCP,
checks from outside, connecting the clients, the monthly token, updates,
backups and troubleshooting.

docker-compose.override.yml is tracked, so Compose would have merged it on
the Pi as well — publishing ports and switching the crawl timer off. The
Pi's .env sets COMPOSE_FILE to add deploy/docker-compose.pi.yml instead,
which joins the existing Caddy network by name and builds DATABASE_URL, so
nothing tracked needs editing there. Postgres moves to a private network
shared only with the server.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 20:19:17 +02:00

15 KiB

Setting up on the Raspberry Pi

A step-by-step guide from a Pi with Docker to a working claude.ai connector. DEPLOYMENT.md explains why each piece is there; this page is the order to do it in.

claude.ai ─┐                    ┌──────────── Pi (home, always on) ─────────────┐
Claude Code├─HTTPS─▶ VPS ─TCP─▶ │ Caddy ─▶ schulcloud-mcp ─▶ schulcloud-thueringen.de
CLI ───────┘  (public IP,       │ (TLS)     └─ postgres (private network)        │
               your domain)     └────────────────────────────────────────────────┘

What you need

  • The Pi, always on. More than two hours offline ends the Schulcloud session, and only a new token from a browser revives it. A Pi 5 on 64-bit Raspberry Pi OS is plenty; put Docker's data on an SSD rather than the SD card if you can, since Postgres and the file mirror (about 1.3 GB on the current account) write to it.
  • Docker Engine with the Compose plugin on the Pi.
  • Caddy running in a container on the Pi, serving ports 80 and 443. This server joins its Docker network; it opens no port of its own.
  • The VPS, with a public IPv4 address, forwarding TCP ports 80 and 443 to the Pi (step 7).
  • A hostname in your domain — mcp.example.org below — whose A record points at the VPS.
  • Your Schulcloud login, for the first token, and a machine with the schulcloud CLI (npm link in a checkout; see CLI.md).

1. Prepare the Pi

sudo apt update && sudo apt full-upgrade -y
curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker "$USER"     # then log out and back in
docker compose version
timedatectl                          # "System clock synchronized: yes"

The clock matters: token expiry and TLS certificates are both judged by it.

2. Get the code

sudo mkdir -p /opt/schulcloud-mcp && sudo chown "$USER": /opt/schulcloud-mcp
git clone https://git.mc02.dev/fabi/Schulcloud-MCP.git /opt/schulcloud-mcp
cd /opt/schulcloud-mcp

Every command below runs in /opt/schulcloud-mcp.

3. Configure

Find the Docker network your Caddy container is on:

docker ps --format '{{.Names}}' | grep -i caddy
docker inspect -f '{{range $k, $v := .NetworkSettings.Networks}}{{$k}} {{end}}' <caddy-container>

Create .env, readable by you alone, and add the Pi's settings with freshly generated secrets — replace <network> with the name you just found:

cp .env.example .env && chmod 600 .env
sed -i '/^MCP_AUTH_TOKEN=$/d; /^DATABASE_URL=/d' .env
cat >> .env <<EOF

# --- the Pi ---
COMPOSE_FILE=docker-compose.yml:deploy/docker-compose.pi.yml
CADDY_NETWORK=<network>
POSTGRES_PASSWORD=$(openssl rand -hex 24)
MCP_AUTH_TOKEN=$(openssl rand -hex 32)
MCP_PATH_SECRET=$(openssl rand -hex 32)
INDEX_PERSONAL_FILES=true
EOF

What those lines do:

Setting
COMPOSE_FILE Makes every docker compose command here use deploy/docker-compose.pi.yml, which joins Caddy's network and wires up the database. It also keeps docker-compose.override.yml out — that one is for local development and would publish ports and switch the crawl timer off.
CADDY_NETWORK The network Caddy reaches this server on, by the name schulcloud-mcp.
POSTGRES_PASSWORD The bundled Postgres, which sits on a private network with this server only. Hex, so it needs no escaping inside the connection URL.
MCP_AUTH_TOKEN What Claude Code, the CLI and the /token page present.
MCP_PATH_SECRET The secret path claude.ai uses until OAuth exists.
INDEX_PERSONAL_FILES Also indexes your own files and handed-in work, including teachers' feedback. Optional.

Copy MCP_AUTH_TOKEN and MCP_PATH_SECRET into your password manager now — you need both again in step 9, and neither is ever printed by the server:

grep -E '^(MCP_AUTH_TOKEN|MCP_PATH_SECRET)=' .env

4. The first Schulcloud token

  1. Open a private window and log in to Schulcloud.
  2. DevTools (F12) → Application (Firefox: Storage) → Cookies → the cookie named jwt → copy its value.
  3. Put it into .env as TSC_JWT_COOKIE=<value>.
  4. Close the private window. Left open, it logs the token out about two hours after login (AUTH.md has the whole story).

This is the only time the token goes into .env. Later ones go in without a restart (step 11).

5. Start the server

docker compose up -d --build        # the first build takes a few minutes on a Pi
docker compose ps
docker compose logs -f schulcloud-mcp

Expect, within a few seconds:

[schulcloud-mcp] listening on 0.0.0.0:8080 — instance https://schulcloud-thueringen.de, auth enabled (plus secret MCP path), token from environment, 29 day(s) left, keepalive every 30min, index every 6h
[schulcloud-mcp] keepalive: session extended, 7200s (120 min) of budget left
  • auth DISABLED means MCP_AUTH_TOKEN is empty. Stop and fix it.
  • keepalive: token rejected (401) means the token is already dead: get a fresh one (step 4) and hand it over with schulcloud token set once step 9 is done.

The index is empty, so the first full crawl starts on its own. It downloads every course file once — about 15 minutes and 1.3 GB on the current account — and the server answers normally meanwhile.

docker compose ps must show no published ports for either container.

6. Add the site to Caddy

Append deploy/Caddyfile.snippet to the Pi's Caddyfile, with your hostname in place of mcp.example.org, then validate and reload:

docker exec <caddy-container> caddy validate --config /etc/caddy/Caddyfile
docker exec <caddy-container> caddy reload --config /etc/caddy/Caddyfile

Keep the snippet's three easily-missed settings:

  • flush_interval -1, or claude.ai's connection hangs without an error.
  • The long timeouts, or a slow search is cut off.
  • The format filter in log, which keeps the secret path out of the access log.

Caddy gets its certificate once DNS and the forwarding work (step 7). Watch for it with docker logs -f <caddy-container> | grep -i certificate.

7. DNS and the VPS

DNS: an A record for mcp.example.org pointing at the VPS's public IPv4. Publish no AAAA record unless the VPS forwards IPv6 as well.

The forwarding must pass TCP through, untouched. TLS has to end at Caddy on the Pi. A VPS that terminates TLS itself, or proxies HTTP, sees every request path — including the secret one — and may log it. Both ports are needed: 80 for the certificate challenge, 443 for everything else.

If the VPS already forwards to the Pi, check how. On the VPS:

sudo ss -ltnp '( sport = :443 )'

No listening process is the good answer: the kernel forwards the packets, as in the example below. An nginx stream block or HAProxy in mode tcp is also fine. An nginx http server, a Caddy, or HAProxy in mode http on the VPS is not.

Example: WireGuard and nftables

Skip this if your forwarding already passes TCP through. Otherwise, a minimal tunnel with the VPS as 10.8.0.1 and the Pi as 10.8.0.2. Create keys on each machine with umask 077; wg genkey | tee private.key | wg pubkey > public.key.

On the VPS, /etc/wireguard/wg0.conf:

[Interface]
Address = 10.8.0.1/24
ListenPort = 51820
PrivateKey = <vps private key>

[Peer]
PublicKey = <pi public key>
AllowedIPs = 10.8.0.2/32

On the Pi, /etc/wireguard/wg0.conf:

[Interface]
Address = 10.8.0.2/24
PrivateKey = <pi private key>

[Peer]
PublicKey = <vps public key>
Endpoint = <vps public ip>:51820
AllowedIPs = 10.8.0.1/32
PersistentKeepalive = 25

Bring the tunnel up on both (sudo apt install wireguard first), and keep it up across reboots:

sudo systemctl enable --now wg-quick@wg0
ping -c 3 10.8.0.1                  # from the Pi

On the VPS, turn on forwarding and send ports 80 and 443 into the tunnel. Replace eth0 with the VPS's public interface (ip route get 1.1.1.1 names it):

echo 'net.ipv4.ip_forward = 1' | sudo tee /etc/sysctl.d/99-forward.conf
sudo sysctl --system
# /etc/nftables.conf on the VPS (merge into what is there)
table ip schulcloud_forward {
	chain prerouting {
		type nat hook prerouting priority dstnat; policy accept;
		iifname "eth0" tcp dport { 80, 443 } dnat to 10.8.0.2
	}
	chain postrouting {
		type nat hook postrouting priority srcnat; policy accept;
		oifname "wg0" ip daddr 10.8.0.2 tcp dport { 80, 443 } masquerade
	}
}
sudo nft -c -f /etc/nftables.conf && sudo systemctl enable --now nftables

If the VPS's forward chain has policy drop, also accept ct state established,related and new TCP 80/443 from eth0 to wg0. Its firewall needs 80/tcp, 443/tcp and 51820/udp open, plus SSH. With the masquerade, Caddy's logs show the VPS's tunnel address as the client; nothing here relies on client addresses.

Check from the VPS that the Pi's Caddy answers through the tunnel:

curl -sI http://10.8.0.2/ -H 'Host: mcp.example.org' | head -1      # a redirect to https

8. Check it from outside

From any machine that is not the Pi:

curl -s https://mcp.example.org/healthz
# {"status":"ok","sessions":0,"index":"on"}

curl -s -o /dev/null -w '%{http_code}\n' -X POST https://mcp.example.org/mcp \
  -H 'content-type: application/json' -d '{}'
# 401   ← the bearer check

curl -s -o /dev/null -w '%{http_code}\n' -X POST https://mcp.example.org/$(openssl rand -hex 32)/mcp \
  -H 'content-type: application/json' -d '{}'
# 404   ← a wrong path secret, answered like any unknown path

Then confirm that TLS really ends on the Pi: the certificate the world sees is the one Caddy there obtained.

echo | openssl s_client -connect mcp.example.org:443 -servername mcp.example.org 2>/dev/null \
  | openssl x509 -noout -issuer -enddate                     # from outside
docker logs <caddy-container> 2>&1 | grep -i 'certificate obtained' | tail -1   # on the Pi

9. Connect Claude and the CLI

CLI, on your laptop:

schulcloud login --server https://mcp.example.org --token <MCP_AUTH_TOKEN>
schulcloud token      # expires … (29 day(s) left); session alive, 120 min budget; from TSC_JWT_COOKIE
schulcloud status     # after the first crawl: generation 1 — crawled … min ago

Claude Code, replacing the registration that points at the laptop:

claude mcp remove --scope user schulcloud
claude mcp add --transport http --scope user schulcloud https://mcp.example.org/mcp \
  --header "Authorization: Bearer <MCP_AUTH_TOKEN>"
claude mcp list       # schulcloud: https://mcp.example.org/mcp (HTTP) - ✔ Connected

claude.ai:

  1. Customize → Connectors → Add custom connector.
  2. Name: Schulcloud. URL: https://mcp.example.org/<MCP_PATH_SECRET>/mcp. No sign-in.
  3. In a chat: + → Connectors → switch Schulcloud on, and ask "Welche Kurse habe ich?"

The URL is the credential for as long as the secret path is in use: keep it out of screenshots and notes. DEPLOYMENT.md says what that trades away.

Retire the laptop's container once the Pi answers — in the laptop checkout, docker compose down keeps its index and mirror volumes. Its session is separate and simply lapses.

10. Verify the first crawl

docker compose logs schulcloud-mcp | grep 'scheduled crawl'
# [schulcloud-mcp] scheduled crawl: generation 1, 1030 files, … newly extracted, …s

A second crawl runs every six hours and downloads only what is new.

11. The monthly token

The token lasts 30 days. From a week before, the log, whoami and schulcloud token warn. Replacing it takes a minute and no restart:

  1. Private window → log in → copy the jwt cookie (as in step 4).
  2. schulcloud token set and paste it — or open https://mcp.example.org/token and paste it there with MCP_AUTH_TOKEN.
  3. Close the private window.

The server checks the token with Schulcloud before using it, restarts the keepalive, and saves it in the state volume, where it outlives restarts and takes precedence over the older one in .env.

The same steps revive a session that lapsed — after a power cut of more than two hours, say.

Updating

cd /opt/schulcloud-mcp
git pull
docker compose up -d --build
docker compose logs --tail 20 schulcloud-mcp

COMPOSE_FILE in .env keeps applying the Pi settings. A rebuild keeps the database, the mirror and a replaced token, all of which live in volumes.

Backups

What Needed?
.env Yes — it holds every secret. Only ever back it up encrypted.
Postgres Optional: a crawl rebuilds it. docker compose exec postgres pg_dump -U schulcloud schulcloud | gzip > index.sql.gz
schulcloud-mcp_mirror No — re-downloaded by the next crawl.
schulcloud-mcp_state No — a replaced token, expiring within 30 days anyway.

Troubleshooting

Symptom Likely cause Fix
keepalive: token rejected (401) The session ended: the Pi was off for more than two hours, a Schulportal tab was left open, or 30 days passed Step 11
401 about two hours after pasting a token A Schulportal tab still open on that login Close it, then step 11
claude.ai cannot add the connector DNS, forwarding or certificate not in place, or a wrong secret (404) Step 8's checks, in order
claude.ai connects, then tools hang flush_interval -1 missing from the Caddy site Step 6
/mcp answers 401 with the right token Whitespace copied along with the token Re-copy it
Caddy never obtains a certificate Port 80 not forwarded, or DNS not yet pointing at the VPS Step 7
network … declared as external, but could not be found Wrong CADDY_NETWORK Step 3
required variable … is missing a value A line missing from .env Step 3
EACCES for /data/state or /data/mirror in the logs A volume created by an old image, owned by root docker compose down, docker volume rm schulcloud-mcp_state (or _mirror), docker compose up -d

Security checklist

  • .env is mode 600 and in no unencrypted backup.
  • docker compose ps shows no published ports for schulcloud-mcp or schulcloud-mcp-db.
  • The VPS forwards raw TCP and opens only 80, 443, 51820/udp and SSH.
  • The secret path stays out of Caddy's access log — this prints a count, never the secret:
    docker exec <caddy-container> grep -c "$(grep '^MCP_PATH_SECRET=' .env | cut -d= -f2)" /var/log/caddy/schulcloud-mcp.log
    # 0
    
  • If the claude.ai URL may have leaked: set a new MCP_PATH_SECRET, run docker compose up -d --force-recreate schulcloud-mcp, and re-add the connector.