Labels every command with who runs it and where

Four contexts got tangled together across the doc as it grew: pi-main
as root, pi-main as gitea-workflow, the separate proxy Pi, and Gitea's
own web UI. Added a legend up front and tagged every single command
block so it's no longer something the reader has to infer.
This commit is contained in:
2026-07-29 14:14:58 +02:00
parent c438ec8b1d
commit 459c347c12
2 changed files with 65 additions and 12 deletions
@@ -1409,3 +1409,13 @@ by Infrastructure Design. U5 carries REF-U3-01's Umami origin gate plus FR-21 an
**Context**: OPERATIONS — Deployment Setup, real host config revealed the reference project's nginx Sentry-tunnel pattern was carried over unnecessarily and conflicts with this app's own built-in tunnel **Context**: OPERATIONS — Deployment Setup, real host config revealed the reference project's nginx Sentry-tunnel pattern was carried over unnecessarily and conflicts with this app's own built-in tunnel
--- ---
## Deployment Setup — Explicit Who/Where Labels on Every Command
**Timestamp**: 2026-07-29T01:00:00Z
**User Input**: "in de deployment instructions i snou niet echt duidelijk wanneer je als welke gebruiker de commando's moet draaien"
**AI Response**: "Added a legend near the top of deployment-instructions.md defining four contexts (pi-main/root, pi-main/gitea-workflow, proxy Pi/root, Gitea web UI) and labelled every single command block throughout the document with one of them — account creation, lingering, directory setup, shared/env, systemd units, the entire nginx/certbot section (now clearly proxy-Pi-only except the pi-main firewall step), backup credentials, the backup script itself and its manual verification, and the Gitea Actions variables (explicitly called out as not a host command at all)."
**Context**: OPERATIONS — Deployment Setup, clarity pass across the whole instructions document
---
@@ -10,6 +10,15 @@ software itself. SlpModularCms can host multiple customers; `slpsoftware` is thi
and the owner's own site. A future second customer on the same Pi would get its own instance name and the owner's own site. A future second customer on the same Pi would get its own instance name
throughout, following the same pattern. throughout, following the same pattern.
**Who runs what, where** — three hosts and two accounts appear below; every command block is
labelled with one of these:
- 🖥️ **pi-main / root** — as yourself with `sudo`, on the Pi this application runs on
- 👤 **pi-main / `gitea-workflow`** — as the deploy account, in a **real login shell** (SSH in
directly as `gitea-workflow`, not `su -`/`sudo -i -u` from your own session — § 1.3 explains why
that distinction matters)
- 🌐 **proxy Pi / root** — as yourself with `sudo`, on the *separate* Pi that terminates TLS (§ 1.7)
- ☁️ **Gitea web UI** — not a host command at all; done in this repository's Actions settings
--- ---
## 1. One-Time Host Setup ## 1. One-Time Host Setup
@@ -37,6 +46,8 @@ named after this project, since it may end up deploying other projects on this P
`SlpSoftware` project's own pipeline may already have exactly this kind of account; check its `SlpSoftware` project's own pipeline may already have exactly this kind of account; check its
`PI_MAIN_USERNAME` secret first and reuse it directly if it already has SSH shell access, rather than `PI_MAIN_USERNAME` secret first and reuse it directly if it already has SSH shell access, rather than
creating a second one). Example name: `gitea-workflow`. creating a second one). Example name: `gitea-workflow`.
🖥️ **pi-main / root:**
```bash ```bash
sudo useradd -m -s /bin/bash gitea-workflow sudo useradd -m -s /bin/bash gitea-workflow
sudo passwd gitea-workflow sudo passwd gitea-workflow
@@ -56,6 +67,8 @@ The rest of this document uses `gitea-workflow` as the example account name —
if you pick something else. if you pick something else.
### 1.3 Enable Lingering (INFRA-U6-01 — do this first, easy to forget) ### 1.3 Enable Lingering (INFRA-U6-01 — do this first, easy to forget)
🖥️ **pi-main / root:**
```bash ```bash
sudo loginctl enable-linger gitea-workflow sudo loginctl enable-linger gitea-workflow
``` ```
@@ -69,6 +82,8 @@ running independent of any login session.
setting the rest of this up manually (before the deploy workflow ever runs): enabling lingering setting the rest of this up manually (before the deploy workflow ever runs): enabling lingering
does not retroactively start the user manager — that happens on the next real login, reboot, or does not retroactively start the user manager — that happens on the next real login, reboot, or
manually: manually:
🖥️ **pi-main / root:**
```bash ```bash
sudo systemctl start user@$(id -u gitea-workflow).service sudo systemctl start user@$(id -u gitea-workflow).service
``` ```
@@ -84,6 +99,8 @@ Two fixes, in order of preference:
- **SSH in directly as `gitea-workflow`** instead of logging in as yourself and switching user — - **SSH in directly as `gitea-workflow`** instead of logging in as yourself and switching user —
a real SSH login does go through `sshd`'s PAM stack and sets `XDG_RUNTIME_DIR` correctly a real SSH login does go through `sshd`'s PAM stack and sets `XDG_RUNTIME_DIR` correctly
- Or, after `su -`/`sudo -i -u`, just set it by hand once per shell: - Or, after `su -`/`sudo -i -u`, just set it by hand once per shell:
👤 **pi-main / `gitea-workflow`:**
```bash ```bash
export XDG_RUNTIME_DIR=/run/user/$(id -u) export XDG_RUNTIME_DIR=/run/user/$(id -u)
``` ```
@@ -102,6 +119,7 @@ Deliberately placed under `gitea-workflow`'s **own home directory**, not under
for the CMS's own release/current/shared structure to live anywhere near the other websites at all, for the CMS's own release/current/shared structure to live anywhere near the other websites at all,
which directly avoids interfering with them (as you asked in Q2): which directly avoids interfering with them (as you asked in Q2):
👤 **pi-main / `gitea-workflow`:**
```bash ```bash
mkdir -p ~/apps/slpsoftware/<env>/releases mkdir -p ~/apps/slpsoftware/<env>/releases
``` ```
@@ -113,6 +131,7 @@ uploads *this* customer's website via FileZilla, e.g. `/mnt/storage1/www/html/sl
(adjust the exact folder name to whatever convention the other sites under `html/` already use, if (adjust the exact folder name to whatever convention the other sites under `html/` already use, if
one exists). Rather than a plain directory, make it a symlink across accounts: one exists). Rather than a plain directory, make it a symlink across accounts:
👤 **pi-main / `gitea-workflow`:**
```bash ```bash
mkdir -p ~/apps/slpsoftware/<env> mkdir -p ~/apps/slpsoftware/<env>
ln -s /mnt/storage1/www/html/slpsoftware/<env> ~/apps/slpsoftware/<env>/shared/wwwroot-web ln -s /mnt/storage1/www/html/slpsoftware/<env> ~/apps/slpsoftware/<env>/shared/wwwroot-web
@@ -122,12 +141,17 @@ ln -s /mnt/storage1/www/html/slpsoftware/<env> ~/apps/slpsoftware/<env>/shared/w
needs no changes for this — it only ever resolves the symlink chain, it doesn't care how many hops needs no changes for this — it only ever resolves the symlink chain, it doesn't care how many hops
that chain has. What **does** need attention: `gitea-workflow` needs read + traverse permission on that chain has. What **does** need attention: `gitea-workflow` needs read + traverse permission on
`/mnt/storage1/www/html/slpsoftware/<env>/` and its parent directories, which `webadmin` owns. `/mnt/storage1/www/html/slpsoftware/<env>/` and its parent directories, which `webadmin` owns.
Simplest fix: put both accounts in a shared group (e.g. `webshared`), `chgrp -R webshared` that Simplest fix: put both accounts in a shared group (e.g. `webshared`), then, 🖥️ **pi-main / root**:
folder, and make sure webadmin's FTP server creates new uploads group-readable (`g+rx`, not just ```bash
sudo chgrp -R webshared /mnt/storage1/www/html/slpsoftware/<env>
```
and make sure webadmin's FTP server creates new uploads group-readable (`g+rx`, not just
owner-readable) — a one-time permission setup, not something either pipeline touches per deploy. owner-readable) — a one-time permission setup, not something either pipeline touches per deploy.
### 1.5 Runtime Configuration File ### 1.5 Runtime Configuration File
One file per environment, **outside** the release directory so it survives every switch: One file per environment, **outside** the release directory so it survives every switch:
👤 **pi-main / `gitea-workflow`:**
```bash ```bash
touch ~/apps/slpsoftware/<env>/shared/env touch ~/apps/slpsoftware/<env>/shared/env
chmod 600 ~/apps/slpsoftware/<env>/shared/env chmod 600 ~/apps/slpsoftware/<env>/shared/env
@@ -187,7 +211,8 @@ gate only catches drift between the frontend build and that Gitea variable; it c
so keeping the two in sync is a manual discipline, not something enforced automatically. so keeping the two in sync is a manual discipline, not something enforced automatically.
### 1.6 systemd User Units ### 1.6 systemd User Units
Create `~/.config/systemd/user/slpsoftware-test.service`:
👤 **pi-main / `gitea-workflow`** — create `~/.config/systemd/user/slpsoftware-test.service`:
```ini ```ini
[Unit] [Unit]
Description=SlpModularCms API (test) Description=SlpModularCms API (test)
@@ -209,6 +234,8 @@ And `~/.config/systemd/user/slpsoftware-production.service` — identical, with
`production` throughout (including the port inside `shared/env`). `production` throughout (including the port inside `shared/env`).
Enable both (does not start them yet — nothing is deployed there until the first CI run): Enable both (does not start them yet — nothing is deployed there until the first CI run):
👤 **pi-main / `gitea-workflow`** (real login shell — § 1.3):
```bash ```bash
systemctl --user daemon-reload systemctl --user daemon-reload
systemctl --user enable slpsoftware-test.service systemctl --user enable slpsoftware-test.service
@@ -251,6 +278,7 @@ or container on the LAN — pi-main and its two ports, in this deployment's case
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
for a certificate before this exists will fail. for a certificate before this exists will fail.
🌐 **proxy Pi / root:**
```nginx ```nginx
server { server {
listen 80; listen 80;
@@ -275,12 +303,15 @@ 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
HTTP→HTTPS redirect for the port 80 block: HTTP→HTTPS redirect for the port 80 block:
🌐 **proxy Pi / root:**
```bash ```bash
sudo certbot --nginx -d <new-domain> sudo certbot --nginx -d <new-domain>
``` ```
Certbot's own systemd timer handles renewal automatically — nothing further to set up for that. Certbot's own systemd timer handles renewal automatically — nothing further to set up for that.
**Step 3 — verify.** After certbot finishes: **Step 3 — verify.** Can be run from anywhere with network access to the domain (your own machine
is fine — this isn't tied to either Pi). After certbot finishes:
```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
``` ```
@@ -292,8 +323,9 @@ site — nginx and certbot don't know or care what's actually listening on the b
**Step 4 — restrict the backend port on pi-main (do this once the domain works end to end).** **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 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 directly, bypassing the proxy Pi's TLS entirely, unless pi-main's firewall says otherwise.
**pi-main**:
🖥️ **pi-main / root:**
```bash ```bash
sudo ufw allow from <proxy-pi-lan-ip> to any port <backend-port> proto tcp sudo ufw allow from <proxy-pi-lan-ip> to any port <backend-port> proto tcp
sudo ufw deny <backend-port>/tcp sudo ufw deny <backend-port>/tcp
@@ -331,6 +363,8 @@ server {
``` ```
### 1.8 Database Backup Credentials (§ 4 depends on this) ### 1.8 Database Backup Credentials (§ 4 depends on this)
👤 **pi-main / `gitea-workflow`:**
```bash ```bash
touch ~/.config/slpsoftware-db-backup.env touch ~/.config/slpsoftware-db-backup.env
chmod 600 ~/.config/slpsoftware-db-backup.env chmod 600 ~/.config/slpsoftware-db-backup.env
@@ -347,7 +381,9 @@ credential, ideally scoped to just read access (`SELECT`, `LOCK TABLES` — ever
needs) rather than the application's own data-access login. needs) rather than the application's own data-access login.
### 1.9 Gitea Actions Variables and Secrets ### 1.9 Gitea Actions Variables and Secrets
Set once, in this repository's Gitea Actions settings. This is exactly where the real path lives —
☁️ **Gitea web UI** — not a host command, no SSH involved. Set once, in this repository's Actions
settings. This is exactly where the real path lives —
`deploy-scp.yaml` never hardcodes it, it only reads `${{ vars.DEPLOY_PATH_TEST }}` / `deploy-scp.yaml` never hardcodes it, it only reads `${{ vars.DEPLOY_PATH_TEST }}` /
`${{ vars.DEPLOY_PATH_PRODUCTION }}`, so changing the path later is a variable edit, not a workflow `${{ vars.DEPLOY_PATH_PRODUCTION }}`, so changing the path later is a variable edit, not a workflow
change: change:
@@ -389,13 +425,15 @@ Already built (U5/U6) — this is the read-only walkthrough for whoever operates
- `wwwroot/web/` will be empty until a website workspace deploys into it — `/` serves the built-in - `wwwroot/web/` will be empty until a website workspace deploys into it — `/` serves the built-in
placeholder until then (this is expected, not a failure) placeholder until then (this is expected, not a failure)
- The very first run has no "previous release" to keep — pruning naturally has nothing to prune - The very first run has no "previous release" to keep — pruning naturally has nothing to prune
- Verify manually after the first run: `curl https://test.slpsoftware.nl/health` and - Verify manually after the first run (from anywhere — not tied to either Pi):
`curl https://slpsoftware.nl/health` (after the first production run) both return `200` `curl https://test.slpsoftware.nl/health` and `curl https://slpsoftware.nl/health` (after the
first production run) both return `200`
## 4. Database Backup Script ## 4. Database Backup Script
Create `~/scripts/backup-slpmodularcms-db.sh` on the Pi (this script is host-side by design — never 👤 **pi-main / `gitea-workflow`** — create `~/scripts/backup-slpmodularcms-db.sh` on the Pi (this
part of this repository, so no DB credential ever reaches Gitea): script is host-side by design — never part of this repository, so no DB credential ever reaches
Gitea):
```bash ```bash
#!/usr/bin/env bash #!/usr/bin/env bash
@@ -433,11 +471,13 @@ ls -1t "$BACKUP_DIR"/*.sql.gz 2>/dev/null | tail -n +8 | xargs -r rm -f
`mariadb-dump` is MariaDB's own name for the tool (present since MariaDB 10.4-ish); if the host only `mariadb-dump` is MariaDB's own name for the tool (present since MariaDB 10.4-ish); if the host only
has the older `mysqldump` name, substitute it — same tool, same flags. has the older `mysqldump` name, substitute it — same tool, same flags.
👤 **pi-main / `gitea-workflow`:**
```bash ```bash
chmod +x ~/scripts/backup-slpmodularcms-db.sh chmod +x ~/scripts/backup-slpmodularcms-db.sh
``` ```
**Verify once, manually**, before relying on it in a real deploy: **Verify once, manually**, before relying on it in a real deploy — 👤 **pi-main / `gitea-workflow`**
(this matches exactly how `deploy-scp.yaml` itself invokes the script over SSH):
```bash ```bash
~/scripts/backup-slpmodularcms-db.sh production ~/scripts/backup-slpmodularcms-db.sh production
``` ```
@@ -445,6 +485,9 @@ Confirm a `.sql.gz` file appears under `~/backups/slpsoftware/production/` and t
silently fail (the script uses `set -euo pipefail`, so a real error does propagate as a non-zero silently fail (the script uses `set -euo pipefail`, so a real error does propagate as a non-zero
exit — which fails the calling `deploy-scp.yaml` step, correctly blocking the deploy). Worth a exit — which fails the calling `deploy-scp.yaml` step, correctly blocking the deploy). Worth a
one-time restore rehearsal too — an untested backup is not a verified one: one-time restore rehearsal too — an untested backup is not a verified one:
👤 **pi-main / `gitea-workflow`** (or wherever you have a MariaDB client and network access to
`127.0.0.1`'s database — this one isn't identity-sensitive, just needs DB access):
```bash ```bash
gunzip -c ~/backups/slpsoftware/production/<file>.sql.gz | mariadb -h 127.0.0.1 -u root -p <a-scratch-database> gunzip -c ~/backups/slpsoftware/production/<file>.sql.gz | mariadb -h 127.0.0.1 -u root -p <a-scratch-database>
``` ```