Fixes deployment docs to reverse-proxy on a separate Pi, not pi-main

nginx and certbot were designed assuming they lived on the same host
as the app. They don't - a dedicated proxy Pi terminates TLS and
forwards plain HTTP over the LAN. Kestrel now binds 0.0.0.0 instead
of localhost, the whole certbot procedure moved to the proxy Pi's
side, and pi-main gets a firewall rule restricting the backend ports
to just the proxy Pi's address - otherwise binding all interfaces
would let anything on the LAN skip the proxy's TLS entirely.
This commit is contained in:
2026-07-29 13:29:12 +02:00
parent d1b0d06568
commit 50c1a5106d
4 changed files with 85 additions and 18 deletions
@@ -123,6 +123,27 @@ and the renamed Identity tables); both `SlpModularCms.Api` and `SlpModularCms.Ap
cleanly against it (`/health` → 200, `MigrateCoreDatabase()` logs "already up to date" on the cleanly against it (`/health` → 200, `MigrateCoreDatabase()` logs "already up to date" on the
second run); full backend suite re-confirmed at 372/372 passed, 0 build errors. second run); full backend suite re-confirmed at 372/372 passed, 0 build errors.
### Reverse Proxy Topology Correction (2026-07-29)
Infrastructure Design's Q10 (`infrastructure-design.md` § 1) established that an nginx reverse
proxy exists — right about *that*, wrong about *where*: it runs on a **separate, dedicated Pi**
("the proxy Pi"), not on pi-main (where this deployment's release directories and systemd units
live). The proxy Pi terminates TLS and forwards plain HTTP to pi-main over the LAN.
**Corrected**:
- `ASPNETCORE_URLS` binds `0.0.0.0`, not `localhost` (`deployment-instructions.md` § 1.5) — the
proxy Pi must reach Kestrel over the network, not loopback
- pi-main runs **no nginx and holds no certificates** for these domains at all — the entire
nginx/certbot procedure in `deployment-instructions.md` § 1.7 happens on the proxy Pi instead
- Added a **firewall requirement on pi-main** (§ 1.7.1 step 4): since Kestrel now listens on all
interfaces, the backend ports must be restricted to just the proxy Pi's address, or the TLS
termination the whole design depends on is trivially bypassable by anything else on the LAN
hitting pi-main directly
Also corrected in `infrastructure-design.md` § 1 (erratum note, history preserved rather than
rewritten). No code changes — this is host topology and Operations documentation only, the
application itself has no opinion on where TLS terminates.
## Scope Decisions (from feature-selection.md) ## Scope Decisions (from feature-selection.md)
- **Public website**: documentation/instructions only — where the website build lands in `wwwroot/`, how it coexists with `wwwroot/admin/`, and what a per-website workspace must deliver. The website's own build/deploy workflow stays out of scope (Q4 = A). - **Public website**: documentation/instructions only — where the website build lands in `wwwroot/`, how it coexists with `wwwroot/admin/`, and what a per-website workspace must deliver. The website's own build/deploy workflow stays out of scope (Q4 = A).
- **Environments**: local, test, production only. - **Environments**: local, test, production only.
@@ -1388,3 +1388,14 @@ by Infrastructure Design. U5 carries REF-U3-01's Umami origin gate plus FR-21 an
**Context**: OPERATIONS — Deployment Setup, nginx documentation generalized for reuse **Context**: OPERATIONS — Deployment Setup, nginx documentation generalized for reuse
--- ---
## Deployment Setup — Reverse Proxy Runs on a Separate Pi
**Timestamp**: 2026-07-29T00:40:00Z
**User Input**: "OKee wacht. Ik heb een andere pi waar de reverse-proxy op staat voor mijn set-up. Die reverse proxy regelt de ssl al en stuurt daarna door. dus de pi-main zou geen ssl verbinding mee rhebben en zal al op poort 80 luisteren"
**AI Response**: "Significant topology correction: the nginx reverse proxy runs on a separate, dedicated Pi, not on pi-main. Updated ASPNETCORE_URLS to bind 0.0.0.0 instead of localhost (deployment-instructions.md § 1.5), rewrote § 1.7 entirely: pi-main runs no nginx/certificates at all, the from-scratch nginx+certbot procedure now runs on the proxy Pi with proxy_pass targeting pi-main's LAN address, and added a firewall step (§ 1.7.1 step 4) restricting pi-main's backend ports to only the proxy Pi's address, since binding 0.0.0.0 without that would let anything on the LAN bypass the proxy's TLS entirely. Added a corresponding erratum to infrastructure-design.md § 1 (Q10 was right that nginx exists, wrong about where) and a full record in aidlc-state.md. No application code changes — pure host-topology/Operations documentation."
**Context**: OPERATIONS — Deployment Setup, reverse-proxy topology corrected across Operations and Construction docs
---
@@ -17,6 +17,15 @@ That port assignment is a one-time host-setup fact (documented in Operations, FR
workflow never reads, writes or reasons about a port. Its only environment-specific inputs are the workflow never reads, writes or reasons about a port. Its only environment-specific inputs are the
deploy path and which systemd unit to restart. deploy path and which systemd unit to restart.
> **Corrected at Deployment Setup, 2026-07-29**: Q10's answer was right that an nginx reverse
> proxy exists, but wrong about *where* — it runs on a **separate, dedicated Pi**, not on this one.
> Kestrel therefore binds `0.0.0.0`, not `localhost` (`deployment-instructions.md` § 1.5), and this
> Pi's firewall must restrict those ports to just the proxy Pi's address
> (`deployment-instructions.md` § 1.7.1 step 4) — otherwise the proxy's TLS termination is
> bypassable by anything else on the LAN. The "fixed, pre-configured local port per environment"
> statement above still holds; only "local" turned out to mean "local to this Pi's network
> interface," not "loopback."
**Publish target**: `linux-arm64`, **framework-dependent** (Q2 = A, Q5 = B) — assumes the .NET 10 **Publish target**: `linux-arm64`, **framework-dependent** (Q2 = A, Q5 = B) — assumes the .NET 10
runtime is already installed on the Pi (ASM-03). Smaller artifact, faster upload over the same SSH runtime is already installed on the Pi (ASM-03). Smaller artifact, faster upload over the same SSH
transport every other unit already relies on. transport every other unit already relies on.
@@ -136,7 +136,11 @@ Contents (fill in real values — this file is never read by the workflow, only
below): below):
```ini ```ini
ASPNETCORE_ENVIRONMENT=Production ASPNETCORE_ENVIRONMENT=Production
ASPNETCORE_URLS=http://localhost:<port> # 0.0.0.0, not localhost: the reverse proxy handling TLS for this domain runs on a SEPARATE Pi
# (§ 1.7), so it must reach Kestrel over the LAN, not loopback. See § 1.7's firewall note — binding
# to all interfaces means the port must be restricted to the proxy Pi's address, not left open to
# the whole LAN.
ASPNETCORE_URLS=http://0.0.0.0:<port>
ConnectionStrings__DefaultConnection=Server=127.0.0.1;Port=3306;Database=SlpSoftware<Env>;Uid=<user>;Pwd=<password> ConnectionStrings__DefaultConnection=Server=127.0.0.1;Port=3306;Database=SlpSoftware<Env>;Uid=<user>;Pwd=<password>
JwtSettings__Secret=<secure-long-random-secret> JwtSettings__Secret=<secure-long-random-secret>
JwtSettings__Issuer=SlpModularCms JwtSettings__Issuer=SlpModularCms
@@ -211,18 +215,28 @@ systemctl --user enable slpsoftware-test.service
systemctl --user enable slpsoftware-production.service systemctl --user enable slpsoftware-production.service
``` ```
### 1.7 nginx Routing ### 1.7 TLS-Terminating Reverse Proxy — On a Separate Pi, Not This One
The existing reverse proxy (`infrastructure-design.md` § 1, § 4) needs a server block per domain, **Revised from the original design**: the reverse proxy that terminates TLS for these domains runs
routing to the matching local port. Two subsections: § 1.7.1 is the repeatable, from-scratch on a **different, dedicated Pi** ("the proxy Pi"), not on this one ("pi-main", where
procedure for adding *any* new site or API to this Pi (this deployment's domains included, the `gitea-workflow` and the release directories from §§ 1.21.4 live). The proxy Pi already handles
first time); § 1.7.2 is simply what that procedure produced for `slpsoftware.nl` — read it as the SSL and forwards plain HTTP to pi-main. Two consequences that change earlier sections:
worked example, not a separate step.
#### 1.7.1 Adding a New Domain From Scratch - **Kestrel must be reachable over the LAN, not just loopback** — hence `ASPNETCORE_URLS=http://0.0.0.0:<port>`
in § 1.5, not `http://localhost:<port>`
- **pi-main runs no nginx and holds no certificates for these domains at all** — everything in this
section happens **on the proxy Pi**, except the firewall step at the end, which is on pi-main
As in § 1.7.11.7.2 below: § 1.7.1 is the repeatable, from-scratch procedure for routing *any* new
domain through the proxy Pi to *any* backend (this deployment's domains included, the first time);
§ 1.7.2 is what that procedure produced for `slpsoftware.nl` — the worked example, not a separate
step.
#### 1.7.1 Adding a New Domain From Scratch (on the proxy Pi)
Starting from nothing — no existing server block, no certificate — for a new domain Starting from nothing — no existing server block, no certificate — for a new domain
`<new-domain>` routing to a local port `<local-port>`: `<new-domain>` that should route to `<backend-host>:<backend-port>` (a service on some other Pi
or container on the LAN — pi-main and its two ports, in this deployment's case):
**Step 1 — plain HTTP block, no TLS yet.** Certbot's nginx plugin (step 2) needs a working HTTP **Step 1 — plain HTTP block, no TLS yet.** Certbot's nginx plugin (step 2) needs a working HTTP
server block for the domain to attach to and to answer the HTTP-01 validation challenge; asking server block for the domain to attach to and to answer the HTTP-01 validation challenge; asking
@@ -233,7 +247,7 @@ server {
listen 80; listen 80;
server_name <new-domain>; server_name <new-domain>;
location / { location / {
proxy_pass http://127.0.0.1:<local-port>; proxy_pass http://<backend-host>:<backend-port>;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
} }
@@ -245,8 +259,9 @@ Save as `/etc/nginx/sites-available/<new-domain>`, symlink it into `sites-enable
sudo nginx -t && sudo systemctl reload nginx sudo nginx -t && sudo systemctl reload nginx
``` ```
Confirm the domain's DNS `A`/`AAAA` record already points at this Pi before continuing — the Confirm the domain's DNS `A`/`AAAA` record already points at **the proxy Pi** (not the backend)
HTTP-01 challenge in step 2 needs the domain to actually resolve here. before continuing — the HTTP-01 challenge in step 2 needs the domain to resolve to whichever host
is answering on port 80, which is the proxy Pi.
**Step 2 — request and install the certificate.** The nginx plugin edits the file from step 1 in **Step 2 — request and install the certificate.** The nginx plugin edits the file from step 1 in
place: it adds the `listen 443 ssl` block, the certificate/key paths, and (by default) an place: it adds the `listen 443 ssl` block, the certificate/key paths, and (by default) an
@@ -260,23 +275,34 @@ Certbot's own systemd timer handles renewal automatically — nothing further to
```bash ```bash
curl -I https://<new-domain>/health # or whichever path this new site/API actually serves curl -I https://<new-domain>/health # or whichever path this new site/API actually serves
``` ```
Confirm it resolves over HTTPS with a valid certificate and reaches the expected local port. Confirm it resolves over HTTPS with a valid certificate and reaches the expected backend.
Repeat steps 13 once per domain. This is the same procedure regardless of whether the new domain Repeat steps 13 once per domain. This is the same procedure regardless of whether the new domain
is another environment for this feature, a completely different project's API, or a plain static is another environment for this feature, a completely different project's API, or a plain static
site — nginx and certbot don't know or care what's listening on the local port they proxy to. site — nginx and certbot don't know or care what's actually listening on the backend they proxy to.
**Step 4 — restrict the backend port on pi-main (do this once the domain works end to end).**
Since Kestrel now listens on `0.0.0.0:<backend-port>` (§ 1.5), anything on the LAN can reach it
directly, bypassing the proxy Pi's TLS entirely, unless pi-main's firewall says otherwise. On
**pi-main**:
```bash
sudo ufw allow from <proxy-pi-lan-ip> to any port <backend-port> proto tcp
sudo ufw deny <backend-port>/tcp
```
(Or the equivalent `nftables`/`iptables` rules if `ufw` isn't what this Pi uses — the point is:
only the proxy Pi's address may reach these ports, everything else is denied.)
#### 1.7.2 Current State for This Deployment #### 1.7.2 Current State for This Deployment
Running the procedure above for `test.slpsoftware.nl` and `slpsoftware.nl` produces (after Running the procedure above (on the proxy Pi) for `test.slpsoftware.nl` and `slpsoftware.nl`,
certbot has added its blocks) server blocks equivalent to: pointing at pi-main's LAN address, produces server blocks equivalent to:
```nginx ```nginx
server { server {
listen 443 ssl; listen 443 ssl;
server_name test.slpsoftware.nl; server_name test.slpsoftware.nl;
location / { location / {
proxy_pass http://127.0.0.1:5100; proxy_pass http://<pi-main-lan-ip>:5100;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
} }
@@ -287,7 +313,7 @@ server {
listen 443 ssl; listen 443 ssl;
server_name slpsoftware.nl; server_name slpsoftware.nl;
location / { location / {
proxy_pass http://127.0.0.1:5101; proxy_pass http://<pi-main-lan-ip>:5101;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
} }