Files
Elternbeirat/plan.md
T

18 KiB

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):

---
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):

- 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)

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:

# in the Watchtower container
WATCHTOWER_LABEL_ENABLE: "true"
# 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)

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

# 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:

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.


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.