18 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.delag 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 cloneist 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-RenderingYamlDotNet— Frontmatter undtermine.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:
actions/checkout- Login an der Gitea-eigenen Container-Registry
docker/build-push-action→gitea.<domain>/tom/elternbeirat:latestund:${{ gitea.sha }}
Zwei Stolpersteine, die erfahrungsgemäß Zeit kosten:
- Der
act_runnerim Docker-Modus braucht Zugriff auf einen Docker-Socket oder einen DinD-Service, sonst schlägtbuild-push-actionfehl. - 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:
- 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.
- Falls nur Dateien vorliegen:
wp-content/uploadsenthält Bilder und PDFs, die Texte liegen aber in der Datenbank. Dann Schritt 3. - Wayback Machine auf Snapshots von
elternbeirat-igmh.deprüfen. - 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.yamlhat ein Port-Mapping5000:8080. Produktiv: kein Mapping, nur über das externenpm-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 indocs/deployment.mdnotiert.
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) undrelease.sh(baut ausmain, taggt:latestund Commit-Kurz-SHA, pusht beide; bricht ab, wenn nicht aufmainoder 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. .gitattributeserzwingt 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.