type
Operations
title
Deployment & operations
description
CI that builds and bumps the image, the HMAC-signed deploy webhook, the two swarm tenants, the bind-mounted data dir, backups, and the two secrets.
tags
deployment, ci, docker, swarm, pmcluster, backups
status
stable
stale_after
Fri Apr 30 2027 02:00:00 GMT+0200 (Central European Summer Time)
generated
techwriter/mimo-v2.6-flash · Sat Oct 31 2026 11:02:38 GMT+0100 (Central European Standard Time)
sources
build → .github/workflows/build-publish.yml; deploy → .github/workflows/deploy.yml; manifest → deploy/test-lms.yaml; manifest2 → deploy/test2-lms.yaml; dockerfile → Dockerfile; readme → README.md

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.

flowchart LR push["push to main<br/>(paths-ignore deploy/**)"] ci["build-publish.yml<br/>go vet · deploy/ secret-lint ·<br/>go test -count=1 · govulncheck"] img["ghcr.io/nextrum-sy/simple-lms<br/>:vYYYYMMDD-sha7 and :latest<br/>(BuildKit secret fetches nx-common/nx-register)"] bump["sed the version line in deploy/*.yaml<br/>commit 'bump: deploy manifests → TAG' + push"] dpush["push to main touching deploy/**"] dep["deploy.yml — matrix: test-lms · test2-lms"] inject["inject SESSION_SECRET / ADMIN_PASSWORD<br/>from repo secrets into __placeholders__"] hook["HMAC-SHA256 signed POST → PMCLUSTER_WEBHOOK_URL<br/>(X-Pmcluster-Timestamp + Signature; 200/201/202 required)"] swarm["per tenant: one swarm service 'web'<br/>image + named volume + host + healthcheck"] push --> ci --> img ci --> bump dpush --> dep --> inject --> hook --> swarm

CI — build, publish, bump

.github/workflows/build-publish.yml runs on every push to main that does not touch deploy/**:

  1. checkout with GH_PAT (also configures git so the private nextrum-sy/nx-common and nx-register modules fetch over HTTPS);
  2. go vet, then a secret lint that fails the build if a literal SESSION_SECRET or ADMIN_PASSWORD ever appears in deploy/;
  3. go test ./... -count=1 and govulncheck ./...;
  4. docker buildx → GHCR, tag v$(date +%Y%m%d)-$(sha7) plus latest; 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 stamp Version/CommitSHA/ BuildTime into the binary;
  5. bump the version: line of every deploy/*.yaml and push the commit back to main — which, because it touches deploy/**, 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

Rendered from simple-lms/deployment.md · OKF bundle · regenerate with npm run render