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.
This commit is contained in:
+268
@@ -0,0 +1,268 @@
|
||||
# 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)
|
||||
```bash
|
||||
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`):
|
||||
```bash
|
||||
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:
|
||||
```bash
|
||||
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):
|
||||
```ini
|
||||
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`:
|
||||
```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.6 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.7 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=<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):
|
||||
|
||||
```bash
|
||||
#!/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
|
||||
```
|
||||
|
||||
```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.
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
# Deployment Plan
|
||||
|
||||
**Date**: 2026-07-28
|
||||
**Decided at**: Deployment Setup, per `deployment-setup-plan.md` (Q1 = A, Q2 = A)
|
||||
|
||||
## Method
|
||||
|
||||
Gitea Actions CI/CD, already built in Construction:
|
||||
- `.gitea/workflows/continuous_integration.yaml` (U5) — six blocking gates, then a per-environment
|
||||
`dotnet publish`, then invokes the deploy workflow
|
||||
- `.gitea/workflows/deploy-scp.yaml` (U6) — reusable `workflow_call` workflow: backup (production
|
||||
only) → upload → link persistent website → atomic switch → restart → health check → prune
|
||||
|
||||
This stage does not change that mechanism — it documents the operational side Construction
|
||||
deliberately left out: host setup, real domains, the database backup script, and the rollback
|
||||
procedure.
|
||||
|
||||
## Environments
|
||||
|
||||
| Environment | Host | Domain | Trigger |
|
||||
|---|---|---|---|
|
||||
| Test | Raspberry Pi (`linux-arm64`) | `test.slpsoftware.nl` | Automatic on push to `master`, or any `workflow_dispatch` |
|
||||
| Production | Same Pi, separate directory | `slpsoftware.nl` | Only `workflow_dispatch` with `deploy_production = true` |
|
||||
|
||||
Same physical host for both (per `infrastructure-design.md` § 1) — separated by directory,
|
||||
`systemd --user` unit, and local port, never by anything the workflow manages directly.
|
||||
|
||||
## Rationale for What's Documented Here vs. Already Decided
|
||||
|
||||
| Already decided (Construction) | Documented here (Operations) |
|
||||
|---|---|
|
||||
| Atomic switch mechanism, retention count, transport | Real domains, real ports |
|
||||
| `deploy-scp.yaml`'s step sequence | The one-time host setup that sequence assumes already exists |
|
||||
| That a DB backup step runs before production deploys | The actual backup script's content |
|
||||
| That `transport` is reserved for FTPS (D-02, NFR-09) | What actually changes operationally when that day comes |
|
||||
|
||||
## Automation Level
|
||||
|
||||
Fully automated for test (push to `master` → live on `test.slpsoftware.nl` with no human step).
|
||||
Production requires an explicit, manual `workflow_dispatch` run with the flag checked — this manual
|
||||
trigger **is** the approval gate (D-09); no separate approval workflow step exists or is needed.
|
||||
|
||||
## Rollback Strategy
|
||||
|
||||
Summarized here; full procedure in `rollback-plan.md`. Two releases are always retained
|
||||
(`infrastructure-design.md` § 3), so rolling back is a re-point-and-restart, never a rebuild, for the
|
||||
most recent deploy. Migrations are required to be forward-compatible and non-destructive (D-26), so
|
||||
redeploying an older commit remains valid further back than just one release.
|
||||
|
||||
## Secrets and Configuration
|
||||
|
||||
Per D-16, runtime configuration lives in **host** environment variables that the workflow never
|
||||
writes — see `deployment-instructions.md` § "Runtime Configuration" for the full list and where it
|
||||
lives on the host (a single `shared/env` file per environment, outside the swapped release
|
||||
directory, `chmod 600`). Gitea Actions secrets are limited to what the workflow itself needs to
|
||||
reach the host: SSH credentials (`PI_MAIN_*`). No application secret is ever a Gitea secret or
|
||||
variable.
|
||||
@@ -0,0 +1,65 @@
|
||||
# Rollback Plan
|
||||
|
||||
**Mechanism** (D-26, `infrastructure-design.md` § 3–4): two releases are always retained —
|
||||
`current` plus exactly one previous. Pruning only ever runs after a **passing** health check, so a
|
||||
failed deploy never leaves fewer than two releases on disk.
|
||||
|
||||
## When to Roll Back
|
||||
|
||||
- The deploy workflow's health check failed (job already shows red; `current` was left pointing at
|
||||
the new, unhealthy release — no automatic rollback, per `infrastructure-design.md` § 4)
|
||||
- The deploy succeeded and passed its health check, but a defect surfaces afterward that only shows
|
||||
up under real traffic
|
||||
|
||||
## Fast Rollback (Previous Release Still on Disk) — the Common Case
|
||||
|
||||
No rebuild needed. Over SSH, on the Pi:
|
||||
|
||||
```bash
|
||||
cd ~/apps/slpmodularcms-<env>/releases
|
||||
ls -1t # confirm which directory is the previous, working release
|
||||
ln -sfn ~/apps/slpmodularcms-<env>/releases/<previous-timestamp> ~/apps/slpmodularcms-<env>/current
|
||||
systemctl --user restart slpmodularcms-<env>.service
|
||||
curl -f https://<env-domain>/health # confirm the rollback itself is healthy
|
||||
```
|
||||
|
||||
This is the same atomic-switch primitive the deploy workflow itself uses — pointing it backward
|
||||
instead of forward. `wwwroot/web/` is untouched either way, since it was never part of the switched
|
||||
directory to begin with (FR-08).
|
||||
|
||||
## Rebuild-and-Redeploy Rollback (Older Than One Release Back)
|
||||
|
||||
If the defect predates the retained previous release, redeploy an **earlier commit** through the
|
||||
normal pipeline:
|
||||
|
||||
1. `git revert` or check out the last-known-good commit on a branch
|
||||
2. Push to `master` (test) or run `workflow_dispatch` with `deploy_production: true` (production) —
|
||||
the same gates and deploy sequence run as any other deploy
|
||||
3. This only works because migrations are required to be forward-compatible and non-destructive
|
||||
(D-26) — redeploying an older commit's code against a database that has since had newer
|
||||
migrations applied must not break. If a migration since the target commit **was** destructive,
|
||||
this path is not safe and the database backup (§ below) is the actual recovery route instead
|
||||
|
||||
## Database Rollback
|
||||
|
||||
A backup is taken before every **production** deploy (`operations/deployment/deployment-instructions.md`
|
||||
§ 4, invoked by `deploy-scp.yaml` when `run_db_backup: true`). To restore:
|
||||
|
||||
```bash
|
||||
sqlcmd -S <server> -U <user> -P <password> -C -Q \
|
||||
"RESTORE DATABASE [SlpModularCmsProduction] FROM DISK = N'<path-to-backup>.bak' WITH REPLACE"
|
||||
```
|
||||
|
||||
Restoring a database backup and rolling back the application release are **independent actions** —
|
||||
decide based on the actual failure whether one, the other, or both are needed. Rolling back the app
|
||||
without restoring the database is usually sufficient (migrations are non-destructive by design);
|
||||
restoring the database without rolling back the app should be rare and deliberate.
|
||||
|
||||
## What Is Never Part of a Rollback
|
||||
|
||||
- `wwwroot/web/` (the customer's website) — structurally outside every release directory; no
|
||||
rollback action should ever touch it
|
||||
- The Data Protection key ring — lives in the database, not the release directory; rolling back the
|
||||
app release does not affect it
|
||||
- Gitea Actions variables/secrets — these describe the *target*, not a specific release; nothing
|
||||
about them changes during a rollback
|
||||
Reference in New Issue
Block a user