Switch code, scripts and docs to English; drop plan.md references

This commit is contained in:
tleininger committed 2026-09-21 15:25:01 +02:00
1 parent 76f1ad7825
commit c4c7ca2ee4
15 files changed
+532 -540

No files matched your search

+239 -241
View File
@@ -1,160 +1,159 @@
# elternbeirat-igmh.de — Neuaufbau
# elternbeirat-igmh.de — Rebuild
Ablösung der bisherigen WordPress-Seite durch eine eigene .NET-Anwendung auf
eigener Infrastruktur.
Replacing the previous WordPress site with a custom .NET application on
self-hosted infrastructure.
Stand: 2026-09-20 · Verantwortlich: Tom (Thomas Leininger)
As of: 2026-09-20 · Responsible: Tom (Thomas Leininger)
---
## 1. Ausgangslage
## 1. Starting Situation
- 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).
- The domain `elternbeirat-igmh.de` was held by Maik Palm (departing
Elternbeirat member) under the "STRATO Hosting Basic" package, order number 9157927.
- The change of domain owner and the domain transfer are signed by both parties
(19.09.2026); the submission to Strato is still pending.
- Target package: Tom's "STRATO Mail Plus" (order number 8844576) — domain + email,
**no web space**.
- **Email stays with Strato.** Only the website moves to self-hosted hardware.
- **The previous website is currently offline.** The content is therefore the
most time-critical open item (→ section 9).
---
## 2. Ziele und Nicht-Ziele
## 2. Goals and Non-Goals
**Ziele**
**Goals**
- Ö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.
- A public information site for the Elternbeirat: who, when, which Protokolle,
how to reach us.
- Operation on self-hosted infrastructure (Unraid), without a third-party hoster.
- Content that is versionable and needs no database — a `git clone` is the complete
backup.
- Low maintenance: no plugin updates, no PHP security holes, no CMS login as an
attack surface.
**Nicht-Ziele (bewusst)**
**Non-Goals (deliberate)**
- 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.
- No CMS with a web editor in stage 1. If other Elternbeirat members should later
edit content themselves, that is a separate expansion step (→ section 11).
- No user accounts, no login, no members' area.
- No database.
- No external integrations (fonts, analytics, maps, social widgets) — for
data-protection reasons, see section 10.
---
## 3. Architekturentscheidungen
## 3. Architecture Decisions
### AE-1: Blazor mit Static Server-Side Rendering, nicht WebAssembly
### AE-1: Blazor with Static Server-Side Rendering, not WebAssembly
**Entscheidung:** Blazor Web App mit Interaktivitätsmodus *None* (reines statisches
SSR). Zielframework .NET 10 (LTS).
**Decision:** Blazor Web App with interactivity mode *None* (pure static SSR).
Target framework .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.
**Rationale:** Blazor WASM loads several MB of runtime before the first visible
letter, serves search engines and link previews (WhatsApp, Signal, Messenger —
the main distribution channel among parents) an empty shell, and provides no value
whatsoever on a pure information site. Static SSR delivers finished HTML, needs no
JavaScript, and costs around 60 MB of RAM in the container.
**Konsequenz:** Interaktivität ist später pro Komponente nachrüstbar
(`@rendermode InteractiveServer` an genau der einen Komponente), ohne die
Architektur zu ändern.
**Consequence:** Interactivity can be added later per component
(`@rendermode InteractiveServer` on exactly that one component), without changing
the architecture.
**Verworfene Alternativen:**
**Rejected alternatives:**
| Alternative | Warum nicht |
| Alternative | Why not |
|---|---|
| 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 |
| Blazor WASM | Payload, SEO, link previews, no benefit |
| ASP.NET Core MVC/Razor Pages | Works just as well, but Razor Components are the more modern model |
| Statiq.Web (C# SSG) → nginx | Leanest from an ops standpoint (nothing to patch), but every text change forces a build run. Kept as a fallback. |
| Astro/Hugo | More mature SSG ecosystem, but unfamiliar territory |
### AE-2: Inhalte als Dateien im Repo, nicht in einer Datenbank
### AE-2: Content as files in the repo, not in a database
**Entscheidung:** Seiteninhalte als Markdown mit YAML-Frontmatter, Termine als
strukturiertes YAML, Dokumente (Protokolle, Satzung) als PDF unter `wwwroot`.
**Decision:** Page content as Markdown with YAML frontmatter, Termine as
structured YAML, documents (Protokolle, bylaws) as PDF under `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.
**Rationale:** No DB backup, no migration schema, no consistency problems between
files and database. Changes are commits and are therefore traceable and
reversible. For a site with ~10 subpages and a few Termine per year, anything else
is 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.
**Consequence:** Text changes require a commit and a redeploy. With an expected
five changes a year that is acceptable — and the reason why section 11 treats the
expansion to a web editor as a separate stage.
### AE-3: Inhalte werden ins Image gebacken, nicht als Volume gemountet
### AE-3: Content is baked into the image, not mounted as a volume
**Entscheidung:** `Content/` und `wwwroot/` sind Teil des Images.
**Decision:** `Content/` and `wwwroot/` are part of the image.
**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.
**Rationale:** Mounted content from the appdata share could be edited directly on
the NAS, but that is exactly when the repo and the live state drift apart — and
the "backup = `git clone`" property from AE-2 would be worthless.
**Konsequenz:** Kein Schnell-Fix am Live-System. Tippfehler werden korrekt über
einen Commit behoben.
**Consequence:** No quick fix on the live system. Typos are corrected properly via
a commit.
### AE-4: TLS und Zertifikate ausschließlich im Nginx Proxy Manager
### AE-4: TLS and certificates exclusively in the 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.
**Decision:** The container speaks only HTTP on port 8080 and has **no** port
mapping to the outside. NPM terminates TLS and is the only path to the container.
**Begründung:** Entspricht dem bereits etablierten Muster im Home-Lab
(`cloud.anticarnist.de`). Zertifikatsverwaltung bleibt an einer Stelle.
**Rationale:** Matches the pattern already established in the home lab
(`cloud.anticarnist.de`). Certificate management stays in one place.
**Konsequenz (wichtig):** In `Program.cs` **kein** `UseHttpsRedirection()` und
**kein** `UseHsts()` — sonst Redirect-Schleife hinter dem Proxy. HSTS setzt NPM.
**Consequence (important):** In `Program.cs`, **no** `UseHttpsRedirection()` and
**no** `UseHsts()` — otherwise a redirect loop behind the proxy. NPM sets HSTS.
---
## 4. Projekt anlegen (Rider)
## 4. Creating the Project (Rider)
Template **Blazor Web App** mit diesen Optionen:
Template **Blazor Web App** with these options:
| Option | Wert |
| Option | Value |
|---|---|
| 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** |
| Include sample pages | off |
| Configure for HTTPS | on (only relevant for local development) |
| Do not use top-level statements | doesn't matter |
| Enlist in .NET Aspire orchestration | **off** |
Projektname: `Elternbeirat.Web`, Solution: `Elternbeirat`.
Project name: `Elternbeirat.Web`, Solution: `Elternbeirat`.
Wichtig ist allein *Interactive render mode = None* — damit erzeugt das Template
kein `.Client`-Projekt und kein WebAssembly-Bundle.
The only thing that matters is *Interactive render mode = None* — this way the
template creates no `.Client` project and no 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.
**Created and confirmed (2026-09-20):** Blazor Web App, `net10.0`, Interactive
render mode `None`, Auth `None`, sample pages off, Docker options in the dialog off
(we write the Dockerfile ourselves, see section 7). Git repository created along
with it. The solution sits directly under `RiderProjects\Elternbeirat\`, the
project in `Elternbeirat.Web\` below it — **no** intermediate `src/` directory,
unlike originally sketched in section 5.
NuGet-Pakete, die dazukommen:
NuGet packages that get added:
- `Markdig` — Markdown-Rendering
- `YamlDotNet` — Frontmatter und `termine.yml`
- `Markdig` — Markdown rendering
- `YamlDotNet` — frontmatter and `termine.yml`
---
## 5. Repo-Struktur
## 5. Repo Structure
```
Elternbeirat/ ← Repo-Root, = Solution-Verzeichnis
Elternbeirat/ ← repo root, = solution directory
├── Elternbeirat.sln
├── plan.md ← dieses Dokument
├── CLAUDE.md ← minimal: Trigger, nicht-offensichtliche Kommandos
├── plan.md ← this document
├── CLAUDE.md ← minimal: triggers, non-obvious commands
├── 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
│ ├── deployment.md ← Unraid, NPM, registry, rollback
│ ├── dns.md ← Strato, DynDNS, mail records
│ ├── inhalte-pflegen.md ← guide for the not-everyday case
│ ├── inhalte-migration.md ← import from the old WordPress
│ └── recht.md ← imprint, privacy, photos
├── Elternbeirat.Web/
│ ├── Elternbeirat.Web.csproj
│ ├── Components/
@@ -164,7 +163,7 @@ Elternbeirat/ ← Repo-Root, = Solution-Verzeichnis
│ │ └── Pages/ Start, UeberUns, Termine, Protokolle,
│ │ News, NewsBeitrag, Kontakt,
│ │ Impressum, Datenschutz, Fehler404
│ ├── Content/ ← Inhalte, kein Code
│ ├── Content/ ← content, no code
│ │ ├── seiten/*.md
│ │ ├── news/2026-09-20-titel.md
│ │ └── termine.yml
@@ -173,25 +172,25 @@ Elternbeirat/ ← Repo-Root, = Solution-Verzeichnis
│ │ ├── TermineService.cs
│ │ └── IcsWriter.cs
│ ├── wwwroot/
│ │ ├── css/site.css ← eigenes CSS, keine CDN-Einbindung
│ │ ├── css/site.css ← own CSS, no CDN integration
│ │ ├── img/
│ │ └── dokumente/ ← Protokolle, Satzung (PDF)
│ │ └── dokumente/ ← Protokolle, bylaws (PDF)
│ └── Program.cs
├── Elternbeirat.Web.Tests/ ← Smoke-Tests: jede Route liefert 200
├── Elternbeirat.Web.Tests/ ← smoke tests: every route returns 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.
`CLAUDE.md` deliberately stays short (build/run commands, style rules, pointer to
`docs/`). The details live in `docs/` and are read only when needed.
---
## 6. Inhaltsmodell
## 6. Content Model
**Seite** (`Content/seiten/ueber-uns.md`):
**Page** (`Content/seiten/ueber-uns.md`):
```markdown
---
@@ -203,7 +202,7 @@ beschreibung: Wer im Elternbeirat der IGMH mitarbeitet und wie wir arbeiten.
## Der Elternbeirat
Fließtext …
Body text …
```
**Termin** (`Content/termine.yml`):
@@ -217,69 +216,69 @@ Fließtext …
notiz: Gäste willkommen
```
**News-Beitrag** (`Content/news/2026-09-20-neue-website.md`) — wie Seite, plus
`datum` und `autor`.
**News post** (`Content/news/2026-09-20-neue-website.md`) — like a page, plus
`datum` and `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.
The services read everything at startup, cache it in memory, and provide it in a
typed form. In Development there is also a `FileSystemWatcher`, so that text
changes become visible without a restart.
**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.
**Added benefit at no extra cost:** an ICS endpoint at `/termine.ics` that serves
the public Termine. Parents subscribe to the calendar once on their phone and see
every session automatically. This is the one point where the self-built solution
noticeably beats the old WordPress site.
---
## 7. Build und Deployment
## 7. Build and Deployment
### Stufe 1 — Handbetrieb (Start hier)
### Stage 1 — Manual (start here)
```bash
docker compose build
docker compose up -d
```
Für fünf Deployments im Jahr vollkommen ausreichend. CI vorab zu bauen wäre
Selbstzweck.
Entirely sufficient for five deployments a year. Building via CI in advance would
be an end in itself.
### Stufe 2 — Gitea Actions (Runner ist vorhanden)
### Stage 2 — Gitea Actions (runner is available)
`.gitea/workflows/deploy.yml` auf Push nach `main`:
`.gitea/workflows/deploy.yml` on push to `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 }}`
2. Login to Gitea's own container registry
3. `docker/build-push-action` → `gitea.<domain>/tom/elternbeirat:latest` **and** `:${{ gitea.sha }}`
Zwei Stolpersteine, die erfahrungsgemäß Zeit kosten:
Two pitfalls that experience shows cost time:
- 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.
- The `act_runner` in Docker mode needs access to a Docker socket or a DinD
service, otherwise `build-push-action` fails.
- The Gitea registry needs a package token with write permission (`write:package`),
not the normal login password.
**Immer auch den SHA-Tag pushen.** `:latest` allein macht Rollback unmöglich.
**Always push the SHA tag too.** `:latest` alone makes rollback impossible.
### Redeploy auf Unraid
### Redeploy on Unraid
Watchtower, aber **label-scoped** — sonst aktualisiert er ungefragt den ganzen
Home-Lab-Bestand:
Watchtower, but **label-scoped** — otherwise it updates the entire home-lab
inventory unasked:
```yaml
# im Watchtower-Container
# in the Watchtower container
WATCHTOWER_LABEL_ENABLE: "true"
```
```yaml
# im eb-web-Service
# in the eb-web service
labels:
com.centurylinklabs.watchtower.enable: "true"
```
Alternativ: manuell im Unraid-Docker-Tab „Update" drücken. Bei dieser
Änderungsfrequenz völlig legitim.
Alternatively: press "Update" manually in the Unraid Docker tab. At this rate of
change entirely legitimate.
### Dockerfile (Skizze)
### Dockerfile (sketch)
```dockerfile
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
@@ -296,14 +295,14 @@ 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.
The chiseled image runs as non-root (UID 1654) by default from .NET 8 on and
listens on port 8080. **It contains no shell** — a `HEALTHCHECK` with `curl` does
not work there. Either use the normal `aspnet:10.0-noble` or leave monitoring to
NPM or Uptime Kuma.
---
## 8. Hosting auf Unraid
## 8. Hosting on Unraid
```yaml
# compose.yaml
@@ -324,22 +323,22 @@ networks:
external: true
```
Kein `ports:`-Block. Der Container ist ausschließlich über das NPM-Docker-Netz
erreichbar.
No `ports:` block. The container is reachable exclusively via the NPM Docker
network.
**NPM Proxy Host:**
| Feld | Wert |
| Field | Value |
|---|---|
| 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) |
| Block Common Exploits | on |
| Websockets Support | off (not needed with static SSR) |
| SSL | Let's Encrypt, Force SSL, HTTP/2, HSTS |
**In `Program.cs` nicht vergessen:**
**Don't forget in `Program.cs`:**
```csharp
app.UseForwardedHeaders(new ForwardedHeadersOptions
@@ -351,125 +350,124 @@ app.UseForwardedHeaders(new ForwardedHeadersOptions
});
```
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.
Without this, the app sees every request as HTTP and with the proxy IP instead of
the client IP — relevant for correct absolute URLs and for the logs.
---
## 9. Inhalte — zeitkritisch
## 9. Content — time-critical
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.
The old site is offline, but Maik's Strato contract is still running. As long as it
runs, the web space is reachable; after cancellation the content is gone for good.
**Reihenfolge der Rettungsversuche:**
**Order of rescue attempts:**
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.
1. Check what was actually secured at the meeting with Maik (files? MySQL dump?
both?). A complete dump would be the ideal case — from it, texts, page
structure, media, and PDFs can be extracted cleanly.
2. If only files are available: `wp-content/uploads` contains images and PDFs, but
the texts live in the database. Then step 3.
3. Check the Wayback Machine for snapshots of `elternbeirat-igmh.de`.
4. If none of that works: ask Maik to pull an export before the contract ends — or
rebuild the content from the Protokolle and the school's website.
**Diese Frage blockiert den Inhaltsteil, nicht den Technikteil.** Das Gerüst lässt
sich mit Platzhaltern bauen und später befüllen.
**This question blocks the content part, not the technical part.** The scaffolding
can be built with placeholders and filled in later.
---
## 10. Rechtliches
## 10. Legal
Kein Rechtsrat — aber die Punkte, an denen Schulseiten regelmäßig auffallen:
Not legal advice — but the points where school sites regularly get flagged:
- **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.
- **Imprint (§ 5 DDG):** If the Elternbeirat has no legal form of its own, the
operator is listed personally in the imprint with name and a valid postal
address for service. This is a decision, not a formality — the private address
becomes public. Alternative: the school's address, but only with its explicit
consent and if the school is a co-operator.
- **Privacy policy:** With the move, Tom becomes the controller in the sense of the
GDPR. Name server logs with IP addresses, define the legal basis and the deletion
period.
- **No external resources.** Google Fonts, Maps, YouTube embeds, and CDN scripts
transmit visitors' IPs to third parties. Fonts are served by ourselves.
- **Photos of children:** only with the consent of the legal guardians — by far the
most common mistake on school sites. When in doubt, no photos of people.
- Hosting on a private connection means: the public IP of the private connection
appears in the DNS of a school site. A deliberate decision, not a side effect.
---
## 11. Offene Punkte
## 11. Open Items
| # | Punkt | Status |
| # | Item | 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 |
| 1 | What was secured from the old WordPress? | **open, time-critical** |
| 2 | Can "STRATO Mail Plus" do DynDNS? Per the Strato FAQ from "PowerWeb Basic 2013 or STRATO Domain" on — whether Mail Plus counts is unclear. Ask support along with the transfer while it is in progress. | open |
| 3 | DynDNS updater: Fritzbox (which, as an exposed-host upstream device, knows the public IP) or a ddclient container on Unraid? | open |
| 4 | Should other Elternbeirat members be able to maintain content themselves? If yes: a separate expansion step (Decap CMS on a Git basis or a small admin UI). | open |
| 5 | Contact form wanted? Would require server interactivity and spam protection — `mailto:` is the effort-free alternative. | open |
| 6 | Who steps in when Tom is unavailable? A site that only one person can deploy is a dependency the board should be aware of. | open |
| 7 | Submit forms to Strato (signed, ready to go) | open |
---
## 12. Umsetzungsreihenfolge
## 12. Implementation Order
| # | Schritt | Abhängig von |
| # | Step | Depends on |
|---|---|---|
| 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 |
| 1 | Submit Strato forms, start the domain transfer | ✅ commissioned (2026-09-20) |
| 2 | Clarify the content situation (section 9) | — |
| 3 | Create the Blazor project in Rider ✅ (2026-09-20) → push to Gitea ✅ | — |
| 6 | Dockerfile, compose.yaml → as a container on Unraid ✅ (2026-09-20) | 3 |
| 4 | Content pipeline: Markdig, YamlDotNet, services, ICS endpoint | 3 |
| 5 | Layout, navigation, pages with placeholders | 4 |
| 7 | Add the real content | 2, 5 |
| 8 | Imprint and privacy policy | 7 |
| 9 | Switch DNS, 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.
Steps 1 and 2 run independently of the code and should start immediately —
step 2 is the only one where waiting does real damage.
**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`.
**Deviation from the original order:** Step 6 (deployment) was deliberately pulled
**ahead of** the content pipeline (4/5). Reason: prove the riskiest chain —
build image → Gitea registry → pull on Unraid → container runs — early, rather
than discovering it just before go-live. Details in `docs/deployment.md`.
### Stand 2026-09-20 (abends)
### As of 2026-09-20 (evening)
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.
Achieved: The raw "Hello world" of the Blazor app runs as a container on Unraid
(`Cube`), reachable on the LAN at `http://cube:5000`. This proves the complete
deploy chain including the private Gitea registry.
Bewusste **Test-Abweichungen** vom Produktivziel (Abschnitt 8), später
zurückzubauen:
Deliberate **test deviations** from the production target (section 8), to be
rolled back later:
- `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.
- `compose.yaml` has a port mapping `5000:8080`. In production: no mapping, only
via the external `npm` network (NPM is not testable yet, the domain moves first).
- Image tag only `:latest`, no SHA tag yet (→ step 10, rollback).
- The registry token sits on Unraid in plain text (`/root/.docker/config.json`).
A credential helper is noted as a later item in `docs/deployment.md`.
**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).
**Next step:** Content pipeline (step 4) — Markdig + YamlDotNet, services for
pages/Termine, ICS endpoint. Open in parallel and independent of the code:
clarify the content situation (step 2, time-critical).
### Stand 2026-09-21 (vormittags)
### As of 2026-09-21 (morning)
Deployment weiter ausgebaut und einmal komplett durchgespielt:
Deployment further built out and run through completely once:
- **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).
- **Branch/PR workflow** established: never directly on `main`; feature branch →
pull request in Gitea → merge. Carried out for the first time (PR #1).
- **Two build scripts** in `scripts/`: `dev-build.sh` (local dev image, no push —
dev states stay out of the registry) and `release.sh` (builds from `main`, tags
`:latest` **and** the short commit SHA, pushes both; aborts if not on `main` or
the working directory is dirty).
- **SHA tagging** is now standard → rollback possible. First release tag:
`:8643f2c`. Redeploy on Unraid (Compose Down/Up) deliberately practiced, works.
- `.gitattributes` enforces LF for `*.sh` (otherwise the shebang fails on 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.
Open for next time (unchanged): content pipeline (step 4) and the time-critical
content situation (step 2). Deployment automation via Gitea Actions (step 10) is
the next optional deployment expansion, but not urgent — manual operation via
`release.sh` is enough.