Deployment & operations
Two GitHub Actions workflows, one image registry, one webhook. CI owns the image and the version bump; the platform (pmcluster, the deploy webhook's target) owns the runtime.
CI — build, publish, bump
.github/workflows/build-publish.yml runs on every push to main that does
not touch deploy/**:
- checkout with
GH_PAT(also configures git so the privatenextrum-sy/nx-commonandnx-registermodules fetch over HTTPS); go vet, then a secret lint that fails the build if a literalSESSION_SECRETorADMIN_PASSWORDever appears indeploy/;go test ./... -count=1andgovulncheck ./...;- docker buildx → GHCR, tag
v$(date +%Y%m%d)-$(sha7)pluslatest; the Go stage fetches the private modules through a BuildKit secret (--mount=type=secret,id=gh_pat), so the PAT never lands in a layer, the cache or the image history; build args stampVersion/CommitSHA/BuildTimeinto the binary; - bump the
version:line of everydeploy/*.yamland push the commit back tomain— which, because it touchesdeploy/**, triggers the deploy workflow by itself.
The image (Dockerfile) is three stages: a Node stage that compiles the
Tailwind stylesheet and checks it exists (no check:assets failure ships), a
CGO_ENABLED=0 Go stage, and an alpine:3.21 runtime carrying only the
binary, ./web, ca-certificates and tzdata. No entrypoint — the server
runs directly, as root, because the named data volume is root-owned and there
is no init step to chown it; an operator who wants a non-root UID owns the
volume first and pins user: in the manifest. The Docker HEALTHCHECK and
the platform's swarm healthcheck both probe GET /health.
Deploy — the webhook
.github/workflows/deploy.yml runs on pushes to main that touch deploy/**,
as a two-leg matrix (fail-fast: false). Each leg: substitute the two secret
placeholders into the manifest, read app: and version:, JSON-encode the
manifest body, sign timestamp+body with PMCLUSTER_WEBHOOK_SECRET
(OpenSSL HMAC-SHA256, sha256= signature header), and POST it to
PMCLUSTER_WEBHOOK_URL. Anything but 200/201/202 fails the leg.
The two tenants
| Tenant | Manifest | Volume | State |
|---|---|---|---|
test-lms |
deploy/test-lms.yaml → test-lms.nextrum-sy.com |
lms-test-data:/app/data |
seeded demo: students, users, an acceptance-run history (see nx-register) |
test2-lms |
deploy/test2-lms.yaml → test2-lms.nextrum-sy.com |
lms-test2-data:/app/data |
empty: the server creates the schema, the built-in roles and the first admin on first boot |
Both run one replica of one service on port 8080, GIN_MODE=release,
LANGS=en,ar, the four currencies, TRUSTED_PROXIES="" (trust none — the
swarm ingress CIDR is not configured yet, per the manifest comment), OTLP to
otel-collector:4317, and resource caps measured from real usage
(reserve 0.05 CPU / 64 M, limit 0.5 CPU / 160 M — a runaway dies in its own
cgroup, not in the node's memory).
Resetting a tenant: a fresh database self-seeds on first boot (schema from
the embedded migrations, roles from Go, admin from ADMIN_USER/ADMIN_PASSWORD
with a forced password change). So the empty tenant is reset by removing its
data volume and letting the next boot rebuild it. For test-lms, the demo
data was restored once by a one-off copy with users remapped by role code
(recorded in nx-register); no repeatable
demo-reset procedure exists in the repo — see Not determined below.
Secrets
| Secret | Where it lives | Why it matters |
|---|---|---|
SESSION_SECRET |
GitHub repo secret → manifest placeholder → env | required at boot; signs the session cookie; changing it invalidates every session |
ADMIN_PASSWORD |
same path | required at boot; hashes the first-boot admin (only created when absent — rotating it on a live tenant does nothing, use the in-app password change) |
GH_PAT |
CI only | fetches the private modules and pushes the bump commit (repo + workflow scopes) |
PMCLUSTER_WEBHOOK_URL / _SECRET |
CI only | authenticates the deploy webhook (timestamp + HMAC signature) |
Backups
Backups are the platform's, not the image's. The old in-container
sqlite3 .backup loop was removed: pmcluster already runs
offen/docker-volume-backup per volume with its own retention, offsite store
and restore path, and two backup systems over one SQLite volume meant two
retentions, two restore procedures and a snapshot nobody could find. The image
therefore carries no /backups volume, no BACKUP_* variables and no
sqlite3 CLI; restore is the platform's volume restore (pmcluster UI → stack
→ backups → restore). For a one-off debug copy, take the volume's lms.db
together with its -wal/-shm siblings or the snapshot is stale.
Not determined
- The pmcluster-side runtime shape (task definitions, the exact volume-backup retention) is platform state, not in this repo.
- No scripted procedure for rebuilding the
test-lmsdemo dataset exists in the repo; only the one-off restore recorded in the register plugin's log.
Rendered from simple-lms/deployment.md · OKF bundle · regenerate with npm run render