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.dewas 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 cloneis 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 renderingYamlDotNet— frontmatter andtermine.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:
actions/checkout- Login to Gitea's own container registry
docker/build-push-action→gitea.<domain>/tom/elternbeirat:latestand:${{ gitea.sha }}
Two pitfalls that experience shows cost time:
- The
act_runnerin Docker mode needs access to a Docker socket or a DinD service, otherwisebuild-push-actionfails. - 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:
- 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.
- If only files are available:
wp-content/uploadscontains images and PDFs, but the texts live in the database. Then step 3. - Check the Wayback Machine for snapshots of
elternbeirat-igmh.de. - 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.yamlhas a port mapping5000:8080. In production: no mapping, only via the externalnpmnetwork (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 indocs/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) andrelease.sh(builds frommain, tags:latestand the short commit SHA, pushes both; aborts if not onmainor 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. .gitattributesenforces 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.