Files
Elternbeirat/plan.md
T
tleiningerandClaude Opus 4.8 2298051bf4 Add plan.md and CLAUDE.md
Architecture decisions with rationale (plan.md) and repo-specific
build/run commands plus style rules (CLAUDE.md).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-20 21:51:18 +02:00

16 KiB

elternbeirat-igmh.de — Neuaufbau

Ablösung der bisherigen WordPress-Seite durch eine eigene .NET-Anwendung auf eigener Infrastruktur.

Stand: 2026-09-20 · Verantwortlich: Tom (Thomas Leininger)


1. Ausgangslage

  • Die Domain elternbeirat-igmh.de lag bei Maik Palm (ausscheidendes Elternbeirat-Mitglied) im Paket „STRATO Hosting Basic", Auftragsnummer 9157927.
  • Domaininhaber-Wechsel und Domainumzug sind beidseitig unterschrieben (19.09.2026), die Einreichung bei Strato steht noch aus.
  • Ziel-Paket: Toms „STRATO Mail Plus" (Auftragsnummer 8844576) — Domain + E-Mail, kein Webspace.
  • E-Mail bleibt bei Strato. Nur die Website zieht auf eigene Hardware.
  • Die bisherige Website ist aktuell offline. Der Inhaltsbestand ist damit der zeitkritischste offene Punkt (→ Abschnitt 9).

2. Ziele und Nicht-Ziele

Ziele

  • Öffentliche Informationsseite des Elternbeirats: wer, wann, welche Protokolle, wie erreichbar.
  • Betrieb auf eigener Infrastruktur (Unraid), ohne fremden Hoster.
  • Inhalte versionierbar und ohne Datenbank — ein git clone ist das vollständige Backup.
  • Wartungsarm: keine Plugin-Updates, keine PHP-Sicherheitslücken, kein CMS-Login als Angriffsfläche.

Nicht-Ziele (bewusst)

  • Kein CMS mit Web-Editor in Stufe 1. Falls andere Beiratsmitglieder später selbst redaktionell arbeiten sollen, ist das ein eigener Ausbauschritt (→ Abschnitt 11).
  • Keine Benutzerkonten, kein Login, kein Mitgliederbereich.
  • Keine Datenbank.
  • Keine externen Einbindungen (Fonts, Analytics, Maps, Social Widgets) — aus Datenschutzgründen, siehe Abschnitt 10.

3. Architekturentscheidungen

AE-1: Blazor mit Static Server-Side Rendering, nicht WebAssembly

Entscheidung: Blazor Web App mit Interaktivitätsmodus None (reines statisches SSR). Zielframework .NET 10 (LTS).

Begründung: Blazor WASM lädt mehrere MB Runtime vor dem ersten sichtbaren Buchstaben, liefert Suchmaschinen und Link-Vorschauen (WhatsApp, Signal, Messenger — der Hauptverbreitungsweg bei Elternschaften) eine leere Shell und bringt auf einer reinen Informationsseite keinerlei Gegenwert. Static SSR liefert fertiges HTML, braucht kein JavaScript und kostet im Container rund 60 MB RAM.

Konsequenz: Interaktivität ist später pro Komponente nachrüstbar (@rendermode InteractiveServer an genau der einen Komponente), ohne die Architektur zu ändern.

Verworfene Alternativen:

Alternative Warum nicht
Blazor WASM Payload, SEO, Link-Vorschauen, kein Nutzen
ASP.NET Core MVC/Razor Pages Funktioniert genauso, aber Razor Components sind das modernere Modell
Statiq.Web (C#-SSG) → nginx Ops-technisch am schlanksten (nichts zu patchen), aber jede Textänderung erzwingt einen Build-Lauf. Bleibt als Rückfallebene.
Astro/Hugo Ausgereifteres SSG-Ökosystem, aber fremdes Terrain

AE-2: Inhalte als Dateien im Repo, nicht in einer Datenbank

Entscheidung: Seiteninhalte als Markdown mit YAML-Frontmatter, Termine als strukturiertes YAML, Dokumente (Protokolle, Satzung) als PDF unter wwwroot.

Begründung: Kein DB-Backup, kein Migrationsschema, keine Konsistenzprobleme zwischen Dateien und Datenbank. Änderungen sind Commits und damit nachvollziehbar und rückrollbar. Für eine Seite mit ~10 Unterseiten und ein paar Terminen pro Jahr ist alles andere Overhead.

Konsequenz: Textänderungen erfordern einen Commit und ein Redeploy. Das ist bei erwarteten fünf Änderungen im Jahr akzeptabel — und der Grund, warum Abschnitt 11 den Ausbau zum Web-Editor als eigene Stufe führt.

AE-3: Inhalte werden ins Image gebacken, nicht als Volume gemountet

Entscheidung: Content/ und wwwroot/ sind Teil des Images.

Begründung: Gemountete Inhalte aus dem Appdata-Share ließen sich zwar direkt am NAS editieren, aber genau dann driften Repo und Live-Stand auseinander — und die Eigenschaft „Backup = git clone" aus AE-2 wäre wertlos.

Konsequenz: Kein Schnell-Fix am Live-System. Tippfehler werden korrekt über einen Commit behoben.

AE-4: TLS und Zertifikate ausschließlich im Nginx Proxy Manager

Entscheidung: Der Container spricht nur HTTP auf Port 8080 und hat kein Port-Mapping nach außen. NPM terminiert TLS und ist der einzige Weg zum Container.

Begründung: Entspricht dem bereits etablierten Muster im Home-Lab (cloud.anticarnist.de). Zertifikatsverwaltung bleibt an einer Stelle.

Konsequenz (wichtig): In Program.cs kein UseHttpsRedirection() und kein UseHsts() — sonst Redirect-Schleife hinter dem Proxy. HSTS setzt NPM.


4. Projekt anlegen (Rider)

Template Blazor Web App mit diesen Optionen:

Option Wert
Framework .NET 10.0
Authentication None
Interactive render mode None
Include sample pages aus
Configure for HTTPS an (nur für lokale Entwicklung relevant)
Do not use top-level statements egal
Enlist in .NET Aspire orchestration aus

Projektname: Elternbeirat.Web, Solution: Elternbeirat.

Wichtig ist allein Interactive render mode = None — damit erzeugt das Template kein .Client-Projekt und kein WebAssembly-Bundle.

Angelegt und bestätigt (2026-09-20): Blazor Web App, net10.0, Interactive render mode None, Auth None, Sample pages aus, Docker-Optionen im Dialog aus (Dockerfile schreiben wir selbst, siehe Abschnitt 7). Git-Repository beim Anlegen mit erzeugt. Solution liegt direkt unter RiderProjects\Elternbeirat\, das Projekt in Elternbeirat.Web\ darunter — kein src/-Zwischenverzeichnis, anders als ursprünglich in Abschnitt 5 skizziert.

NuGet-Pakete, die dazukommen:

  • Markdig — Markdown-Rendering
  • YamlDotNet — Frontmatter und termine.yml

5. Repo-Struktur

Elternbeirat/                        ← Repo-Root, = Solution-Verzeichnis
├── Elternbeirat.sln
├── plan.md                          ← dieses Dokument
├── CLAUDE.md                        ← minimal: Trigger, nicht-offensichtliche Kommandos
├── docs/
│   ├── deployment.md                ← Unraid, NPM, Registry, Rollback
│   ├── dns.md                       ← Strato, DynDNS, Mail-Records
│   ├── inhalte-pflegen.md           ← Anleitung für den Nicht-Alltagsfall
│   ├── inhalte-migration.md         ← Übernahme aus dem alten WordPress
│   └── recht.md                     ← Impressum, Datenschutz, Fotos
├── Elternbeirat.Web/
│   ├── Elternbeirat.Web.csproj
│   ├── Components/
│   │   ├── App.razor
│   │   ├── Routes.razor
│   │   ├── Layout/                  MainLayout, NavMenu, Footer
│   │   └── Pages/                   Start, UeberUns, Termine, Protokolle,
│   │                                News, NewsBeitrag, Kontakt,
│   │                                Impressum, Datenschutz, Fehler404
│   ├── Content/                     ← Inhalte, kein Code
│   │   ├── seiten/*.md
│   │   ├── news/2026-09-20-titel.md
│   │   └── termine.yml
│   ├── Services/
│   │   ├── MarkdownContentService.cs
│   │   ├── TermineService.cs
│   │   └── IcsWriter.cs
│   ├── wwwroot/
│   │   ├── css/site.css             ← eigenes CSS, keine CDN-Einbindung
│   │   ├── img/
│   │   └── dokumente/               ← Protokolle, Satzung (PDF)
│   └── Program.cs
├── Elternbeirat.Web.Tests/          ← Smoke-Tests: jede Route liefert 200
├── Dockerfile
├── compose.yaml
├── .dockerignore
└── .gitea/workflows/deploy.yml

CLAUDE.md bleibt bewusst kurz (Build-/Run-Kommandos, Stilregeln, Verweis auf docs/). Die Details liegen in docs/ und werden nur bei Bedarf gelesen.


6. Inhaltsmodell

Seite (Content/seiten/ueber-uns.md):

---
titel: Über uns
slug: ueber-uns
reihenfolge: 20
beschreibung: Wer im Elternbeirat der IGMH mitarbeitet und wie wir arbeiten.
---

## Der Elternbeirat

Fließtext …

Termin (Content/termine.yml):

- titel: Elternbeiratssitzung
  beginn: 2026-10-14T19:30:00
  ende:   2026-10-14T21:00:00
  ort:    IGMH, Raum A103
  oeffentlich: true
  notiz:  Gäste willkommen

News-Beitrag (Content/news/2026-09-20-neue-website.md) — wie Seite, plus datum und autor.

Die Services lesen beim Start alles ein, cachen es im Speicher und stellen es typisiert bereit. In Development zusätzlich ein FileSystemWatcher, damit Textänderungen ohne Neustart sichtbar werden.

Zusatznutzen ohne Mehraufwand: ein ICS-Endpoint unter /termine.ics, der die öffentlichen Termine ausliefert. Eltern abonnieren den Kalender einmal im Handy und sehen jede Sitzung automatisch. Das ist der eine Punkt, an dem die Eigenbau- Lösung die alte WordPress-Seite spürbar schlägt.


7. Build und Deployment

Stufe 1 — Handbetrieb (Start hier)

docker compose build
docker compose up -d

Für fünf Deployments im Jahr vollkommen ausreichend. CI vorab zu bauen wäre Selbstzweck.

Stufe 2 — Gitea Actions (Runner ist vorhanden)

.gitea/workflows/deploy.yml auf Push nach main:

  1. actions/checkout
  2. Login an der Gitea-eigenen Container-Registry
  3. docker/build-push-action → gitea.<domain>/tom/elternbeirat:latest und :${{ gitea.sha }}

Zwei Stolpersteine, die erfahrungsgemäß Zeit kosten:

  • Der act_runner im Docker-Modus braucht Zugriff auf einen Docker-Socket oder einen DinD-Service, sonst schlägt build-push-action fehl.
  • Die Gitea-Registry braucht ein Paket-Token mit Schreibrecht (write:package), nicht das normale Login-Passwort.

Immer auch den SHA-Tag pushen. :latest allein macht Rollback unmöglich.

Redeploy auf Unraid

Watchtower, aber label-scoped — sonst aktualisiert er ungefragt den ganzen Home-Lab-Bestand:

# im Watchtower-Container
WATCHTOWER_LABEL_ENABLE: "true"
# im eb-web-Service
labels:
  com.centurylinklabs.watchtower.enable: "true"

Alternativ: manuell im Unraid-Docker-Tab „Update" drücken. Bei dieser Änderungsfrequenz völlig legitim.

Dockerfile (Skizze)

FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY Elternbeirat.Web/Elternbeirat.Web.csproj Elternbeirat.Web/
RUN dotnet restore Elternbeirat.Web/Elternbeirat.Web.csproj
COPY . .
RUN dotnet publish Elternbeirat.Web/Elternbeirat.Web.csproj -c Release -o /app

FROM mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled AS runtime
WORKDIR /app
COPY --from=build /app .
EXPOSE 8080
ENTRYPOINT ["dotnet", "Elternbeirat.Web.dll"]

Das chiseled-Image läuft ab .NET 8 standardmäßig als non-root (UID 1654) und hört auf Port 8080. Es enthält keine Shell — ein HEALTHCHECK mit curl funktioniert dort nicht. Entweder das normale aspnet:10.0-noble verwenden oder die Überwachung NPM bzw. Uptime Kuma überlassen.


8. Hosting auf Unraid

# compose.yaml
services:
  eb-web:
    image: gitea.<domain>/tom/elternbeirat:latest
    container_name: eb-web
    restart: unless-stopped
    environment:
      ASPNETCORE_URLS: http://+:8080
      TZ: Europe/Berlin
    networks: [npm]
    labels:
      com.centurylinklabs.watchtower.enable: "true"

networks:
  npm:
    external: true

Kein ports:-Block. Der Container ist ausschließlich über das NPM-Docker-Netz erreichbar.

NPM Proxy Host:

Feld Wert
Domain Names elternbeirat-igmh.de, www.elternbeirat-igmh.de
Scheme http
Forward Hostname eb-web
Forward Port 8080
Block Common Exploits an
Websockets Support aus (wird bei statischem SSR nicht gebraucht)
SSL Let's Encrypt, Force SSL, HTTP/2, HSTS

In Program.cs nicht vergessen:

app.UseForwardedHeaders(new ForwardedHeadersOptions
{
    ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto,
    // NPM ist der einzige Weg zum Container (kein Port-Mapping),
    // daher ist das Leeren der Allowlists hier vertretbar:
    KnownNetworks = { }, KnownProxies = { }
});

Ohne das sieht die App jede Anfrage als HTTP und mit der Proxy-IP statt der Client-IP — relevant für korrekte absolute URLs und für die Logs.


9. Inhalte — zeitkritisch

Die alte Seite ist offline, Maiks Strato-Vertrag läuft aber noch. Solange er läuft, ist der Webspace erreichbar; nach der Kündigung ist der Bestand endgültig weg.

Reihenfolge der Rettungsversuche:

  1. Prüfen, was beim Termin mit Maik tatsächlich gesichert wurde (Dateien? MySQL-Dump? Beides?). Ein vollständiger Dump wäre der Idealfall — daraus lassen sich Texte, Seitenstruktur, Medien und PDFs sauber extrahieren.
  2. Falls nur Dateien vorliegen: wp-content/uploads enthält Bilder und PDFs, die Texte liegen aber in der Datenbank. Dann Schritt 3.
  3. Wayback Machine auf Snapshots von elternbeirat-igmh.de prüfen.
  4. Falls nichts davon greift: Maik bitten, vor Vertragsende noch einen Export zu ziehen — oder Inhalte aus den Protokollen und von der Schulseite neu aufbauen.

Diese Frage blockiert den Inhaltsteil, nicht den Technikteil. Das Gerüst lässt sich mit Platzhaltern bauen und später befüllen.


10. Rechtliches

Kein Rechtsrat — aber die Punkte, an denen Schulseiten regelmäßig auffallen:

  • Impressum (§ 5 DDG): Hat der Elternbeirat keine eigene Rechtsform, steht der Betreiber persönlich mit Name und ladungsfähiger Anschrift im Impressum. Das ist eine Entscheidung, keine Formalie — die Privatadresse wird damit öffentlich. Alternative: Anschrift der Schule, aber nur mit deren ausdrücklichem Einverständnis und wenn die Schule Mitbetreiberin ist.
  • Datenschutzerklärung: Mit dem Umzug ist Tom Verantwortlicher im Sinne der DSGVO. Server-Logs mit IP-Adressen benennen, Rechtsgrundlage und Löschfrist festlegen.
  • Keine externen Ressourcen. Google Fonts, Maps, YouTube-Embeds und CDN-Skripte übertragen die IP der Besucher an Dritte. Fonts werden selbst ausgeliefert.
  • Fotos von Kindern: nur mit Einwilligung der Erziehungsberechtigten — bei Schulseiten der mit Abstand häufigste Fehler. Im Zweifel keine Personenfotos.
  • Hosting am privaten Anschluss bedeutet: Die öffentliche IP des Privatanschlusses steht im DNS einer Schulseite. Bewusste Entscheidung, kein Nebeneffekt.

11. Offene Punkte

# Punkt Status
1 Was wurde vom alten WordPress gesichert? offen, zeitkritisch
2 Kann „STRATO Mail Plus" DynDNS? Laut Strato-FAQ ab „PowerWeb Basic 2013 bzw. STRATO Domain" — ob Mail Plus dazuzählt, ist unklar. Beim Support mit anfragen, solange der Umzugsvorgang läuft. offen
3 DynDNS-Updater: Fritzbox (kennt als Exposed-Host-Vorschaltgerät die öffentliche IP) oder ddclient-Container auf Unraid? offen
4 Sollen andere Beiratsmitglieder Inhalte selbst pflegen können? Falls ja: eigener Ausbauschritt (Decap CMS auf Git-Basis oder kleines Admin-UI). offen
5 Kontaktformular gewünscht? Würde Server-Interaktivität und Spam-Schutz erfordern — mailto: ist die aufwandsfreie Alternative. offen
6 Wer springt ein, wenn Tom nicht verfügbar ist? Eine Seite, die nur einer deployen kann, ist eine Abhängigkeit, die der Beirat kennen sollte. offen
7 Formulare bei Strato einreichen (unterschrieben, liegt bereit) offen

12. Umsetzungsreihenfolge

# Schritt Abhängig von
1 Strato-Formulare einreichen, Domainumzug anstoßen —
2 Inhaltslage klären (Abschnitt 9) —
3 Blazor-Projekt in Rider anlegen ✅ (2026-09-20) → nach Gitea pushen —
4 Content-Pipeline: Markdig, YamlDotNet, Services, ICS-Endpoint 3
5 Layout, Navigation, Seiten mit Platzhaltern 4
6 Dockerfile, compose.yaml, lokal gegen NPM testen 5
7 Echte Inhalte einpflegen 2, 5
8 Impressum und Datenschutzerklärung 7
9 DNS umstellen, NPM Proxy Host, Let's Encrypt 1, 6
10 Gitea Actions + Registry + Watchtower 6, 9

Schritte 1 und 2 laufen unabhängig vom Code und sollten sofort starten — Schritt 2 ist der einzige, bei dem Warten echten Schaden anrichtet.