# 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./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./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.