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:
@@ -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
|
||||||
|
|
||||||
|
---
|
||||||
|
|||||||
+9
@@ -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.
|
||||||
|
|||||||
+44
-18
@@ -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.2–1.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.1–1.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 1–3 once per domain. This is the same procedure regardless of whether the new domain
|
Repeat steps 1–3 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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user