Files
Elternbeirat/plan.md
T

474 lines
18 KiB
Markdown

# elternbeirat-igmh.de — Rebuild
Replacing the previous WordPress site with a custom .NET application on
self-hosted infrastructure.
As of: 2026-09-20 · Responsible: Tom (Thomas Leininger)
---
## 1. Starting Situation
- 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. Goals and Non-Goals
**Goals**
- 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.
**Non-Goals (deliberate)**
- 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. Architecture Decisions
### AE-1: Blazor with Static Server-Side Rendering, not WebAssembly
**Decision:** Blazor Web App with interactivity mode *None* (pure static SSR).
Target framework .NET 10 (LTS).
**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.
**Consequence:** Interactivity can be added later per component
(`@rendermode InteractiveServer` on exactly that one component), without changing
the architecture.
**Rejected alternatives:**
| Alternative | Why not |
|---|---|
| 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: Content as files in the repo, not in a database
**Decision:** Page content as Markdown with YAML frontmatter, Termine as
structured YAML, documents (Protokolle, bylaws) as PDF under `wwwroot`.
**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.
**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: Content is baked into the image, not mounted as a volume
**Decision:** `Content/` and `wwwroot/` are part of the image.
**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.
**Consequence:** No quick fix on the live system. Typos are corrected properly via
a commit.
### AE-4: TLS and certificates exclusively in the Nginx Proxy Manager
**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.
**Rationale:** Matches the pattern already established in the home lab
(`cloud.anticarnist.de`). Certificate management stays in one place.
**Consequence (important):** In `Program.cs`, **no** `UseHttpsRedirection()` and
**no** `UseHsts()` — otherwise a redirect loop behind the proxy. NPM sets HSTS.
---
## 4. Creating the Project (Rider)
Template **Blazor Web App** with these options:
| Option | Value |
|---|---|
| Framework | .NET 10.0 |
| Authentication | None |
| Interactive render mode | **None** |
| 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** |
Project name: `Elternbeirat.Web`, Solution: `Elternbeirat`.
The only thing that matters is *Interactive render mode = None* — this way the
template creates no `.Client` project and no WebAssembly bundle.
**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 packages that get added:
- `Markdig` — Markdown rendering
- `YamlDotNet` — frontmatter and `termine.yml`
---
## 5. Repo Structure
```
Elternbeirat/ ← repo root, = solution directory
├── Elternbeirat.sln
├── 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 ← 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/
│ │ ├── App.razor
│ │ ├── Routes.razor
│ │ ├── Layout/ MainLayout, NavMenu, Footer
│ │ └── Pages/ Start, UeberUns, Termine, Protokolle,
│ │ News, NewsBeitrag, Kontakt,
│ │ Impressum, Datenschutz, Fehler404
│ ├── Content/ ← content, no code
│ │ ├── seiten/*.md
│ │ ├── news/2026-09-20-titel.md
│ │ └── termine.yml
│ ├── Services/
│ │ ├── MarkdownContentService.cs
│ │ ├── TermineService.cs
│ │ └── IcsWriter.cs
│ ├── wwwroot/
│ │ ├── css/site.css ← own CSS, no CDN integration
│ │ ├── img/
│ │ └── dokumente/ ← Protokolle, bylaws (PDF)
│ └── Program.cs
├── Elternbeirat.Web.Tests/ ← smoke tests: every route returns 200
├── Dockerfile
├── compose.yaml
├── .dockerignore
└── .gitea/workflows/deploy.yml
```
`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. Content Model
**Page** (`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
Body 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 post** (`Content/news/2026-09-20-neue-website.md`) — like a page, plus
`datum` and `autor`.
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.
**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 and Deployment
### Stage 1 — Manual (start here)
```bash
docker compose build
docker compose up -d
```
Entirely sufficient for five deployments a year. Building via CI in advance would
be an end in itself.
### Stage 2 — Gitea Actions (runner is available)
`.gitea/workflows/deploy.yml` on push to `main`:
1. `actions/checkout`
2. Login to Gitea's own container registry
3. `docker/build-push-action` → `gitea.<domain>/tom/elternbeirat:latest` **and** `:${{ gitea.sha }}`
Two pitfalls that experience shows cost time:
- 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.
**Always push the SHA tag too.** `:latest` alone makes rollback impossible.
### Redeploy on Unraid
Watchtower, but **label-scoped** — otherwise it updates the entire home-lab
inventory unasked:
```yaml
# in the Watchtower container
WATCHTOWER_LABEL_ENABLE: "true"
```
```yaml
# in the eb-web service
labels:
com.centurylinklabs.watchtower.enable: "true"
```
Alternatively: press "Update" manually in the Unraid Docker tab. At this rate of
change entirely legitimate.
### Dockerfile (sketch)
```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"]
```
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 on Unraid
```yaml
# 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
```
No `ports:` block. The container is reachable exclusively via the NPM Docker
network.
**NPM Proxy Host:**
| Field | Value |
|---|---|
| Domain Names | `elternbeirat-igmh.de`, `www.elternbeirat-igmh.de` |
| Scheme | `http` |
| Forward Hostname | `eb-web` |
| Forward Port | `8080` |
| Block Common Exploits | on |
| Websockets Support | off (not needed with static SSR) |
| SSL | Let's Encrypt, Force SSL, HTTP/2, HSTS |
**Don't forget in `Program.cs`:**
```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 = { }
});
```
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. Content — time-critical
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.
**Order of rescue attempts:**
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.
**This question blocks the content part, not the technical part.** The scaffolding
can be built with placeholders and filled in later.
---
## 10. Legal
Not legal advice — but the points where school sites regularly get flagged:
- **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. Open Items
| # | Item | Status |
|---|---|---|
| 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. Implementation Order
| # | Step | Depends on |
|---|---|---|
| 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 |
Steps 1 and 2 run independently of the code and should start immediately —
step 2 is the only one where waiting does real damage.
**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`.
### As of 2026-09-20 (evening)
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.
Deliberate **test deviations** from the production target (section 8), to be
rolled back later:
- `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`.
**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).
### As of 2026-09-21 (morning)
Deployment further built out and run through completely once:
- **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).
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.