Files
slp-modular-cms/aidlc-docs/features/gitea-deployment-workflow/operations/deployment/deployment-instructions.md
T
Sluijsens cf80f827ae Deployment setup - the parts the workflow deliberately left out
Host setup checklist, real domains and ports, the database backup
script the deploy workflow only ever invokes, and a rollback plan.
Also drafted the FTPS switch procedure for whenever production moves
off the Pi, with a note that shared hosting is likely IIS-based -
that's a bigger change than swapping the transport.
2026-07-28 19:07:26 +02:00

12 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
  • SSH access for the deploy user, password auth for now (matches the reference project; SSH-key migration remains a documented future step, same as there)
  • sqlcmd installed, for the backup script (§ 4) — e.g. mssql-tools18 / unixodbc on Debian-based Raspberry Pi OS

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

sudo loginctl enable-linger <deploy-user>

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.3 Directory Skeleton

Run once per environment (test, production):

mkdir -p ~/apps/slpmodularcms-<env>/releases
mkdir -p ~/apps/slpmodularcms-<env>/shared/wwwroot-web

current is created by the first deploy itself (ln -sfn) — don't pre-create it.

1.4 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 § 3) — 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.5 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.6 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.7 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.4) 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.8 Gitea Actions Variables and Secrets

Set once, in this repository's Gitea Actions settings:

Name Kind Value
PI_MAIN_ADDRESS secret the Pi's address
PI_MAIN_PORT secret SSH port
PI_MAIN_USERNAME secret deploy user
PI_MAIN_PASSWORD secret deploy user's password
DEPLOY_PATH_TEST variable /home/<user>/apps/slpmodularcms-test
DEPLOY_PATH_PRODUCTION variable /home/<user>/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.4'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.7" >&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.