# Setting up on the Raspberry Pi A step-by-step guide from a Pi with Docker to a working claude.ai connector. [DEPLOYMENT.md](DEPLOYMENT.md) explains *why* each piece is there; this page is the order to do it in. The Pi never builds the server. It pulls the image published to `registry.mc02.dev`, built for arm64 and amd64 by `npm run publish-image` on a development machine ([DEPLOYMENT.md](DEPLOYMENT.md#publishing-an-image)). ``` 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**, Compose 2.24 or later, on the Pi. - **A login for `registry.mc02.dev`**, where the image is published. - **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](CLI.md)). ## 1. Prepare the Pi ```bash 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 # v2.24 or later timedatectl # "System clock synchronized: yes" docker login registry.mc02.dev ``` The clock matters: token expiry and TLS certificates are both judged by it. The login is needed to pull, not only to push; Docker keeps it in `~/.docker/config.json`. ## 2. Get the compose files ```bash 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`. The checkout provides the compose files, `.env.example` and the Caddy snippet; its source code is not built here. ## 3. Configure Find the Docker network your Caddy container is on: ```bash docker ps --format '{{.Names}}' | grep -i caddy docker inspect -f '{{range $k, $v := .NetworkSettings.Networks}}{{$k}} {{end}}' ``` Create `.env`, readable by you alone, and add the Pi's settings with freshly generated secrets — replace `` with the name you just found: ```bash cp .env.example .env && chmod 600 .env sed -i '/^MCP_AUTH_TOKEN=$/d; /^DATABASE_URL=/d' .env cat >> .env < POSTGRES_PASSWORD=$(openssl rand -hex 24) MCP_AUTH_TOKEN=$(openssl rand -hex 32) MCP_PATH_SECRET=$(openssl rand -hex 32) INDEX_PERSONAL_FILES=true # SCHULCLOUD_MCP_TAG=latest EOF ``` What those lines do: | Setting | | |---|---| | `COMPOSE_FILE` | Makes every `docker compose` command here use [`deploy/docker-compose.pi.yml`](../deploy/docker-compose.pi.yml), which runs the registry image — removing the build section, so nothing can be built here — 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. | | `SCHULCLOUD_MCP_TAG` | Which published image to run. Unset means `latest`; a commit id such as `bac9130` pins it, so updates happen only when you change it. 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: ```bash 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=`. 4. **Close the private window.** Left open, it logs the token out about two hours after login ([AUTH.md](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 ```bash docker compose pull # the published image, and Postgres docker compose up -d docker compose ps docker compose logs -f schulcloud-mcp ``` Nothing is built on the Pi: `pull` fetches the arm64 variant of the image, and with the Pi file in place no `docker compose` command can fall back to a build. 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`](../deploy/Caddyfile.snippet) to the Pi's Caddyfile, with your hostname in place of `mcp.example.org`, then validate and reload: ```bash docker exec caddy validate --config /etc/caddy/Caddyfile docker exec 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 | 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: ```bash 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`: ```ini [Interface] Address = 10.8.0.1/24 ListenPort = 51820 PrivateKey = [Peer] PublicKey = AllowedIPs = 10.8.0.2/32 ``` On the Pi, `/etc/wireguard/wg0.conf`: ```ini [Interface] Address = 10.8.0.2/24 PrivateKey = [Peer] PublicKey = Endpoint = :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: ```bash 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): ```bash echo 'net.ipv4.ip_forward = 1' | sudo tee /etc/sysctl.d/99-forward.conf sudo sysctl --system ``` ```nft # /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 } } ``` ```bash 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: ```bash 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: ```bash 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. ```bash 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 2>&1 | grep -i 'certificate obtained' | tail -1 # on the Pi ``` ## 9. Connect Claude and the CLI **CLI**, on your laptop: ```bash schulcloud login --server https://mcp.example.org --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: ```bash claude mcp remove --scope user schulcloud claude mcp add --transport http --scope user schulcloud https://mcp.example.org/mcp \ --header "Authorization: Bearer " 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`. 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](DEPLOYMENT.md#claudeai--a-secret-path-for-now) 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 ```bash 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 A new version is published first, from a development machine: `npm run publish-image` ([DEPLOYMENT.md](DEPLOYMENT.md#publishing-an-image)). Then, on the Pi: ```bash cd /opt/schulcloud-mcp git pull # compose files and docs docker compose pull # the newly published image docker compose up -d docker compose logs --tail 20 schulcloud-mcp ``` `COMPOSE_FILE` in `.env` keeps applying the Pi settings. Recreating the container keeps the database, the mirror and a replaced token, all of which live in volumes. With `SCHULCLOUD_MCP_TAG` pinned, set it to the new commit id before pulling. Going back works the same way: set the previous commit id, then `pull` and `up -d`. ## 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 | | `unauthorized` or `pull access denied` from `docker compose pull` | Not logged in to the registry on the Pi | `docker login registry.mc02.dev` | | `no matching manifest for linux/arm64` | The image was published for amd64 only | Publish again with `npm run publish-image`, which builds both | | Compose rejects `!reset` in `deploy/docker-compose.pi.yml` | Compose older than 2.24 | Update Docker (step 1) | | `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 registry login in `~/.docker/config.json` is only base64-encoded. Keep the Pi user's home private, and use an account that may only pull if your registry can issue one. - 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: ```bash docker exec 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.