Merge pull request 'Dissolve plan.md into docs/, move open work to Gitea issues' (#27) from docs/dissolve-plan-md into main
Build and push image / build (push) Successful in 1m41s
Build and push image / build (push) Successful in 1m41s
Reviewed-on: #27
This commit was merged in pull request #27.
This commit is contained in:
commit
56922707ea
9 files changed
+142
-508
No files matched your search
@@ -0,0 +1,11 @@
|
||||
# Template for the local .env file (which is gitignored and holds real secrets).
|
||||
# Copy this to .env and fill in the real values. Never commit .env.
|
||||
|
||||
# Gitea access token with scope "issue: Read and Write", used to read and create
|
||||
# issues on the Gitea instance. Create it at:
|
||||
# Gitea -> Settings -> Applications -> Access Tokens (name "issue-tracking")
|
||||
GITEA_TOKEN=
|
||||
|
||||
# Base URL of the Gitea instance and the repo path issues belong to.
|
||||
GITEA_URL=https://gitea.anticarnist.de
|
||||
GITEA_REPO=Tom/Elternbeirat
|
||||
+4
-1
@@ -7,4 +7,7 @@ riderModule.iml
|
||||
|
||||
# Altbestand der WordPress-Seite (Sichtung/Migration, kann DB-Dumps mit
|
||||
# personenbezogenen Daten und grosse Binaerdateien enthalten) -- nie ins Repo.
|
||||
/backup/
|
||||
/backup/
|
||||
|
||||
# Secrets (Gitea-Token o.ae.) -- niemals ins Repo. Vorlage: .env.example
|
||||
.env
|
||||
@@ -4,23 +4,28 @@ Website des Elternbeirats der IGMH. Blazor Web App mit statischem
|
||||
Server-Side-Rendering, .NET 10. Läuft als Container auf Unraid hinter dem Nginx
|
||||
Proxy Manager.
|
||||
|
||||
Architekturentscheidungen und deren Begründung: `plan.md`. Dort nachlesen, bevor
|
||||
eine davon in Frage gestellt wird.
|
||||
Architekturentscheidungen und deren Begründung: `docs/architektur.md`. Dort
|
||||
nachlesen, bevor eine davon in Frage gestellt wird.
|
||||
|
||||
## Wann welches Dokument
|
||||
|
||||
| Thema | Datei |
|
||||
|---|---|
|
||||
| Warum SSR statt WASM, warum keine DB, offene Punkte, Meilensteine | `plan.md` |
|
||||
| Warum SSR statt WASM, warum keine DB (AE-1 bis AE-4) | `docs/architektur.md` |
|
||||
| Unraid, compose, NPM-Proxy-Host, Registry, Rollback | `docs/deployment.md` |
|
||||
| Strato, DynDNS, Mail-Records | `docs/dns.md` |
|
||||
| Inhalte anlegen und ändern | `docs/inhalte-pflegen.md` |
|
||||
| Impressum, Datenschutz, Fotos | `docs/recht.md` |
|
||||
| Offene Arbeit, Meilensteine, offene Punkte | Gitea-Issues (Milestone „Elternbeirat-Website") |
|
||||
|
||||
Offene Aufgaben liegen als **Gitea-Issues**, nicht als Markdown im Repo. So löst
|
||||
das Anlegen oder Ändern eines Tickets keinen Build aus. Die vier Arbeitsstränge
|
||||
(Redaktionssystem, Deployment, Inhalte, Infrastruktur) sind Parent-Issues mit
|
||||
Sub-Issues als Checkliste im Body, alle am Milestone „Elternbeirat-Website".
|
||||
|
||||
## Kommandos
|
||||
|
||||
```bash
|
||||
dotnet run --project Elternbeirat.Web # lokal, Content-Reload per FileSystemWatcher
|
||||
dotnet run --project Elternbeirat.Web # lokal; Inhalte werden beim Start gelesen (kein Auto-Reload)
|
||||
dotnet test # Smoke-Tests: jede Route liefert 200
|
||||
docker compose build && docker compose up -d
|
||||
```
|
||||
@@ -31,7 +36,8 @@ vollständige Stand.
|
||||
## Nicht offensichtlich
|
||||
|
||||
- **Inhalte liegen im Image**, nicht in einem Volume. Jede Textänderung braucht
|
||||
Commit und Rebuild. Das ist Absicht (`plan.md`, AE-3) — nicht „vereinfachen".
|
||||
Commit und Rebuild. Das ist Absicht (`docs/architektur.md`, AE-3) — nicht
|
||||
„vereinfachen".
|
||||
- **Kein `UseHttpsRedirection()`, kein `UseHsts()`.** NPM terminiert TLS; beides
|
||||
erzeugt hinter dem Proxy eine Redirect-Schleife. `UseForwardedHeaders` mit
|
||||
geleerten `KnownNetworks`/`KnownProxies` ist korrekt so, weil der Container kein
|
||||
|
||||
@@ -5,7 +5,7 @@ title: Datenschutz
|
||||
# Datenschutzerklärung
|
||||
|
||||
*TODO: Rechtlich verbindliche Datenschutzerklärung ergänzen. Erst nach Freigabe
|
||||
finalisieren – siehe `docs/recht.md` (noch anzulegen).*
|
||||
finalisieren – siehe `docs/recht.md`.*
|
||||
|
||||
Diese Website wird bewusst ohne externe Ressourcen betrieben: keine CDN-Skripte,
|
||||
keine Google Fonts, keine Karten- oder Video-Einbettungen. Schriften werden von
|
||||
|
||||
@@ -5,7 +5,7 @@ title: Impressum
|
||||
# Impressum
|
||||
|
||||
*TODO: Rechtlich verbindliches Impressum ergänzen. Erst nach Freigabe mit echten
|
||||
Daten füllen – siehe `docs/recht.md` (noch anzulegen).*
|
||||
Daten füllen – siehe `docs/recht.md`.*
|
||||
|
||||
## Angaben gemäß § 5 DDG
|
||||
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
# Architecture decisions
|
||||
|
||||
Why the site is built the way it is. Read this before questioning one of these
|
||||
decisions — each records the reasoning, not just the choice. Operational how-to
|
||||
lives in `docs/deployment.md`; open work lives in `tasks/`.
|
||||
|
||||
> **Note on scope.** These decisions describe stage 1: a static information site
|
||||
> with content as files. The move to editor-based content maintenance
|
||||
> (`tasks/001-redaktion-ohne-entwickler.md`) deliberately revisits AE-2 and AE-3 —
|
||||
> when that lands, update this file.
|
||||
|
||||
## 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 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. Never global.
|
||||
|
||||
## AE-2: Content as files in the repo, not in a database
|
||||
|
||||
**Decision:** Page content as Markdown with YAML frontmatter, events as structured
|
||||
YAML (`Content/events.yml`), documents 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 events 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 the editor expansion
|
||||
(`tasks/001`) is treated 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. Note: the content is read once at startup and cached — there is **no**
|
||||
`FileSystemWatcher` (a change needs a restart / `dotnet watch`).
|
||||
|
||||
## 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.
|
||||
`UseForwardedHeaders` with emptied `KnownNetworks`/`KnownProxies` is correct because
|
||||
the container has no port mapping and is reachable only through NPM.
|
||||
|
||||
## Rejected alternatives (for AE-1)
|
||||
|
||||
| Alternative | Why not |
|
||||
|---|---|
|
||||
| Blazor WASM | Payload, SEO, link previews, no benefit |
|
||||
| ASP.NET Core MVC/Razor Pages | Works too, but Razor Components are the more modern model |
|
||||
| Statiq.Web (C# SSG) → nginx | Leanest to operate, but every text change forces a build. Kept as a fallback. |
|
||||
| Astro/Hugo | More mature SSG ecosystem, but unfamiliar territory |
|
||||
+5
-26
@@ -187,33 +187,12 @@ Requires two repository secrets (Gitea → repo → **Settings** → **Actions**
|
||||
- `REGISTRY_TOKEN` — a Gitea access token with **`package: write`**, *not* the
|
||||
login password.
|
||||
|
||||
The workflow currently only builds and pushes — it does **not** run the tests
|
||||
yet, and it does **not** deploy. Those open steps (tests in the workflow,
|
||||
auto-deploy with a health gate via Watchtower, encrypting the registry token on
|
||||
Unraid) are tracked in `tasks/002-deployment-automatisieren.md`.
|
||||
|
||||
Two known pitfalls: the `act_runner` needs Docker socket access to build, and it
|
||||
must offer the `ubuntu-latest` label the workflow asks for. To check the runner:
|
||||
Gitea → repo (or site admin) → **Settings** → **Actions** → **Runners** — it
|
||||
should be listed as **online** with a label set that includes `ubuntu-latest`.
|
||||
|
||||
### Auto-deploy via Watchtower (open — needs a health gate first)
|
||||
|
||||
Automatically rolling out `:latest` the moment it lands in the registry is
|
||||
tempting, but **not** something to switch on blindly: a build can be green and
|
||||
still serve a broken page (a bad content file, a runtime-only culture crash like
|
||||
the de-DE one). Auto-deploy without a gate would push that live **unnoticed**.
|
||||
|
||||
So before turning this on, decide the gate:
|
||||
|
||||
- The container must prove itself **healthy** before it replaces the running one
|
||||
— but the chiseled image has no shell, so a `HEALTHCHECK` with `curl`/`sh` does
|
||||
not work inside it. The check has to come from outside (e.g. an external probe
|
||||
hitting a known route, or a compose-level check from a sidecar).
|
||||
- Watchtower must be **label-scoped** to *only* the `eb-web` container, otherwise
|
||||
it updates the entire home-lab inventory.
|
||||
- Keep a fast **rollback**: pin `:<sha>` in the Unraid compose and bring it back
|
||||
up (see "Rollback" above).
|
||||
|
||||
Until that gate exists, deployment stays manual on purpose.
|
||||
|
||||
### Encrypt the registry token on Unraid (open)
|
||||
|
||||
Currently the token sits in plain text in `/root/.docker/config.json`. Set up a
|
||||
credential helper later, so that the plain-text warning from `docker login`
|
||||
disappears.
|
||||
@@ -0,0 +1,36 @@
|
||||
# Legal — imprint, privacy, photos
|
||||
|
||||
Not legal advice — but the points where school sites regularly get flagged. The
|
||||
actual imprint and privacy texts live in `Content/pages/impressum.md` and
|
||||
`Content/pages/datenschutz.md`; filling them in with released, real data is tracked
|
||||
in `tasks/003-echte-inhalte-vor-go-live.md`.
|
||||
|
||||
## 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 the 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. This is the reason behind the "no external
|
||||
resources" rule in `CLAUDE.md` — data protection, not taste.
|
||||
|
||||
## 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. Note: the old WordPress backup
|
||||
contained no own images anyway (only URLs in the database), so any photo used is a
|
||||
fresh, consciously released one.
|
||||
|
||||
## Hosting on a private connection
|
||||
|
||||
The public IP of the private connection appears in the DNS of a school site. A
|
||||
deliberate decision, not a side effect.
|
||||
@@ -1,473 +0,0 @@
|
||||
# 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.
|
||||
Reference in new issue
Block a user