Files
slp-modular-cms/aidlc-docs/features/gitea-deployment-workflow/operations/deployment/deployment-instructions.md
T
Sluijsens 843253888e Nests environments under one slpmodularcms folder instead of siblings
Applies to both the app's own release tree and the website upload
path - slpmodularcms/test and slpmodularcms/production side by side
under one parent, not two separately-named directories. Service unit
names stay hyphenated; those aren't folders.
2026-07-28 21:43:37 +02:00

16 KiB

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: gitea-workflow.

sudo useradd -m -s /bin/bash gitea-workflow
sudo passwd gitea-workflow

Each project deploying through this account gets its own subdirectory under its home (§ 1.4 already namespaces by project: ~/apps/slpmodularcms/<env>/), 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 gitea-workflow as the example account name — rename consistently if you pick something else.

1.3 Enable Lingering (INFRA-U6-01 — do this first, easy to forget)

sudo loginctl enable-linger gitea-workflow

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 gitea-workflow'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):

mkdir -p ~/apps/slpmodularcms/<env>/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/<env>/ (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:

mkdir -p ~/apps/slpmodularcms/<env>
ln -s /mnt/storage1/www/html/slpmodularcms/<env> ~/apps/slpmodularcms/<env>/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: gitea-workflow needs read + traverse permission on /mnt/storage1/www/html/slpmodularcms/<env>/ 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:

touch ~/apps/slpmodularcms/<env>/shared/env
chmod 600 ~/apps/slpmodularcms/<env>/shared/env

Contents (fill in real values — this file is never read by the workflow, only by the systemd unit below):

ASPNETCORE_ENVIRONMENT=Production
ASPNETCORE_URLS=http://localhost:<port>
ConnectionStrings__DefaultConnection=Server=127.0.0.1,1433;User ID=<user>;Password=<password>;Database=SlpModularCms<Env>;TrustServerCertificate=True
JwtSettings__Secret=<secure-long-random-secret>
JwtSettings__Issuer=SlpModularCms
JwtSettings__Audience=SlpModularCmsPortal
MasterModule__MasterUrl=https://<this-environment-domain>
Observability__SentryDsn=<sentry-dsn-or-empty>
SecurityHeaders__AllowedScriptOrigins__0=<umami-script-origin>
SecurityHeaders__AllowedConnectOrigins__0=<sentry-ingest-origin>

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:

[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):

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:

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)

touch ~/.config/slpmodularcms-db-backup.env
chmod 600 ~/.config/slpmodularcms-db-backup.env
DB_SERVER=127.0.0.1,1433
DB_NAME=SlpModularCmsProduction
DB_USER=<a-login-with-backup-database-permission>
DB_PASSWORD=<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 gitea-workflow — the generic, host-wide deploy account (§ 1.2), not webadmin; reuse the reference project's if it already has one
PI_MAIN_PASSWORD secret gitea-workflow's password
DEPLOY_PATH_TEST variable /home/gitea-workflow/apps/slpmodularcms/test
DEPLOY_PATH_PRODUCTION variable /home/gitea-workflow/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):

#!/usr/bin/env bash
set -euo pipefail

ENVIRONMENT="${1:?Usage: backup-slpmodularcms-db.sh <environment>}"
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
chmod +x ~/scripts/backup-slpmodularcms-db.sh

Verify once, manually, before relying on it in a real deploy:

~/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.