# 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`): ```markdown --- 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`): ```yaml - 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) ```bash 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./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: ```yaml # im Watchtower-Container WATCHTOWER_LABEL_ENABLE: "true" ``` ```yaml # 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) ```dockerfile 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 ```yaml # compose.yaml services: eb-web: image: gitea./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:** ```csharp 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 | ✅ beauftragt (2026-09-20) | | 2 | Inhaltslage klären (Abschnitt 9) | — | | 3 | Blazor-Projekt in Rider anlegen ✅ (2026-09-20) → nach Gitea pushen ✅ | — | | 6 | Dockerfile, compose.yaml → als Container auf Unraid ✅ (2026-09-20) | 3 | | 4 | Content-Pipeline: Markdig, YamlDotNet, Services, ICS-Endpoint | 3 | | 5 | Layout, Navigation, Seiten mit Platzhaltern | 4 | | 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. **Abweichung von der ursprünglichen Reihenfolge:** Schritt 6 (Deployment) wurde bewusst **vor** die Content-Pipeline (4/5) gezogen. Grund: die riskanteste Kette — Image bauen → Gitea-Registry → auf Unraid ziehen → Container läuft — früh beweisen, statt sie erst kurz vor dem Livegang zu entdecken. Details in `docs/deployment.md`. ### Stand 2026-09-20 (abends) Erreicht: Das rohe „Hello world" der Blazor-App läuft als Container auf Unraid (`Cube`), erreichbar im LAN unter `http://cube:5000`. Bewiesen ist damit die komplette Deploy-Kette inkl. privater Gitea-Registry. Bewusste **Test-Abweichungen** vom Produktivziel (Abschnitt 8), später zurückzubauen: - `compose.yaml` hat ein Port-Mapping `5000:8080`. Produktiv: kein Mapping, nur über das externe `npm`-Netz (NPM ist noch nicht testbar, Domain zieht erst um). - Image-Tag nur `:latest`, noch kein SHA-Tag (→ Schritt 10, Rollback). - Registry-Token liegt auf Unraid im Klartext (`/root/.docker/config.json`). Credential-Helper ist als späterer Punkt in `docs/deployment.md` notiert. **Nächster Schritt:** Content-Pipeline (Schritt 4) — Markdig + YamlDotNet, Services für Seiten/Termine, ICS-Endpoint. Parallel offen und unabhängig vom Code: Inhaltslage klären (Schritt 2, zeitkritisch). ### Stand 2026-09-21 (vormittags) Deployment weiter ausgebaut und einmal komplett durchgespielt: - **Branch/PR-Workflow** etabliert: nie direkt auf `main`; Feature-Branch → Pull Request in Gitea → Merge. Erstmals durchgeführt (PR #1). - **Zwei Build-Skripte** in `scripts/`: `dev-build.sh` (lokales Dev-Image, kein Push — Dev-Stände bleiben aus der Registry raus) und `release.sh` (baut aus `main`, taggt `:latest` **und** Commit-Kurz-SHA, pusht beide; bricht ab, wenn nicht auf `main` oder Arbeitsverzeichnis unsauber). - **SHA-Tagging** ist damit Standard → Rollback möglich. Erster Release-Tag: `:8643f2c`. Redeploy auf Unraid (Compose Down/Up) bewusst geübt, läuft. - `.gitattributes` erzwingt LF für `*.sh` (sonst scheitert der Shebang unter Windows). Offen fürs nächste Mal (unverändert): Content-Pipeline (Schritt 4) und die zeitkritische Inhaltslage (Schritt 2). Deployment-Automatisierung per Gitea Actions (Schritt 10) ist der nächste optionale Deployment-Ausbau, aber nicht dringend — der Handbetrieb über `release.sh` reicht.