Compare commits

..
2 Commits
Author SHA1 Message Date
Tom 56922707ea 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
Reviewed-on: #27
2026-09-22 13:39:54 +02:00
tleininger f7fad64bcb Dissolve plan.md into docs/, move open work to Gitea issues 2026-09-22 13:39:27 +02:00
9 changed files with 141 additions and 507 deletions

No files matched your search

+11
View File
@@ -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
+3
View File
@@ -8,3 +8,6 @@ riderModule.iml
# Altbestand der WordPress-Seite (Sichtung/Migration, kann DB-Dumps mit
# personenbezogenen Daten und grosse Binaerdateien enthalten) -- nie ins Repo.
/backup/
# Secrets (Gitea-Token o.ae.) -- niemals ins Repo. Vorlage: .env.example
.env
+12 -6
View File
@@ -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
+1 -1
View File
@@ -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
+72
View File
@@ -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
View File
@@ -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.
+36
View File
@@ -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.
-473
View File
@@ -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.