474 lines
18 KiB
Markdown
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.
|