# Deployment Instructions **Target**: single Raspberry Pi (`linux-arm64`), test and production both on it, separated by directory, `systemd --user` unit, and local port. Domains: `test.slpsoftware.nl` (test), `slpsoftware.nl` (production). --- ## 1. One-Time Host Setup Do this once, before the first automated deploy. Everything here is host configuration the workflow assumes already exists — `deploy-scp.yaml` (U6) never creates any of it. ### 1.1 Prerequisites - .NET 10 runtime installed on the Pi (ASM-03) — the publish is framework-dependent (`infrastructure-design.md` § 1), so the Pi needs the runtime, not the full SDK - `sqlcmd` installed, for the backup script (§ 4) — e.g. `mssql-tools18` / `unixodbc` on Debian-based Raspberry Pi OS ### 1.2 Account Model (revised — `webadmin` is not the deploy account) Clarified during Deployment Setup: `webadmin` is the FileZilla/SFTP account website-workspace authors use to upload customer sites under `/mnt/storage1/www/html/` (`WEBSITE_WORKSPACE.md`'s role) — it cannot SSH in, and should **stay** SFTP-only. `deploy-scp.yaml` needs a real SSH shell (for `mkdir`, `ln -sfn`, `systemctl --user restart`, the backup script), which is a different kind of access than FileZilla uses. **Preferred**: a separate account for the deploy pipeline, but a **generic, host-wide** one — not named after this project, since it may end up deploying other projects on this Pi too (the reference `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 creating a second one). Example name: `pi-deploy`. ```bash sudo useradd -m -s /bin/bash pi-deploy sudo passwd pi-deploy ``` Each project deploying through this account gets its own subdirectory under its home (§ 1.4 already namespaces by project: `~/apps/slpmodularcms-/`), so one generic account can serve multiple projects without their release trees colliding. **Fallback, only if no SSH-capable account exists at all and creating one genuinely isn't feasible** (e.g. a hosting provider that doesn't allow arbitrary new system accounts): grant `webadmin` SSH shell access instead (`sudo usermod -s /bin/bash webadmin`, plus enabling SSH password/key auth for it if currently blocked at the `sshd_config` level). This merges the FTP and deploy roles onto one account — acceptable as a fallback, but worth revisiting later, since it means a website-workspace author's FTP credential would also be able to run shell commands on the Pi. The rest of this document uses `` — substitute the generic account's actual name. ### 1.3 Enable Lingering (INFRA-U6-01 — do this first, easy to forget) ```bash sudo loginctl enable-linger ``` Without this, the `systemd --user` service manager is torn down when the deploy SSH session ends, killing the just-restarted app a few seconds after every successful deploy. This also fixes the common "Failed to connect to bus" error `systemctl --user` can throw when invoked from a non-interactive SSH command — lingering keeps the user's systemd instance (and `XDG_RUNTIME_DIR`) running independent of any login session. ### 1.4 Directory Skeleton Deliberately placed under ``'s **own home directory**, not under `/mnt/storage1/www/html/` — since it's now a separate account from `webadmin`, there is no reason 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): ```bash mkdir -p ~/apps/slpmodularcms-/releases ``` `current` is created by the first deploy itself (`ln -sfn`) — don't pre-create it. **`shared/wwwroot-web` is the one exception** — it must resolve to wherever `webadmin` actually uploads *this* customer's website via FileZilla, e.g. `/mnt/storage1/www/html/slpmodularcms-/` (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: ```bash mkdir -p ~/apps/slpmodularcms- ln -s /mnt/storage1/www/html/slpmodularcms- ~/apps/slpmodularcms-/shared/wwwroot-web ``` `deploy-scp.yaml`'s existing logic (`releases/{ts}/wwwroot/web -> ../../../shared/wwwroot-web`) 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: `` needs read + traverse permission on `/mnt/storage1/www/html/slpmodularcms-/` and its parent directories, which `webadmin` owns. Simplest fix: put both accounts in a shared group (e.g. `webshared`), `chgrp -R webshared` that folder, 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. ### 1.5 Runtime Configuration File One file per environment, **outside** the release directory so it survives every switch: ```bash touch ~/apps/slpmodularcms-/shared/env chmod 600 ~/apps/slpmodularcms-/shared/env ``` Contents (fill in real values — this file is never read by the workflow, only by the systemd unit below): ```ini ASPNETCORE_ENVIRONMENT=Production ASPNETCORE_URLS=http://localhost: ConnectionStrings__DefaultConnection=Server=127.0.0.1,1433;User ID=;Password=;Database=SlpModularCms;TrustServerCertificate=True JwtSettings__Secret= JwtSettings__Issuer=SlpModularCms JwtSettings__Audience=SlpModularCmsPortal MasterModule__MasterUrl=https:// Observability__SentryDsn= SecurityHeaders__AllowedScriptOrigins__0= SecurityHeaders__AllowedConnectOrigins__0= ``` Use ports **5100** (test) and **5101** (production) unless something else on the Pi already occupies them. `ASPNETCORE_ENVIRONMENT=Production` is used for **both** environments deliberately — `Development` disables HSTS and exposes the Scalar API explorer (`Program.cs`), neither of which should be true for anything reachable at a real domain, including test. **`SecurityHeaders__AllowedScriptOrigins__0` / `_AllowedConnectOrigins__0` must exactly match** the Gitea variables `SECURITY_ALLOWED_SCRIPT_ORIGINS_TEST` / `_PRODUCTION` (see § 1.9) — REF-U5-01's CI gate only catches drift between the frontend build and that Gitea variable; it cannot see this file, so keeping the two in sync is a manual discipline, not something enforced automatically. ### 1.6 systemd User Units Create `~/.config/systemd/user/slpmodularcms-test.service`: ```ini [Unit] Description=SlpModularCms API (test) After=network.target [Service] WorkingDirectory=%h/apps/slpmodularcms-test/current ExecStart=/usr/bin/dotnet %h/apps/slpmodularcms-test/current/SlpModularCms.Api.dll EnvironmentFile=%h/apps/slpmodularcms-test/shared/env Restart=on-failure RestartSec=5 KillSignal=SIGINT TimeoutStopSec=20 [Install] WantedBy=default.target ``` And `~/.config/systemd/user/slpmodularcms-production.service` — identical, with `test` replaced by `production` throughout (including the port inside `shared/env`). Enable both (does not start them yet — nothing is deployed there until the first CI run): ```bash systemctl --user daemon-reload systemctl --user enable slpmodularcms-test.service systemctl --user enable slpmodularcms-production.service ``` ### 1.7 nginx Routing The existing reverse proxy (`infrastructure-design.md` § 1, § 4) needs a server block per domain, routing to the matching local port: ```nginx server { listen 443 ssl; server_name test.slpsoftware.nl; location / { proxy_pass http://127.0.0.1:5100; proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto $scheme; } } server { listen 443 ssl; server_name slpsoftware.nl; location / { proxy_pass http://127.0.0.1:5101; proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto $scheme; } } ``` TLS certificate provisioning (e.g. certbot) is existing host operations, out of scope for this feature — assumed already handled the same way the reference project's domains are. ### 1.8 Database Backup Credentials (§ 4 depends on this) ```bash touch ~/.config/slpmodularcms-db-backup.env chmod 600 ~/.config/slpmodularcms-db-backup.env ``` ```ini DB_SERVER=127.0.0.1,1433 DB_NAME=SlpModularCmsProduction DB_USER= DB_PASSWORD= ``` Kept **separate** from `shared/env` (§ 1.5) deliberately — the backup script needs its own credential, ideally scoped to just `BACKUP DATABASE` permission rather than the application's own data-access login. ### 1.9 Gitea Actions Variables and Secrets Set once, in this repository's Gitea Actions settings. This is exactly where the real path lives — `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 change: | Name | Kind | Value | |---|---|---| | `PI_MAIN_ADDRESS` | secret | the Pi's address | | `PI_MAIN_PORT` | secret | SSH port | | `PI_MAIN_USERNAME` | secret | `` — the generic, host-wide deploy account (§ 1.2), **not** `webadmin`; reuse the reference project's if it already has one | | `PI_MAIN_PASSWORD` | secret | ``'s password | | `DEPLOY_PATH_TEST` | variable | `/home//apps/slpmodularcms-test` | | `DEPLOY_PATH_PRODUCTION` | variable | `/home//apps/slpmodularcms-production` | | `SERVICE_NAME_TEST` | variable | `slpmodularcms-test.service` | | `SERVICE_NAME_PRODUCTION` | variable | `slpmodularcms-production.service` | | `HEALTH_CHECK_URL_TEST` | variable | `https://test.slpsoftware.nl/health` | | `HEALTH_CHECK_URL_PRODUCTION` | variable | `https://slpsoftware.nl/health` | | `VITE_SENTRY_DSN` | variable | Sentry DSN (shared, not sensitive — safe in the client bundle) | | `VITE_UMAMI_SCRIPT_URL` | variable | Umami script host (shared) | | `VITE_UMAMI_WEBSITE_ID_TEST` / `_PRODUCTION` | variable | per-environment Umami website ID | | `SECURITY_ALLOWED_SCRIPT_ORIGINS_TEST` / `_PRODUCTION` | variable | **must match** § 1.5's `SecurityHeaders__AllowedScriptOrigins__0` for that environment | --- ## 2. Deploy Sequence (What Actually Happens on a Run) Already built (U5/U6) — this is the read-only walkthrough for whoever operates it: 1. Push to `master`, or a manual `workflow_dispatch` → the six gates run 2. `publish-test` runs (and `publish-production`, only if `workflow_dispatch` with the flag) 3. `deploy-test` (always, if gates pass) calls `deploy-scp.yaml`, which uploads into a new `releases/{timestamp}/`, links `shared/wwwroot-web` in, switches `current`, restarts `slpmodularcms-test.service`, verifies `https://test.slpsoftware.nl/health`, then prunes old releases (only test — `run_db_backup: false`) 4. `deploy-production` (only with the flag) does the same, plus a database backup first (`run_db_backup: true`) — see § 4 ## 3. First-Ever Deploy Notes - `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) - 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 `curl https://slpsoftware.nl/health` (after the first production run) both return `200` ## 4. Database Backup Script Create `~/scripts/backup-slpmodularcms-db.sh` on the Pi (this script is host-side by design — never part of this repository, so no DB credential ever reaches Gitea): ```bash #!/usr/bin/env bash set -euo pipefail ENVIRONMENT="${1:?Usage: backup-slpmodularcms-db.sh }" CREDENTIALS_FILE="$HOME/.config/slpmodularcms-db-backup.env" if [[ ! -f "$CREDENTIALS_FILE" ]]; then echo "Missing $CREDENTIALS_FILE — see deployment-instructions.md § 1.8" >&2 exit 1 fi # shellcheck source=/dev/null source "$CREDENTIALS_FILE" : "${DB_SERVER:?}" "${DB_NAME:?}" "${DB_USER:?}" "${DB_PASSWORD:?}" BACKUP_DIR="$HOME/backups/slpmodularcms-${ENVIRONMENT}" mkdir -p "$BACKUP_DIR" TIMESTAMP=$(date -u +%Y%m%d%H%M%S) BACKUP_FILE="$BACKUP_DIR/${DB_NAME}-${TIMESTAMP}.bak" sqlcmd -S "$DB_SERVER" -U "$DB_USER" -P "$DB_PASSWORD" -C -Q \ "BACKUP DATABASE [$DB_NAME] TO DISK = N'$BACKUP_FILE' WITH INIT, COMPRESSION" echo "Backup written to $BACKUP_FILE" # Retention: keep the 7 most recent backups for this environment ls -1t "$BACKUP_DIR"/*.bak 2>/dev/null | tail -n +8 | xargs -r rm -f ``` ```bash chmod +x ~/scripts/backup-slpmodularcms-db.sh ``` **Verify once, manually**, before relying on it in a real deploy: ```bash ~/scripts/backup-slpmodularcms-db.sh production ``` Confirm a `.bak` file appears under `~/backups/slpmodularcms-production/` and that `sqlcmd` didn't silently fail (the script uses `set -euo pipefail`, so a real SQL error does propagate as a non-zero exit — which fails the calling `deploy-scp.yaml` step, correctly blocking the deploy). ## 5. Future: Switching Production to FTPS (Shared Hosting) D-02/NFR-09 required the workflow's *transport* to be swappable without restructuring — satisfied by the `transport` input already on `deploy-scp.yaml` (currently only `scp` is implemented). This section documents what actually changes when that day comes (OPEN-04 — not scheduled, drafted now per Q5 = B of the deployment setup plan). ### 5.1 What carries over unchanged - The CI workflow's gates, the two-build split, the `config` job pattern - The overall shape of the interface: `artifact_name`, `environment`, `deploy_path` ### 5.2 What does not carry over — read this before assuming it's a drop-in swap Shared .NET hosting is almost always **Windows/IIS-based**, not Linux/systemd. That changes more than the transport: - **No `systemctl --user` restart** — IIS picks up a new deployment via an app-pool recycle, usually triggered by touching `web.config` or the app-pool's own recycle mechanism, not a service restart command - **The atomic release-switch pattern may not be available at all** — many shared hosts expose only a single web root over FTPS, with no ability to create sibling directories and swap a symlink. `wwwroot/web/` persistence (FR-08, ASM-01) would need a **different** mechanism on such a host — e.g. never touching a specific subfolder during upload, rather than linking a persistent directory outside a swapped release tree, since "outside the release tree" may not be an available concept - **Database backup** — shared hosting frequently does not expose direct `sqlcmd`/SSH access at all; the backup step in `deploy-scp.yaml` (§ 4 script) would need to become either a provider-specific API call or a documented manual pre-production step (the FR-20 fallback U6 already designed for) ### 5.3 What building `deploy-ftps.yaml` would actually require 1. A new reusable workflow implementing the **same five required inputs** (`artifact_name`, `environment`, `deploy_path`, `service_name`, `health_check_url`) so `continuous_integration.yaml` calls it identically via the `transport` input 2. An FTPS upload step (e.g. `lftp` mirror or `curl --ftp-ssl`) replacing the `scp` step 3. A **new decision, not yet made**: what "atomic switch" and "restart" even mean on the target host — this cannot be answered generically; it depends on which shared host is actually chosen 4. Re-running this feature's Infrastructure Design step for U6, scoped to the new host, once a specific shared-hosting provider is selected — not a checklist item that can be pre-answered today **Recommendation**: treat this section as a starting brief for that future Infrastructure Design pass, not as a ready-to-execute procedure — the concrete host was unknown at the time this was written (OPEN-04), so several of the decisions above are necessarily provider-specific.