The Dockerfile creates /data/mirror and /data/state with the right owner, and the comment above it says exactly why: Docker initialises a new named volume from the image directory, so a mount point the image does not have lands root-owned and the unprivileged user gets EACCES on every write. /data/notes was added to docker-compose.yml without being added here, so every save on a fresh deployment would have failed that way — verified both directions before fixing it. docs/DEPLOY-NOTES.md is the runbook for putting this on a server that is already running: publish, decide where the notes live *before* anything writes one, set WEB_PASSWORD, verify, migrate, index. Plus rollback, which is uneventful — no migration, and the old image simply ignores the new settings and leaves the notes volume alone. PI.md's backup table needed the bigger change. Everything else this server stores is a copy of something upstream and a crawl rebuilds it; the notes are not, and nothing can. They are now the one entry in that table marked irreplaceable, and the EACCES row says to fix the volume's ownership rather than delete it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
488 lines
21 KiB
Markdown
488 lines
21 KiB
Markdown
# 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}}' <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:
|
|
|
|
```bash
|
|
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_CONNECTOR_TOKEN=$(openssl rand -hex 32)
|
|
INDEX_PERSONAL_FILES=true
|
|
# SCHULCLOUD_MCP_TAG=latest
|
|
EOF
|
|
```
|
|
|
|
To write your own lesson notes — and to replace the Schulcloud token from a
|
|
phone rather than a terminal — set a password for the app at `/app`. Unlike the
|
|
tokens above it is typed by a person, so it is a passphrase: **at least 12
|
|
characters, three or four words**. See [NOTES.md](NOTES.md).
|
|
|
|
```bash
|
|
cat >> .env <<'EOF'
|
|
WEB_PASSWORD=change-this-to-three-or-four-words
|
|
EOF
|
|
```
|
|
|
|
If your school publishes its timetable in **WebUntis**, add those four values
|
|
too — the timetable, its cancellations and substitutions are not in Schulcloud
|
|
at all. They are in WebUntis under Profil → Freigaben → Untis Mobile → QR-Code:
|
|
|
|
```bash
|
|
cat >> .env <<'EOF'
|
|
UNTIS_SERVER=yourschool.webuntis.com
|
|
UNTIS_SCHOOL=yourschool
|
|
UNTIS_USER=your.username
|
|
UNTIS_SECRET=THEKEYFROMTHEQRDIALOG
|
|
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_CONNECTOR_TOKEN` | What claude.ai sends as a request header. It opens `/mcp` only, never `/api`, because claude.ai stores it. |
|
|
| `INDEX_PERSONAL_FILES` | Also indexes your own files and handed-in work, including teachers' feedback. Optional. |
|
|
| `UNTIS_*` | WebUntis, where the school keeps the timetable. All four or none; the key needs no password and does not expire. See [AUTH.md](AUTH.md). Optional. |
|
|
| `WEB_PASSWORD` | The app at `/app`: the notes editor and a settings page for the Schulcloud token. Unset means no app is served at all. Hashed at startup and never logged; changing it logs out every session. 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`, `MCP_CONNECTOR_TOKEN` and `WEB_PASSWORD` into your
|
|
password manager now** — you need the first two again in step 9, and none of
|
|
them is ever printed by the server:
|
|
|
|
```bash
|
|
grep -E '^(MCP_AUTH_TOKEN|MCP_CONNECTOR_TOKEN|WEB_PASSWORD)=' .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](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 connector token), 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-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 a secret path out of the access log if
|
|
you ever use one (step 9).
|
|
|
|
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 —
|
|
the claude.ai token in its header included — 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 = <vps private key>
|
|
|
|
[Peer]
|
|
PublicKey = <pi public key>
|
|
AllowedIPs = 10.8.0.2/32
|
|
```
|
|
|
|
On the Pi, `/etc/wireguard/wg0.conf`:
|
|
|
|
```ini
|
|
[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:
|
|
|
|
```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 <caddy-container> 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 <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:
|
|
|
|
```bash
|
|
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*. Name: `Schulcloud`. URL:
|
|
`https://mcp.example.org/mcp`.
|
|
2. The next step shows **No sign-in** as detected — keep it — and a **Request
|
|
headers** section. Add one: name `authorization`, value
|
|
`Bearer <MCP_CONNECTOR_TOKEN>`, with the space. Add the connector.
|
|
3. In a chat: **+ → Connectors** → switch *Schulcloud* on, and ask *"Welche Kurse
|
|
habe ich?"*
|
|
|
|
claude.ai stores the header and never shows it again; to change it, remove the
|
|
connector and add it again.
|
|
|
|
*Without request headers* — say, for a client that cannot send them — use a
|
|
secret path instead:
|
|
|
|
```bash
|
|
echo "MCP_PATH_SECRET=$(openssl rand -hex 32)" >> .env
|
|
docker compose up -d --force-recreate schulcloud-mcp
|
|
```
|
|
|
|
The URL is then `https://mcp.example.org/<MCP_PATH_SECRET>/mcp`, with no header.
|
|
It is the credential itself, so keep it out of screenshots and notes;
|
|
[DEPLOYMENT.md](DEPLOYMENT.md#without-request-headers--a-secret-path) 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
|
|
|
|
Everything this server stores is a copy of something upstream — with one
|
|
exception. **Your own notes are not a copy of anything**, and nothing can
|
|
rebuild them.
|
|
|
|
| What | Needed? |
|
|
|---|---|
|
|
| `.env` | **Yes** — it holds every secret. Only ever back it up encrypted. |
|
|
| **`schulcloud-mcp_notes`** | **Yes. The only irreplaceable thing here** — a note taken in a lesson cannot be retaken. Small: a school year is a few megabytes. |
|
|
| 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. |
|
|
|
|
```bash
|
|
docker run --rm -v schulcloud-mcp_notes:/notes:ro -v "$PWD":/out alpine \
|
|
tar czf /out/notizen-$(date +%F).tar.gz -C /notes .
|
|
```
|
|
|
|
If you bind-mounted a directory you already sync ([NOTES.md](NOTES.md)), back
|
|
that up instead — and check the sync tool keeps versioned copies rather than
|
|
assuming it does.
|
|
|
|
## 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; a missing or mistyped request header (401); or, with a secret path, a wrong secret (404) | Step 8's checks, in order, then the header |
|
|
| The connector token works on `/mcp` but not with the CLI | By design: it is refused on `/api` | The CLI uses `MCP_AUTH_TOKEN` |
|
|
| 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) |
|
|
| `WebUntis rejected the server's key` from a untis_* tool | The key was regenerated in WebUntis, or `UNTIS_USER` does not match it | Copy both again from Profil → Freigaben → Untis Mobile, then `docker compose up -d --force-recreate schulcloud-mcp` |
|
|
| `the server's clock is too far off` from a untis_* tool | The Pi's clock has drifted; the Untis code is time-based | `timedatectl status`, then fix NTP |
|
|
| `EACCES` for `/data/state`, `/data/mirror` or `/data/notes` in the logs | A volume created by an old image, owned by root | `docker compose down`, `docker volume rm schulcloud-mcp_state` (or `_mirror`) and `docker compose up -d`. **Never delete `_notes`** — back it up, then fix the ownership: `docker run --rm -v schulcloud-mcp_notes:/n alpine chown -R 1000:1000 /n` |
|
|
| `/app/` answers 404 | `WEB_PASSWORD` not set, or the container not recreated since it was | `grep ^WEB_PASSWORD= .env`, then `docker compose up -d --force-recreate schulcloud-mcp` |
|
|
| The app logs you out constantly | The session cookie is marked `Secure` but the connection is not HTTPS, or Caddy's `X-Forwarded-Proto` is being stripped | Reach it over HTTPS; leave that header alone |
|
|
| Notes exist but `search` cannot find them | Only a **full** crawl reads them | `schulcloud refresh --force` |
|
|
|
|
## 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.
|
|
- With a secret path set, it stays out of Caddy's access log — this prints a
|
|
count, never the secret:
|
|
```bash
|
|
docker exec <caddy-container> grep -c "$(grep '^MCP_PATH_SECRET=' .env | cut -d= -f2)" /var/log/caddy/schulcloud-mcp.log
|
|
# 0
|
|
```
|
|
- If claude.ai's token may have leaked: set a new `MCP_CONNECTOR_TOKEN`, run
|
|
`docker compose up -d --force-recreate schulcloud-mcp`, and re-add the
|
|
connector with the new header. A leaked secret path is replaced the same way.
|
|
`MCP_AUTH_TOKEN` stays valid either way.
|
|
- `WEB_PASSWORD` is the one credential here that can be guessed, so its length
|
|
is what protects the notes and the Schulcloud token. Changing it and
|
|
recreating the container logs out every session — that is the revocation
|
|
mechanism, and the thing to do if a phone is lost.
|