LQMS Server Deployment (single Linux host, public HTTPS)¶
How to run LQMS on a real server: one small Linux box, Docker Compose, a public domain, automatic TLS. This is the topology guide — it says what to expose, what to keep private, which secrets to generate and in what order to bring things up.
Two companion documents, both still authoritative for what they cover:
| Document | Covers |
|---|---|
docs/operations/deployment.md |
the configuration reference — every quarkus.* / lqms.* key, the BYPASSRLS requirement, IdP integration, upgrade path, bootstrap flow. Read §4 (database roles) and §5 (configuration) alongside this file. |
deploy/README.md |
the localhost demo stack — build prerequisites, the login theme, SBOM, backup/restore scripts. |
The files this guide drives:
deploy/docker-compose.yml # the base stack (unchanged; never used alone on a server)
deploy/docker-compose.server.yml # the server OVERRIDE — pull-based app, Caddy, TLS, no public ports
deploy/Caddyfile # the reverse proxy: routes, HSTS, admin-console block
deploy/server.env.example # every secret to generate + LQMS_VERSION (the release to run)
deploy/lqms-realm.json # the realm; every credential in it is env-substitutable
.github/workflows/ci.yml # the `publish-image` job — the ONLY producer of the image
The shape of a deployment, in one sentence: the server runs a published image pulled from
ghcr.io/nburri/lqms and a copy of deploy/ delivered by scp/rsync. It does not clone the
repository, does not hold a toolchain, and never builds. §3.4 covers the files, §4 the image.
1. Topology¶
flowchart LR
U([Browser]) -->|"443 · https://qms.example.com"| C
U -->|"443 · https://auth.qms.example.com"| C
C[caddy<br/>auto-TLS, only public ports 80/443]
C -->|"http, compose network"| A["app :8080<br/>(no published port)"]
C -->|"http, compose network<br/>/admin* and /realms/master* → 404"| K["keycloak :8080<br/>(loopback publish 8081 only)"]
A -->|"backchannel<br/>http://keycloak:8080"| K
A --> P[("postgres :5432<br/>never published")]
A --> V[["content volume<br/>(blobs, ADR-0001)"]]
S([Operator]) -.->|"ssh -L 8081:localhost:8081"| K
Exposure summary — this is the security claim of the deployment:
| Reachable from the internet | Reachable from the host only | Not published at all |
|---|---|---|
443/tcp, 443/udp (HTTP/3), 80/tcp (ACME + redirect to HTTPS) |
Keycloak on 127.0.0.1:8081 — the SSH-tunnel target for the admin console |
PostgreSQL, the app's HTTP port, Keycloak's management port (9000) |
The Keycloak admin console and master realm are not routed publicly: Caddy answers 404 for
/admin* and /realms/master* on auth.<domain> (a 404, not a 403 — no confirmation that an admin
console lives there). See §8 for how you reach it instead.
Why Keycloak sits on a subdomain, not a path¶
Decided and stated here so it is not re-litigated at 2 a.m.:
- Keycloak 26's
--hostnameaccepts a full URL, so a host root needs no path handling at all. Serving Keycloak under/authinstead requiresKC_HTTP_RELATIVE_PATH=/auth, and the image's own help says of--http-management-relative-path: "If not given, the value is inherited from HTTP options." The management port's health endpoint would silently move to/auth/health/readyand the compose healthcheck indocker-compose.yml(which probes/health/readyon:9000) would break. Verified against thequay.io/keycloak/keycloak:26.4image. - A separate origin keeps Keycloak's cookies (
AUTH_SESSION_ID,KEYCLOAK_IDENTITY) off the app origin. They are host-only cookies, so they never join the app'sCookieheader — which matters here because an oversizedCookieheader is exactly the HTTP 431 failure mode the app's OIDC settings were hardened against (application.properties, "finding #18"). - The cost is one extra DNS record and one extra certificate, both handled automatically by
Caddy. The
SameSite=laxsession posture is unaffected either way: the login redirect is a top-level GET navigation, which Lax cookies accompany.
2. Sizing¶
Everything runs on one host: two JVMs (app, Keycloak), PostgreSQL, and a small Go proxy.
| Resource | Minimum | Recommended | Notes |
|---|---|---|---|
| vCPU | 2 | 4 | 2 is enough at rest and for a handful of concurrent users; PDF/audit-pack rendering and the SPA build are the CPU spikes. |
| RAM | 4 GB | 8 GB | Two JVMs plus PostgreSQL. 4 GB works but leaves no headroom for a render burst or a restore rehearsal. The box does not build the image (§4), so the ~4 GB a Gradle + Angular build needs is not part of this budget. |
| Disk | 40 GB SSD | 80 GB SSD | OS + Docker images ≈ 6-8 GB; the database and the content blob store (ADR-0001) grow with the QMS; local backup archives default to a 35-day retention window (LQMS_BACKUP_RETENTION_DAYS) and are the largest variable. Keep ≥ 30 % free — PostgreSQL and the backup staging directory both need room. |
| Network | any | — | Ports 80/443 inbound; outbound HTTPS for ACME and image pulls. |
| OS | Debian 12+ / Ubuntu 22.04+ | — | Anything with a current Docker Engine and unattended-upgrades. |
| Docker | Engine 24+, Compose v2.24+ | — | v2.24 is the floor for the !reset / !override merge tags used by the server override. Check with docker compose version. |
Swap: give a 4 GB box at least 2 GB of swap so a JVM spike degrades instead of being OOM-killed.
3. Prerequisites¶
3.1 DNS — two records, before the first start¶
Caddy obtains certificates over the ACME HTTP-01 challenge, which requires both names to already resolve to this server on port 80.
| Record | Type | Value |
|---|---|---|
qms.example.com |
A (and AAAA if the host has IPv6) | the server's public IP |
auth.qms.example.com |
A (and AAAA) | the server's public IP |
auth.<domain> is derived from the single LQMS_DOMAIN variable — you never configure it
separately, but the record must exist. Verify before bringing the stack up:
If the AAAA record exists but the host has no working IPv6 route, ACME validation can fail over IPv6 while succeeding over IPv4. Publish AAAA only when IPv6 actually works.
3.2 Firewall¶
Allow 22 (SSH), 80, 443. Deny the rest inbound.
ufw default deny incoming
ufw default allow outgoing
ufw allow 22/tcp
ufw allow 80,443/tcp
ufw allow 443/udp # HTTP/3; harmless to omit
ufw enable
Docker publishes ports around ufw. Docker inserts its own NAT/forward rules, so a container port published on
0.0.0.0is reachable even if ufw denies it. This deployment publishes only 80/443 plus a loopback-bound 8081, so nothing leaks — but the moment you run something that publishes another port (notablyverify-backup.sh, §9.3), that port is public. Defence in depth, scoped to the external interface so container egress is unaffected:
3.3 Unattended OS updates¶
Security patches for the host — not for the container images (see §11 for those).
apt install unattended-upgrades apt-listchanges
dpkg-reconfigure -plow unattended-upgrades # answer "yes"
# optional but recommended: automatic reboot in a quiet window
# /etc/apt/apt.conf.d/50unattended-upgrades
# Unattended-Upgrade::Automatic-Reboot "true";
# Unattended-Upgrade::Automatic-Reboot-Time "04:30";
A reboot restarts the stack automatically: every service in the server override carries
restart: unless-stopped.
3.4 The deploy files on the server — delivered by scp, not by git¶
The server does not get a clone. It gets a copy of deploy/, and nothing else: no sources, no
Gradle wrapper, no .git. That is the whole point of the pull-based image (§4) — the box holds
configuration and data, never a build.
The reference installation (lqms.ch) runs the stack as an unprivileged user, lqms-ops, and
keeps the files in that user's home. /home/lqms-ops/lqms/deploy is therefore the path used
throughout this guide, and the same user owns the registry credential (§4.2) and the backup crontab
(§9.1). From your checkout:
--delete hazard (found live, 2026-08-23): without the
*.bak-*/*.log*exclusions below, this sync would delete the server-sideserver.env.bak-*safety copies, script.bak-*preservation files, andbackup-verification.log*— operational evidence, not repo files. Also learned the hard way the same day: deploy-script FIXES only reach the box when this sync actually runs — restore.sh and verify-backup.sh sat drifted (pre-fix, vacuous-pass era) on the server for a week. Run the sync as part of every release's server session. (And a second lesson from the v0.9.19 run: the first version of THIS note was spliced inside the code fence by a careless edit, so the printed command did not paste-run. The fence below is the reconstructed command the run actually used, verified live with zero deletions.)
rsync -av --delete \
--exclude 'server.env.bak-*' --exclude '*.bak-*' --exclude '*.log' --exclude '*.log.*' \
--exclude 'server.env' \
--exclude 'lqms-compose' \
--exclude 'seed-local.d/' \
--exclude 'backups/' \
--exclude 'backup-verification.log' \
--exclude 'smoke.sh' --exclude 'nightly-smoke.sh' \
--exclude '.DS_Store' \
deploy/ lqms-ops@server:/home/lqms-ops/lqms/deploy/
Read the exclusions — each one is deliberate:
| Excluded | Why |
|---|---|
server.env |
it holds every secret of the deployment. It is created on the server (§5) and never travels from a laptop as a side effect of an update. |
lqms-compose |
the wrapper (§6) is created on the server and is not in the repo — without this exclusion, --delete removes it (it did, 2026-08-16; the v0.9.12 deploy recreated it by hand). |
seed-local.d/ |
local scratch. Nothing about it belongs on a server. |
backups/, backup-verification.log |
these are the server's own output. Pushing your laptop's copies over them would destroy the archive set. |
smoke.sh, nightly-smoke.sh |
the two scripts that are dangerous by construction on a server. smoke.sh ends in docker compose -p lqms down -v, which deletes the database, the content blobs and the Keycloak volume; nightly-smoke.sh builds at HEAD, which a box with no repository cannot do anyway. The safest place for both is not on the box (§7). |
seed-demo.shandDEMO.mdare deliberately NOT excluded — they are on the box, and that is what the reference installation looks like. Presence is not permission. Unlike the two above,seed-demo.shcannot quietly succeed here: it authenticates with the demo client secret, the demo Keycloak master password and demo user passwords, all of which a server has replaced, so it fails early rather than half-way (§7). Excluding it would buy a false sense of safety and hide the rule that actually protects the installation — never run it against a server stack — which holds whether the file is present or not.DEMO.mdis the localhost walk-through that describes it; on a server it is inert text.
--excludealso protects a path from--delete, so the server'sserver.envandbackups/survive every subsequent sync. Run the same command to update the deploy files; it is idempotent.
What legitimately lands on the server: both compose files, Caddyfile, lqms-realm.json,
postgres-init.sql, keycloak-theme/, lqms-init.sh (the first-deploy interview, §5), backup.sh,
restore.sh, verify-backup.sh, server.env.example — plus seed-demo.sh and DEMO.md as
described above. Dockerfile rides along harmlessly — its build context (the repository root) does
not exist there, which is exactly the intended dead end; README.md and the remaining small helpers
are inert.
Ownership: the deploy directory belongs to the user that runs the stack —
chown -R lqms-ops:lqms-ops /home/lqms-ops/lqms/deploy on the reference installation. Keep that the
same user that holds the docker login (§4.2) and the backup crontab (§9.1); splitting them is how
you get a pull that works by hand and fails from cron. server.env stays chmod 600.
4. The image — published by CI, pulled by the server¶
The server pulls. It never builds. The image is produced exactly once, by the publish-image
job in .github/workflows/ci.yml, and pushed to the GitHub
Container Registry:
| Registry path | ghcr.io/nburri/lqms |
| Published on | a release tag push (v*), and on manual workflow_dispatch |
| Tags written | :vX.Y.Z for the release, plus :latest moved onto it. A dispatch that does not name a tag publishes :<ref>-<sha> and does not move :latest |
| Platform | linux/amd64 only — built natively on the runner |
| Visibility | private (see below) |
| Build stamps | GIT_SHA and PRODUCT_VERSION (git describe, ADR-0090) baked in; they surface at /api/version |
This retires the architecture trap that used to lead this section. CI builds on an amd64 runner, so
exec format error on an x86_64 VPS cannot happen from a laptop cross-build any more (§13.4 keeps the
symptom, for the case where somebody builds by hand anyway). arm64 is deliberately not published —
the workflow comments carry the reason and the one-line change if a target ever needs it.
4.1 The package is private — confirm it once¶
A container package published to ghcr.io is private by default, and its visibility is a property
of the package, not of the repository: the package inherits the linked repository's access
permissions, but not its visibility, and a later change to the repository does not reliably flip
it. nburri/lqms is a private repository today, which is consistent — but "consistent by accident"
is not a control.
So do this once, after the first publish, and record it:
https://github.com/nburri/lqms/pkgs/container/lqms → the landing page must say Private. To change it: on the package landing page, right-hand side → Package settings → bottom of the page, Danger Zone → Change visibility. (Also reachable from your profile → Packages →
lqms→ Package settings.)
If the repository is ever made public, re-check this page. A public LQMS image is a supply-chain and licensing decision, not a side effect.
4.2 The server's registry credential — a classic PAT with read:packages¶
Pulling a private package needs an authenticated Docker client. GitHub Packages supports only a
personal access token (classic) for the container registry — a fine-grained token does not
authenticate to ghcr.io. Verified against GitHub's own "Working with the Container registry"
documentation; do not spend an evening on a fine-grained token that will keep returning denied.
Create the token (once, on your own account):
- GitHub → avatar → Settings → Developer settings → Personal access tokens → Tokens (classic) → Generate new token (classic).
- Note:
lqms server pull(name it after the box it lives on). - Expiration: pick a real date and put it in your calendar. An expired token does not take the
site down — the containers keep running — but the next
pullfails withdenied, usually in the middle of an upgrade. - Scopes: tick
read:packagesand nothing else. Notwrite:packages, notrepo.
Use it on the server:
# on the server, as the user that runs the stack — `lqms-ops` on the reference installation, the
# same user that owns /home/lqms-ops/lqms/deploy (§3.4) and the backup crontab (§9.1)
echo '<the-token>' | docker login ghcr.io -u nburri --password-stdin
# -> Login Succeeded
Then prove the credential actually pulls, before you need it:
docker pull ghcr.io/nburri/lqms:v0.9.0
docker image inspect ghcr.io/nburri/lqms:v0.9.0 --format '{{.Architecture}}' # -> amd64
Three things to know about that credential:
- It is stored, not encrypted.
docker loginwrites the token base64-encoded into~/.docker/config.jsonof the user that ran it.chmod 600 ~/.docker/config.json, and remember that the file is per user — a stack run aslqms-opsreads/home/lqms-ops/.docker/config.json, so adocker loginperformed as root or as your own account does nothing for it. This is the usual cause of apullthat works when you type it and fails from cron.docker logout ghcr.ioremoves it. - The scope is account-wide. A classic PAT's
read:packagesgrants read to every package the account can read; GHCR offers no narrower token. On a single-owner account that is the practical minimum. When this stops being a single-owner setup, the standard hardening is a dedicated machine account with read access to this repository only, holding its own classic PAT. - The running stack does not need it. It is required to
pull— first deploy, upgrades, and any restart that has to re-fetch a pruned image. A reboot of an unchanged stack does not pull (restart: unless-stoppedrestarts existing containers).
4.3 Before you cut the release tag: the deploy-runtime render check¶
The runtime stage is eclipse-temurin:25-jre-noble and runs as a non-root user. The deploy JVM
(Temurin 25) differs from CI's JDK 21, and no CI job runs the deploy image — publish-image
builds it, nothing exercises it. The PDF/audit-pack render path on that runtime is covered only by
deploy/nightly-smoke.sh (the FWK005 guard). Run it on your build machine, before tagging a
release, never on the server.
4.4 If you must build it yourself¶
Kept as a dead end on purpose: the supported path is pull. If a registry outage or an air-gapped
target forces a local build, the mechanics are in deploy/README.md
(prerequisites: pre-generated jOOQ sources via ./gradlew :backend:persistence:generateJooq, plus
GIT_SHA/PRODUCT_VERSION as build args) — cross-build with
docker buildx build --platform linux/amd64 -f deploy/Dockerfile -t ghcr.io/nburri/lqms:<tag> .,
then docker save … | ssh server 'docker load' under the same tag LQMS_VERSION names. Under
QEMU on an Apple Silicon Mac that build is slow and memory-hungry; on the server itself it needs the
~4 GB of free RAM the sizing table no longer budgets for.
5. Secrets¶
Every credential in the demo stack is a well-known default. The server override refuses to start
until each one is replaced: the variables are declared ${VAR:?…}, so docker compose fails with
the name of the missing variable rather than silently falling back.
The first-deploy interview writes the whole file for you — it asks for the domain, the release tag, the organization's name and the admin identity, verifies both DNS records before anything can hit the Caddy HTTP-01 gate, and generates every secret in place (never typed, never echoed):
The manual path remains, and fills the same rows:
cd /home/lqms-ops/lqms/deploy
cp server.env.example server.env
chmod 600 server.env
$EDITOR server.env # generate each value with the command shown in the file
5.1 Inventory — what must be regenerated¶
| Variable | Replaces (demo default) | Where it lands |
|---|---|---|
LQMS_POSTGRES_PASSWORD |
postgres / postgres |
PostgreSQL superuser |
LQMS_MIGRATION_PASSWORD |
lqms_migration / lqms_migration |
the lqms_migration DB role — created by postgres-init.sql at first boot and used as QUARKUS_DATASOURCE_PASSWORD. One variable, so the two cannot drift |
LQMS_KC_ADMIN_PASSWORD |
admin / admin |
Keycloak master-realm admin |
LQMS_OIDC_CLIENT_SECRET |
lqms-app-secret |
the confidential lqms-app client — in the realm import and in the app, from one variable |
LQMS_KC_ADMIN_API_SECRET |
lqms-admin-api-secret |
the confidential lqms-admin-api service-account client (§5.3) — again in the realm import and in the app, from one variable |
LQMS_ADMIN_PASSWORD |
demo / demo |
the first LQMS administrator's realm password |
LQMS_ADMIN_USERNAME / _EMAIL / _FIRST_NAME / _LAST_NAME |
the demo user's identity |
that same user — a real person instead of "Demo Administrator" |
LQMS_ORG_NAME |
the placeholder tenant "Operating Organization" | the name the first boot founds your organization under (register 2026-08-06 #34). Not a secret — the interview asks for it. First bootstrap only; an installation that already booted renames through the governed API instead (§6.2) |
There is no row for demo story users, and that is the point: the realm template carries no demo
people at all (feedback register 2026-08-06 #24, decided 2026-08-09). anna.sommer, ben.keller,
carla.fischer and dora.winter belong to the localhost demo world and are created there by
deploy/seed-demo.sh; nothing imports them here, so there is nothing to disable, nothing to rotate
and no dormant account for an auditor to ask about.
Generate with openssl rand -hex 32 — 256 bits, and an alphabet with no shell metacharacters,
which matters because these values pass through a compose env file, a container environment and (for
the database role) a psql script.
Not secrets, but required: LQMS_DOMAIN, LQMS_ACME_EMAIL and LQMS_ORG_NAME (in the table
above because it, too, replaces a demo default).
5.2 How the realm gets them: ${VAR:default} substitution¶
Keycloak's realm import resolves ${VAR} and ${VAR:default} placeholders from the process
environment. Verified empirically on 26.4:
${VAR:default}→ the environment value when set, the default when not.deploy/lqms-realm.jsonuses this form throughout, with the demo values as defaults — so the localhost stack imports exactly as before, and only a server that sets the variables gets different content.$(env:VAR)and${env.VAR}are not substituted (they import as literal text).${VAR}with no default and no environment value imports the literal string${VAR}— no error, no warning. That is why every one of these variables is:?-required indocker-compose.server.yml: a typo becomes a start-up failure instead of a client whose secret is the eleven characters${LQMS_…}.- Quoted booleans (
"temporary": "${LQMS_ADMIN_PASSWORD_TEMPORARY:false}") are coerced correctly.
What the server sets, beyond the credentials: sslRequired=external (the demo realm ships none),
redirectUris=https://<domain>/* and webOrigins=https://<domain> — closing the demo realm's
wildcard redirect surface, which on a public confidential client is a real finding, not cosmetics.
The realm import only ever applies to a realm that does not exist yet. Changing
server.envafter the first successful boot changes nothing in Keycloak — use §10 to rotate.
5.3 Inviting people who have no account yet — mail, and the one credential that mints logins¶
This is optional configuration for an optional capability, and it is worth understanding before you decide whether you need it.
Without any of it, the product runs completely: documents, lifecycle, audit, the in-app notification inbox, and inviting a person who already has a login here (they answer in their invitations list). What you cannot do is invite an email address — the path a customer's first user arrives through, for somebody this installation has never seen. That one act refuses with a 409 naming these variables; nothing else changes.
With it, one invitation walks the whole way:
tenant admin invites alice@customer.example
│
├─► LQMS mails her: https://qms.example.com/invite/<token> (LQMS's mail server)
│ the token is single-use, expires in 14 days, and is stored
│ only as a SHA-256 — the database holds no usable link
│
├─► she opens it, presses "set up access"
│ LQMS creates her Keycloak account with NO credential at all
│ and asks Keycloak to invite her to set one
│
├─► KEYCLOAK mails her its own setup link (Keycloak's mail server)
│ UPDATE_PASSWORD + VERIFY_EMAIL, inside Keycloak's own pages
│
└─► she sets her password, verifies her address, logs in
→ LQMS binds her by verified email and her invitation completes
LQMS is never in the password path. It creates an account with no credentials array, and the
secret is created by the person inside Keycloak's own flow, over a token Keycloak minted. This is
why there are two mail senders for one mail server: the credential link is a Keycloak action
token, so only Keycloak can send it. docker-compose.server.yml feeds both halves from the same
LQMS_SMTP_* variables precisely so they cannot drift.
What to set in server.env (all optional; an empty LQMS_SMTP_HOST is "no mail"):
| Variable | Consumed by | Note |
|---|---|---|
LQMS_SMTP_HOST / _PORT |
both | empty host = no mail, which is the default posture |
LQMS_SMTP_USERNAME / _PASSWORD |
both | |
LQMS_SMTP_FROM / _FROM_NAME |
both | defaults to lqms@<LQMS_DOMAIN>, which most relays refuse — set a mailbox you own |
LQMS_SMTP_AUTH / _STARTTLS / _SSL |
Keycloak's half | booleans |
LQMS_SMTP_START_TLS |
the app's half | DISABLED / OPTIONAL / REQUIRED — the same decision in quarkus-mailer's spelling |
LQMS_KC_ADMIN_API_SECRET |
both | required (§5.1) — the service-account credential that mints accounts |
LQMS_NOTIFICATIONS_EMAIL_ENABLED |
the app | off even when mail works; turn it on after the relay is proven |
LQMS_APP_BASE_URL is derived from LQMS_DOMAIN in the compose file and needs no row. It is
configuration and never the request's Host header — a link is built in one request and
followed from a mail client in another, and trusting Host is exactly how password-reset poisoning
works.
Existing installations: the realm was imported once, so add the client by hand¶
smtpServer and the lqms-admin-api client are part of the realm template, and the realm import
only ever applies to a realm that does not exist yet (§5.2). A server that was deployed before
this feature therefore needs both added to its live realm. Through the tunnel (§8b), with kcadm
already configured:
K=/opt/keycloak/bin/kcadm.sh
C='./lqms-compose exec keycloak'
# 0. authenticate kcadm (once per shell)
$C $K config credentials --server http://localhost:8080 --realm master \
--user admin --password "$LQMS_KC_ADMIN_PASSWORD"
# 1. the realm's own mail server — Keycloak sends the credential link itself
$C $K update realms/lqms \
-s 'smtpServer.host=smtp.example.com' \
-s 'smtpServer.port=587' \
-s 'smtpServer.from=qms@example.com' \
-s 'smtpServer.fromDisplayName=LQMS' \
-s 'smtpServer.auth=true' \
-s 'smtpServer.starttls=true' \
-s 'smtpServer.user=<smtp user>' \
-s 'smtpServer.password=<smtp password>'
# 2. the service-account client LQMS administers users with
$C $K create clients -r lqms \
-s clientId=lqms-admin-api \
-s 'name=LQMS identity administration' \
-s enabled=true -s publicClient=false \
-s standardFlowEnabled=false -s directAccessGrantsEnabled=false \
-s serviceAccountsEnabled=true \
-s "secret=$LQMS_KC_ADMIN_API_SECRET"
# 3. its ONE permission: realm-management -> manage-users, and nothing else
CID=$($C $K get clients -r lqms -q clientId=lqms-admin-api --fields id --format csv --noquotes)
$C $K add-roles -r lqms --uusername "service-account-lqms-admin-api" \
--cclientid realm-management --rolename manage-users
Then verify, from the server, that the credential works and carries exactly that role:
# a service-account token (never paste this into a shared log)
./lqms-compose exec keycloak sh -lc 'curl -s -d grant_type=client_credentials \
-d client_id=lqms-admin-api -d client_secret=$LQMS_KC_ADMIN_API_SECRET \
http://localhost:8080/realms/lqms/protocol/openid-connect/token' | head -c 40
# -> {"access_token":"eyJ... (anything else = the client or its secret is wrong)
Finally, send one real invitation to an address you control and watch two messages arrive. One
message means the other half's mail configuration is missing: LQMS's invitation but no Keycloak
setup link is the realm's smtpServer; no invitation at all is the app's quarkus.mailer.*. The
app's own refusal ("this installation cannot send mail") means LQMS_SMTP_HOST never reached the
app container — check ./lqms-compose config rather than the file.
Existing installations: credential self-service (register 2026-08-06 #27)¶
Needed only on installs whose realm was imported before the credential-self-service change
(fresh imports carry all of it). The realm template now enables self-service password reset
("Forgot Password?" on the sign-in page — it mails through the same smtpServer as above, so make
sure mail works first), names the LQMS account theme for Keycloak's Account Console (which the
app's person menu deep-links as "Change password"), and sets the realm display name the reset mail
greets with ("your LQMS account", not "your Lqms account"). With kcadm authenticated as in step 0
above:
# 4. self-service credentials: reset flag + console theme + mail display name (one call)
$C $K update realms/lqms -s resetPasswordAllowed=true -s accountTheme=lqms -s displayName=LQMS
# 5. the administrator imported with the ORIGINAL realm template holds no realm roles at all —
# without default-roles-lqms (account: manage-account/view-profile) the Account Console answers
# 401 and renders "Something went wrong" for exactly that user. Users minted later through the
# admin API (the ADR-0108 invite path) get the default role automatically; only realm-file
# imports skip it. Substitute your LQMS_ADMIN_USERNAME:
$C $K add-roles -r lqms --uusername "$LQMS_ADMIN_USERNAME" --rolename default-roles-lqms
The account theme itself ships as files (deploy/keycloak-theme/lqms/account/, mounted read-only
by the compose file): make sure the deploy directory on the server is current (§3.4) and recreate
the Keycloak container so the mount picks the directory up — the theme, unlike the realm settings
above, needs no import and follows the files on every restart.
6. Bring-up¶
cd /home/lqms-ops/lqms/deploy
# 0. sanity: compose version + DNS
docker compose version # must be v2.24+
dig +short "$DOMAIN" "auth.$DOMAIN"
# 1. the registry credential (§4.2) — once per server, as the user that runs the stack
echo '<classic PAT with read:packages>' | docker login ghcr.io -u nburri --password-stdin
# 2. render the merged configuration and READ IT — no ports on app, 80/443 on caddy,
# 127.0.0.1:8081 on keycloak, nothing on postgres, and the app's `image:` naming the
# LQMS_VERSION you pinned (there must be no `build:` section at all)
docker compose --env-file server.env \
-f docker-compose.yml -f docker-compose.server.yml -p lqms config | less
# 3. pull the pinned release. Never `--build`: there is no build context on this box
docker compose --env-file server.env \
-f docker-compose.yml -f docker-compose.server.yml -p lqms pull
# 4. start
docker compose --env-file server.env \
-f docker-compose.yml -f docker-compose.server.yml -p lqms up -d
# 5. watch the first boot — certificates, realm import, migrations, bootstrap
docker compose -p lqms logs -f caddy keycloak app
If step 3 fails with denied or unauthorized, the credential is the problem, not the tag: re-read
§4.2 (classic token, read:packages, and the login must belong to the same user that runs
compose). If it fails with manifest unknown, LQMS_VERSION names a tag that was never published —
check https://github.com/nburri/lqms/pkgs/container/lqms.
Because the full command is long and every operation must use both files, define it once:
# /home/lqms-ops/lqms/deploy/lqms-compose (chmod +x) — or a shell alias
#!/bin/sh
exec docker compose --env-file /home/lqms-ops/lqms/deploy/server.env \
-f /home/lqms-ops/lqms/deploy/docker-compose.yml \
-f /home/lqms-ops/lqms/deploy/docker-compose.server.yml -p lqms "$@"
Running plain docker compose up -d in deploy/ (base file only) on a server would recreate the
containers with demo credentials and published ports. Use the wrapper.
What a healthy first boot looks like, in order:
- caddy —
certificate obtained successfullytwice (once per name). Failures here are DNS or port 80 reachability, not TLS. - keycloak —
Realm 'lqms' imported,Import finished successfully, thenKeycloak 26.4.x … started,Profile prod activated. - app — Flyway applying migrations, then
Deployment checks passed (fan-out DEFINER owner can bypass RLS.),System bootstrap complete for admin subject '11111111-…', then the Quarkus start line. docker compose -p lqms psshows all four serviceshealthy.
Then run the first-deploy checklist in §12 — it is short, and it covers exactly the things that cannot be verified without a real domain.
6.1 First content: wizard birth or demo seed¶
- Real installation → the wizard. Log in at
https://<domain>/as the administrator fromserver.env. The app bootstraps that subject as system administrator on every boot (ADR-0012), and your organization already exists — the first boot founded it underLQMS_ORG_NAME(register 2026-08-06 #34). The setup wizard takes it from there: the project, the scopes, the document types. Nothing else is required — an empty start is a supported state. - Evaluation installation → the demo corpus.
./seed-demo.shis on the box (§3.4 syncs it), but it authenticates with the demo client secret, the demo Keycloak master password and demo user passwords, all of which a server deployment has replaced — and its first act is to create four story accounts (anna.sommer,ben.keller,carla.fischer,dora.winter) in the realm, which is exactly what this installation is built not to have. Seed on the localhost stack, not on the server.
6.2 Existing installations: give the tenant its real name¶
An installation whose first boot predates LQMS_ORG_NAME (or ran with it unset) carries its
organization under the bootstrap placeholder "Operating Organization". Setting the variable now
changes nothing, and that is by design: the bootstrap resolves the tenant through the ORG scope's
immutable code and never renames — a tenant's name moves only through a governed, audited act
(MANDATOR_RENAMED in the GLOBAL trail), never as a configuration side effect of a boot.
The rename lives on the provisioning API (PUT /api/admin/provisioning/mandators/{id}/name,
MANAGE_MANDATORS-gated — the bootstrap administrator holds it). Once the admin UI grows the
affordance you can do this from the Scopes page; until then, two calls:
# A direct-grant token for the administrator — the same pattern as the §12 token check. server.env
# is a COMPOSE env file, not a shell script (values are unquoted and may contain spaces since
# LQMS_ORG_NAME), so read the rows out rather than sourcing it. If the admin password was changed
# at first login (LQMS_ADMIN_PASSWORD_TEMPORARY=true), use the current one, not server.env's.
cd /home/lqms-ops/lqms/deploy
LQMS_DOMAIN="$(sed -n 's/^LQMS_DOMAIN=//p' server.env)"
LQMS_OIDC_CLIENT_SECRET="$(sed -n 's/^LQMS_OIDC_CLIENT_SECRET=//p' server.env)"
LQMS_ADMIN_USERNAME="$(sed -n 's/^LQMS_ADMIN_USERNAME=//p' server.env)"
LQMS_ADMIN_PASSWORD="$(sed -n 's/^LQMS_ADMIN_PASSWORD=//p' server.env)"
TOKEN="$(curl -s "https://auth.$LQMS_DOMAIN/realms/lqms/protocol/openid-connect/token" \
-d grant_type=password -d client_id=lqms-app \
--data-urlencode "client_secret=$LQMS_OIDC_CLIENT_SECRET" \
--data-urlencode "username=$LQMS_ADMIN_USERNAME" \
--data-urlencode "password=$LQMS_ADMIN_PASSWORD" \
| sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p')"
# 1. the tenant's id — the internal organization in the provisioning directory
curl -s -H "Authorization: Bearer $TOKEN" "https://$LQMS_DOMAIN/api/admin/provisioning/mandators"
# -> [{"id":"<uuid>","name":"Operating Organization","internal":true, …}]
# 2. the governed rename — 204. MANDATOR_RENAMED lands in the GLOBAL audit trail with the
# before/after names, and the tenant's ORG anchor scope follows while it is still named
# after the tenant (a deliberately relabelled anchor stays as it is).
curl -s -X PUT -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"name":"Your Organization AG"}' \
"https://$LQMS_DOMAIN/api/admin/provisioning/mandators/<uuid>/name"
A name another organization already carries is a named 409 (two tenants must not share a name), a
blank one a 400 — nothing moves on any refusal, and replaying the stored name writes no event.
7. Do not run the demo scripts against a server stack¶
deploy/ ships scripts written for the localhost stack. Two of them are destructive here, because
they hardcode -f docker-compose.yml (base only) and act on project lqms. The §3.4 sync keeps
smoke.sh and nightly-smoke.sh off the box entirely; seed-demo.sh and DEMO.md do ride
along with every sync and are present on the reference installation. Presence is not permission:
none of these may be RUN against a server stack, and the table is what each one would do — whether it
is already there or somebody copies it over:
| Script | On a server |
|---|---|
smoke.sh |
DESTRUCTIVE — never run it on the server. Its cleanup step is docker compose -p lqms down -v, which deletes the database, the content blobs and the Keycloak volume. It also rebuilds the image. |
nightly-smoke.sh |
Builds at HEAD and runs an isolated project (lqms-nightly, ports 8180/8181). Not destructive to lqms, but it publishes ports (see the ufw note in §3.2) and competes for RAM. Run it on the build machine. |
seed-demo.sh |
Pollutes this installation. Beyond writing a synthetic mandator, scopes and documents into your database, its first step creates four demo accounts (anna.sommer, ben.keller, carla.fischer, dora.winter) in your Keycloak realm through the admin API — the very thing the realm template stopped shipping. It also assumes the demo secrets, so on a properly configured server it fails early rather than half-way; do not "fix" it by supplying real ones. |
restore.sh |
Legitimate, but it recreates the stack from the base file — see §9.4 for the correct sequence. |
verify-backup.sh |
Legitimate; prefer running it elsewhere (§9.3). |
backup.sh |
Safe. It only uses exec, stop, start and ps, so it never recreates a container with base-file configuration. |
7.1 Base-file-only scripts and the pulled image¶
There is a second, quieter consequence of -f docker-compose.yml on a pull-based server. The base
file names image: ${LQMS_IMAGE:-lqms:latest} and still carries a build: section (the server
override deletes it, but these scripts do not load the override). On this box lqms:latest does not
exist — so docker compose up would try to build it, against a repository root that is not
there.
Give those scripts the image explicitly, derived from the same variable the deployment is pinned to so the two cannot drift:
cd /home/lqms-ops/lqms/deploy
set -a; . ./server.env; set +a
export LQMS_IMAGE="ghcr.io/nburri/lqms:$LQMS_VERSION"
Do this before restore.sh (§9.4) or verify-backup.sh (§9.3). With the image present locally,
compose uses it and never reaches for the build context.
8. Keycloak admin access¶
The admin console is not on the public route. Two ways in, both requiring SSH to the box:
a) The console, through an SSH tunnel (for user administration, sessions, realm settings):
# from your workstation
ssh -L 8081:localhost:8081 you@server
# then open http://localhost:8081/admin/ in your browser
This works because Keycloak is published on 127.0.0.1:8081 (unreachable from the network) and
KC_HOSTNAME_ADMIN=http://localhost:8081 tells Keycloak to build admin-console URLs for that
address instead of the public one. The realm's sslRequired=external still permits plain HTTP here:
the request arrives from a private (Docker bridge) address, which "external" exempts.
b) kcadm inside the container (for scripted changes — no tunnel, no browser):
./lqms-compose exec keycloak /opt/keycloak/bin/kcadm.sh config credentials \
--server http://localhost:8080 --realm master --user admin --password "$LQMS_KC_ADMIN_PASSWORD"
./lqms-compose exec keycloak /opt/keycloak/bin/kcadm.sh get realms/lqms --fields sslRequired
Right after the first boot, do the two things Keycloak 26 expects of you:
- Create a named permanent admin in the master realm and delete the temporary bootstrap
adminuser (the log saysCreated temporary admin user with username admin). - Confirm the LQMS realm's users are what you expect: your administrator, and nobody else
(plus the hidden
service-account-lqms-admin-api, which the user list does not show). The realm template carries no demo people — ifanna.sommer,ben.keller,carla.fischerordora.winteris there, something ranseed-demo.shagainst this box (§7) and the accounts should be deleted.
Password hygiene: the SERVER deployment imports the realm with a password policy —
length(12) and notUsername and notEmail, injected by docker-compose.server.yml as
LQMS_PASSWORD_POLICY — which binds every password change from then on. Two consequences:
- The policy is deliberately not in the realm template itself: Keycloak validates plaintext
imported credentials against the policy (observed on 26.4 — a fresh import of the demo realm
with its demo passwords under
length(12)aborts startup withinvalidPasswordMinLengthMessage), so a policy baked into the template would brick every demo-stack boot. The demo realm boots policy-free (the placeholder's empty default); real deployments inject it, and their generated 64-hexserver.envpasswords always pass. A server whose realm predates the policy (imports happen on FIRST boot only) adds it once, in the tunneled console (Authentication → Policies → Password policy) or viakcadm:kcadm.sh update realms/lqms -s 'passwordPolicy=length(12) and notUsername and notEmail'. - When creating a user (e.g. the first real person after the neutral bootstrap admin), set their
initial password with Temporary = ON: Keycloak then forces a proper, policy-checked password
change at first login. The same lever exists for the bootstrap admin itself as
LQMS_ADMIN_PASSWORD_TEMPORARY=trueinserver.env— leave itfalseonly if you intend to run the scripted first-deploy token check before the browser login (§ checklist).
8b. Importing a corpus — the one-shot rule (read before the production import)¶
An arrival import (scripts/import/load_arrival.py, ADR-0113) can be undone with
--dissolve-finalized only while the imported scope is still a pure copy of the source.
The window closes at the first act by any user — one training acknowledgement, one comment,
one local release or revocation, one approval, one issued dossier, one disposal. From that moment
lqms_working_record_evidence (V106) refuses dissolution permanently, and the corpus cannot be
re-imported in place.
This is deliberate, and the reason matters more than the rule: once somebody works in an imported project, the source system stops being the authority of record. Those acknowledgements and comments exist only in LQMS. A re-import would silently destroy work that has no other home.
Practical consequences
| Situation | What to do |
|---|---|
| The import looks wrong, nobody has worked in it yet | --dissolve-finalized, fix the bundle, run again. Check --dissolve-preview first: it reads the same oracle the dissolution enforces, so it cannot disagree with what actually happens. |
| The import looks wrong, people have already worked in it | Dissolution is refused. Retire the corpus through the governed disposal lifecycle, which leaves a trail. (Dissolution means "this never happened"; disposal means "this happened and was retired" — for a real corpus the latter is what you want anyway.) |
| Test system, want a clean slate | Full reset + re-seed, not dissolve-then-import. A wipe gives a genuinely known starting state; dissolution leaves behind whatever it does not reach (global catalog rows, roles, users, and the trail of the dissolution itself). |
Therefore: inspect the arrived corpus before granting anyone access to work in it. The pre-flight check is cheap; the window is narrower than it looks.
The refusal, when it fires, names its evidence — e.g. "'MERIDIAN-QMS' has 5 acknowledgements, 3 comments. From the first record made in it an imported project is a working record system of this installation…" — so an operator learns why, not merely that.
8c. Exporting a corpus — the portability bundle (ADR-0125)¶
The mirror of §8b: a scope leaves this installation as the same bundle format an arrival import
consumes (docs/architecture/arrival-bundle-format.md, format 1.2). One endpoint, one audited
act — and one refusal: an ORGANIZATION anchor is not exportable (it holds no documents; export the
projects beneath it), while a PROJECT and the GLOBAL base library both are:
# EXPORT_PROJECTS at GLOBAL is the gate (V115 seeded it to every role holding IMPORT_PROJECTS).
# The call streams a zip; the EXPORTED event lands on the scope's trail with counts.
curl -sf -H "Authorization: Bearer $TOKEN" -o meridianqms-bundle.zip "https://lqms.ch/api/scopes/<scope-id>/portability-export"
What travels, what stays — read this before promising anyone a migration. The bundle carries
the released corpus and its provenance: all versions per the arrival floor (RELEASED and REVOKED
with releaser and date, an in-review draft as a draft), folders, attachments, native content
trees, within-scope trace links, and the catalog rows the corpus uses. It does not carry the
working-record layer: no audit trail, no approvals, no acknowledgements, no comments, no
periodic reviews, no tasks, no save-points. A migration LOSES these BY DESIGN — the source
instance, or its final backup, remains the archive of record for them (ADR-0125 §3). The bundle
says this itself: its README.txt and manifest carry the loss statement, and an arrival import
refuses a bundle that lacks it.
Completeness is structural, not asserted. The writer emits manifest.json last; if anything
fails mid-stream there is no manifest, no EXPORTED event, and a zip the importer refuses. An
export you can finalize an import from is complete; there is no "probably complete".
Landing it elsewhere: the target runs the ordinary §8b arrival (load_arrival.py) against
the bundle. Ids land verbatim — the bundle declares idsAuthoritative, the import batch
adopts every id including numbering gaps, and the target's counter is seeded past them — so every
doc: cross-reference keeps meaning what it meant. The target scope adopts the source's scope
code (ids embed it; the import refuses a divergent mapping). After landing, the one-shot rule of
§8b governs the copy exactly like any arrival.
9. Backups¶
The mechanism, the retention rules and the disposal interplay are ADR-0060 / ADR-0025 / ADR-0040 and
are documented in deploy/README.md and
07_deployment_view.md §7.1. What is server-specific:
9.1 The schedule — what the reference installation actually runs¶
A user crontab owned by lqms-ops (crontab -e as that user), not a root /etc/cron.d
drop-in. A user crontab has no user column, and the choice is not cosmetic: the stack, the deploy
directory (§3.4) and the docker login (§4.2) all belong to that same unprivileged user, and the
backup must run as the identity that can reach the Docker socket and the archive directory.
# crontab -e as lqms-ops — nightly full backup at 02:15
15 2 * * * cd /home/lqms-ops/lqms/deploy && LQMS_BACKUP_TARGET=/home/lqms-ops/backup ./backup.sh >> /home/lqms-ops/lqms-backup.log 2>&1
backup.sh briefly stops Keycloak (its H2 file must be unlocked for the realm export) and always
restarts it — expect a ~10 second login outage at 02:15. Blobs are copied first and re-hashed, then
pg_dump -Fc, then the realm export including users.
Two gaps in the reference installation — stated, not papered over. Both were verified on the box during the v0.9.15 deploy (2026-08-19). Both fixes are host-side and Nicolas's to apply; no code change is involved, and this note stays until they are.
- The off-box target is on the same disk.
LQMS_BACKUP_TARGET=/home/lqms-ops/backupsits on the host's own filesystem, so §9.2's rule — a directory on the same disk is not a backup — is currently violated by this very installation. The archives themselves are real and restorable; what is missing is the separation ADR-0018 requires, and the loss of the host would take every copy with it. Pending: repointLQMS_BACKUP_TARGETat one of §9.2's genuinely separate destinations (a second host overscp, or object storage viarclone), and run the exact cron command by hand once so the credential is proven from cron's environment, not from your shell.- The dead-man ping is documented but not armed.
LQMS_BACKUP_PING_URLis unset in the crontab above, so nothing fires on success and — the part that matters — nothing is watching for silence. A backup that quietly stops running would go unnoticed until somebody needed it. §9.2 and §14 both describe the ping as part of this deployment's monitoring; on this box it is not. Pending: register a check with a ping service, add the variable to the cron line, and configure that service to alarm on silence — otherwise the mechanism is decoration (§9.2).Until both are done, §12's check 16 does not pass on this installation, and the honest statement of the backup posture is: nightly archives are produced and verifiable, but they are not stored separately and their absence is not alarmed.
9.2 The off-server copy is the point¶
LQMS_BACKUP_TARGET is what makes the archive survive the loss of the server (ADR-0018 — "stored
separately from the primary system"). A directory on the same disk is not a backup. The rule
stands — and the reference installation currently breaks it: its target is /home/lqms-ops/backup,
on the host's own disk (§9.1, gap 1; host-side fix pending). Pick one:
| Target | LQMS_BACKUP_TARGET |
LQMS_BACKUP_TARGET_CMD |
|---|---|---|
| a mounted NAS / second volume | /mnt/backup/lqms |
cp (default) |
| another host over SSH | backup@nas:/srv/lqms/ |
scp |
| object storage (S3, B2, …) | remote:lqms-backups/ |
rclone copyto |
For scp/rclone, root's key or rclone config must exist on the server (cron runs without your
shell environment) — test the exact cron command once by hand.
Two more things to get right, because neither is automatic:
- Retention on the destination.
backup.shprunes only the local copies pastLQMS_BACKUP_RETENTION_DAYS(default 35). Pruning the off-server copies to the same window is yours to configure — and it is a data-protection statement, not housekeeping: disposed content persists in backups for at most the retention window (ADR-0025 / REQ-DPR-011). - The dead-man ping.
LQMS_BACKUP_PING_URLfires only on success. The absence of the ping is the alert — so the ping service must be configured to alarm on silence, or the whole mechanism is decoration. On the reference installation the variable is unset, so there is no ping and nothing watching for its silence (§9.1, gap 2; host-side fix pending).
9.3 Restore rehearsal — preferably not on this box¶
verify-backup.sh restores the newest archive into a scratch project alongside the running stack.
On a 2-4 vCPU / 8 GB server that means a second PostgreSQL, a second Keycloak and a second JVM
competing with production, plus two publicly-published ports (8090/8091 — Docker bypasses ufw, §3.2;
binding them to loopback does not work, because the script's own health check calls
http://localhost:${PORT}/api/version).
Run the rehearsal on another machine against the off-server copy instead. That is also the stronger test: it proves the copy that would survive the server is restorable. Weekly, e.g.
# crontab -e on the REHEARSAL machine — its own copy of deploy/, its own paths, not the server's
30 3 * * 0 cd ~/lqms/deploy && LQMS_BACKUP_DIR=/mnt/backup/lqms ./verify-backup.sh >> ~/lqms-verify.log 2>&1
If it must run on the server, add the DOCKER-USER drop rule from §3.2 first and accept the
contention.
What a rehearsal record proves — and why the pre-2026-08-19 rows do not¶
Each run appends one line to deploy/backup-verification.log (that file is the server's own output
and is gitignored; it is never synced from a laptop, §3.4):
- Every check is measured against something outside the restored copy — the archive's own
manifest.json(blobs.count,app.git_sha,database.sha256). A restored store is never graded against its own numbers. result=ABORTEDmeans the run died before it had a verdict (crash,set -e, Ctrl-C, docker daemon gone);exit=carries the process status. An aborted rehearsal proves nothing, and is never recorded as PASS.- A manifest whose
app.git_shais"unknown"FAILS the version check, deliberately: there is then nothing outside the restored copy to compare the running image against, and a comparison with no yardstick must not be recorded as proof. Every archive produced on the reference installation up to and including the v0.9.15 deploy carriesunknown, for a single reason —backup.shasked the app on the host'slocalhost:8080, a port the server override never publishes (ports: !reset []on the app service). Fixed on 2026-08-21: the script now asks the app container directly over the compose project, falls back toLQMS_APP_URL, and prints a loudWARNINGwhen neither answers. Archives written before that fix cannot prove which image they came from, so a rehearsal against one of them recordsversion_sha=FAILfor that reason alone; archives written after it carry the real short sha. - A per-check
SKIPmeans there was genuinely nothing to measure — no blobs to re-hash, nocontent_partrows to resolve, nodatabase.sha256claimed (in-app-produced archives statenull, ADR-0116). It is deliberately neither PASS nor FAIL: an empty instance must not be dressed up as proof.
Records dated before 2026-08-19 are not load-bearing evidence (fix-log decision 4, 2026-08-19).
The verifier that wrote them could pass vacuously: its blob check walked the restored store and
reported "clean over N blobs" without reading the manifest, so an EMPTY restore gave N=0, mismatch=0
and recorded result=PASS; its closure check passed when the content_part query returned nothing,
including when the query had FAILED; and its teardown captured the process exit code and never used
it, so a crashed or interrupted rehearsal still appended result=PASS. Reproduced on 2026-08-19
with identical input (a 28-blob archive restored into an empty store): old script result=PASS,
fixed script result=FAIL. Those restores may all have been perfectly good — the point is only
that the records do not PROVE it. The rows are kept verbatim, with a dated note appended in the
file itself; the first record written after that note is the first load-bearing rehearsal
evidence, and that is what IQ evidence item 7 should cite from now on.
9.4 Restoring onto the server — the exact sequence¶
restore.sh knows only the base compose file, so a plain restore recreates the stack in demo
configuration (published ports, start-dev Keycloak, demo DB-role password on the freshly
initialised volume). Sequence that ends in the right state:
cd /home/lqms-ops/lqms/deploy
set -a; . ./server.env; set +a # so the restore's compose interpolation sees your values
export LQMS_IMAGE="ghcr.io/nburri/lqms:$LQMS_VERSION" # §7.1 — or compose tries to BUILD
./restore.sh backups/lqms-backup-<ts>.zip --project lqms --force
# the recreated postgres volume ran postgres-init.sql WITHOUT the server file's
# LQMS_MIGRATION_PASSWORD, so put the real passwords back before the app connects
./lqms-compose exec -T -e NEWPW="$LQMS_MIGRATION_PASSWORD" postgres \
psql -U postgres -d lqms -v ON_ERROR_STOP=1 <<'SQL'
\set newpw `echo "$NEWPW"`
ALTER ROLE lqms_migration PASSWORD :'newpw';
SQL
# bring the stack back to the SERVER configuration (Caddy, no published app port, prod Keycloak)
./lqms-compose up -d
# the realm export carries NO client secret (ADR-0060) — re-apply it (§10)
Then work through the obligations restore.sh prints — in particular replaying disposals
recorded after the archive's timestamp (a restore resurrects disposed content, ADR-0040).
If --force fails, read the message before assuming the worst. Since 2026-08-19 the script
unpacks and validates the archive before it wipes anything: it requires manifest.json, a
non-empty db.dump whose table of contents pg_restore --list can read, a db.dump matching the
manifest's database.sha256 when the archive states one, and — when the manifest claims blobs — a
content half that is actually present. Any of these failing aborts with NOTHING was destroyed, and
the existing project is untouched: fix or re-fetch the archive and re-run. Only failures reported
after the --force: tearing down … line have cost you the old volumes. (A blob count that differs
from the manifest but is not zero only WARNS — partial content is still worth restoring in a
disaster — and the next rehearsal records that discrepancy as a failure.)
10. Rotating a secret after first boot¶
The realm import runs once; after that, credentials are changed in place.
OIDC client secret (also the fix after any restore):
cd /home/lqms-ops/lqms/deploy
set -a; . ./server.env; set +a
KC=(./lqms-compose exec -T keycloak /opt/keycloak/bin/kcadm.sh)
"${KC[@]}" config credentials --server http://localhost:8080 --realm master \
--user admin --password "$LQMS_KC_ADMIN_PASSWORD"
CID=$("${KC[@]}" get clients -r lqms -q clientId=lqms-app --fields id --format csv --noquotes)
"${KC[@]}" update "clients/$CID" -r lqms -s "secret=$LQMS_OIDC_CLIENT_SECRET"
./lqms-compose up -d app # the app picks up the new value from server.env
Database role password: update server.env, then (same form as §9.4 — the value travels in the
container environment, never in a command line that ps or your shell history can see):
set -a; . ./server.env; set +a
./lqms-compose exec -T -e NEWPW="$LQMS_MIGRATION_PASSWORD" postgres \
psql -U postgres -d lqms -v ON_ERROR_STOP=1 <<'SQL'
\set newpw `echo "$NEWPW"`
ALTER ROLE lqms_migration PASSWORD :'newpw';
SQL
./lqms-compose up -d app # so the app reconnects with the new value
The same shape rotates the superuser: ALTER ROLE postgres PASSWORD :'newpw' with
LQMS_POSTGRES_PASSWORD.
Keycloak admin / user passwords: through the admin console (§8) — KC_BOOTSTRAP_ADMIN_PASSWORD
only ever applies to the creation of the temporary admin user.
Anything you change in server.env needs ./lqms-compose up -d to reach a container; compose
recreates only the services whose configuration actually changed.
11. Keeping it current¶
| Layer | How | Cadence |
|---|---|---|
| OS packages | unattended-upgrades (§3.3) |
automatic |
Container images — postgres:18, quay.io/keycloak/keycloak:26.4, caddy:2-alpine |
./lqms-compose pull && ./lqms-compose up -d — pulls the newest patch of each pinned tag |
monthly, and on a published CVE |
| The LQMS image | edit LQMS_VERSION in server.env → ./lqms-compose pull app → ./lqms-compose up -d app (§11.1) |
per release |
Two cautions:
- Never move
postgres:18to a new major (postgres:19) by editing the tag: PostgreSQL major upgrades require a dump/restore of the data directory, and the container will refuse to start on a data directory from the previous major. Treat it as a planned migration. - Take a backup before any upgrade, and check
/api/versionafterwards to confirm what is actually running (§12).
11.1 Upgrading LQMS — the whole ritual¶
An upgrade is one edited line and two commands. There is no build, no transfer, no repository.
cd /home/lqms-ops/lqms/deploy
./backup.sh # first. Always. (§9)
$EDITOR server.env # LQMS_VERSION=v0.9.0 -> LQMS_VERSION=v0.10.0
./lqms-compose pull app # fetch exactly that tag from ghcr.io
./lqms-compose up -d app # recreate the app container on the new image
./lqms-compose logs -f app # watch Flyway migrate, then the Quarkus start line
curl -s https://<domain>/api/version # confirm productVersion is what you just pinned
Notes that matter:
- Migrations run on boot. The app applies its Flyway migrations at start-up, so the upgrade is the deploy. Expect the first start after an upgrade to take longer than a restart; watch the log rather than assuming.
- Only
appis touched.pull app/up -d appleave PostgreSQL, Keycloak and Caddy alone. A bare./lqms-compose pullalso fetches new patches of those three — a separate decision (the table above), not something to do accidentally during an LQMS upgrade. - Rolling back is a restore, not a re-pin. Migrations are forward-only: pointing
LQMS_VERSIONback at the previous tag leaves the old code facing a newer schema. If an upgrade must be undone, restore the pre-upgrade backup (§9.4) and pin the old version. This is why the backup step is first and not optional. latestexists and is still the wrong answer. It always names the newest release, which means the running version depends on when the container happened to be recreated. Pin the tag: it is the only wayserver.envanswers "what is running here" (ADR-0090 — a release, and an upgrade to it, is a deliberate act).- The upgrade needs the registry credential (§4.2). If
pullreturnsdenied, the token expired — the site is still up, the upgrade is simply blocked until you issue a new one.
12. First-deploy checklist¶
The configuration was verified at config level — the merged compose output (the app resolves to
the pinned ghcr.io image with no build: section and no published port, Keycloak stays loopback,
a missing LQMS_VERSION refuses to start), the base file rendering unchanged on its own, the
Caddyfile syntax, the realm substitution on both the demo and server paths, and the database role
password on both paths. Nothing about the registry itself — the first publish, the package's
visibility, a pull from this server — has been executed. That, and everything needing a real
domain, is below. Work through it on the first deploy; nothing here is assumed to work.
| # | Check | Command / expectation |
|---|---|---|
| 1 | The ghcr package is private (§4.1) | https://github.com/nburri/lqms/pkgs/container/lqms shows Private. Do this before anything else — it is the one check that cannot be undone after the fact. |
| 2 | The server can log in to the registry (§4.2) | echo '<classic PAT>' \| docker login ghcr.io -u nburri --password-stdin → Login Succeeded, as the user that runs compose |
| 3 | The pinned release actually pulls, and is amd64 | docker pull ghcr.io/nburri/lqms:$LQMS_VERSION then docker image inspect … --format '{{.Architecture}}' → amd64. denied = credential (§4.2); manifest unknown = the tag was never published |
| 4 | The merged config pulls and never builds | ./lqms-compose config → the app service has image: ghcr.io/nburri/lqms:<your tag>, no build: key, and no ports: |
| 5 | TLS handshake and certificate chain, both names | curl -sI https://qms.example.com/api/health and curl -sI https://auth.qms.example.com/realms/lqms → 200, no certificate warning. Caddy's log shows certificate obtained successfully for both. |
| 6 | HTTP → HTTPS redirect | curl -sI http://qms.example.com/ → 308 to https:// |
| 7 | Health endpoint | curl -s https://qms.example.com/api/health → {"status":"UP"} |
| 8 | Version stamp matches the pin | curl -s https://qms.example.com/api/version → productVersion equals LQMS_VERSION, and the sha is a real short sha (not unknown). This is the check that proves the box runs the release you think it does. |
| 9 | Admin console is not public | curl -so /dev/null -w '%{http_code}\n' https://auth.qms.example.com/admin/master/console/ → 404; likewise /realms/master/… |
| 10 | No stray published ports | ss -lntp \| grep -E ':(8080\|8081\|5432)' → 8081 bound to 127.0.0.1 only; nothing on 8080/5432 |
| 11 | The full OIDC round trip in a browser — the real proof | open https://qms.example.com/ → redirected to https://auth.qms.example.com/realms/lqms/… (branded login page) → sign in as your administrator → land back on the SPA, authenticated. |
| 12 | The session survives a reload | reload the app; you stay logged in (the BFF cookie is Secure and was accepted — a login loop here means §13.2) |
| 13 | Live updates work (SSE through the proxy) | open the app in two browsers; an action in one appears in the other. This exercises /api/events, deliberately excluded from Caddy's compression. |
| 14 | Logout | the Log out button ends both the app session and the Keycloak SSO session; you are asked for credentials again. |
| 15 | Reboot survival | reboot, then docker compose -p lqms ps → all services back and healthy. |
| 16 | A backup, end to end | run the cron command by hand; confirm the archive exists locally and at the off-server target, and that the dead-man ping registered. Also read the manifest: unzip -p backups/<archive>.zip manifest.json must show a real app.git_sha, not unknown — an unknown there means backup.sh could not reach the app and every later rehearsal will fail its version check (§9.3). This check does not currently pass on the reference installation — §9.1 names both open gaps. |
| 17 | A restore rehearsal on another machine (§9.3) | verify-backup.sh appends a PASS record. |
| 18 | The registry token's expiry is in a calendar (§4.2) | not a command — the failure mode is a blocked upgrade months from now, and nothing on the server will remind you. |
| 19 | Invite-by-email, end to end (§5.3) — only if you configured mail | invite an address you control into a mandator. Two messages must arrive: LQMS's invitation with a /invite/<token> link, then Keycloak's credential-setup link after you open it. Set the password, log in, and the invitation completes by itself. One message = one half's SMTP is missing (§5.3). |
| 20 | The service-account client is the only one that can administer users | in the tunnelled console, lqms-admin-api → Service account roles shows realm-management manage-users and nothing else; lqms-app has no service account at all. |
An uptime monitor closes the loop afterwards: poll https://<domain>/api/health every 5 minutes and
alert on anything that is not 200 with "status":"UP". The endpoint is deliberately unauthenticated
(ADR-0032). Add a certificate-expiry check on both names as a second signal — Caddy renews
automatically, but a renewal that fails silently is the classic 60-days-later outage.
13. Troubleshooting¶
13.1 Issuer mismatch — the classic breakage¶
Symptoms. The login page appears and accepts credentials, but the redirect back to the app ends
in a 401/500 or an immediate bounce to the login page again. The app log shows an OIDC token
verification failure naming two URLs that differ, e.g. expected
https://auth.qms.example.com/realms/lqms, got http://localhost:8081/realms/lqms. A bearer-token
call (/api/me) fails the same way with a token that Keycloak just issued.
Cause. Three values must name the same issuer:
| Value | Set by | Correct value |
|---|---|---|
what Keycloak stamps as iss |
KC_HOSTNAME |
https://auth.<domain> |
| what the app expects | QUARKUS_OIDC_TOKEN_ISSUER |
https://auth.<domain>/realms/lqms |
| where the app fetches discovery/JWKS | QUARKUS_OIDC_AUTH_SERVER_URL |
http://keycloak:8080/realms/lqms — stays internal, on purpose |
The third one looks wrong and is not: KC_HOSTNAME_BACKCHANNEL_DYNAMIC=true makes Keycloak serve a
discovery document whose browser-facing endpoints (issuer, authorization_endpoint,
end_session_endpoint) carry the public HTTPS name while token_endpoint and jwks_uri follow the
requesting host. Verified against 26.4 with exactly this configuration.
Fix. Do not "repair" it by pointing auth-server-url at the public URL. Check instead that
LQMS_DOMAIN is identical everywhere (./lqms-compose config | grep -E 'KC_HOSTNAME|TOKEN_ISSUER'),
that both DNS names resolve to this host, and that Keycloak has KC_PROXY_HEADERS=xforwarded so it
sees the original https scheme. Then:
# the app image ships no curl (its healthcheck uses bash /dev/tcp), so ask from a throwaway
# container ON THE COMPOSE NETWORK — i.e. from exactly where the app asks
docker run --rm --network lqms_default curlimages/curl:latest -s \
http://keycloak:8080/realms/lqms/.well-known/openid-configuration \
| tr ',' '\n' | grep -E '"issuer"|authorization_endpoint'
issuer must be https://auth.<domain>/realms/lqms, character for character.
If you changed the domain after the realm was imported, Keycloak keeps the stored realm; the
import does not re-run. Update KC_HOSTNAME via server.env and fix the client's redirect URIs
and web origins in the admin console (or with kcadm) — otherwise Keycloak rejects the redirect with
Invalid parameter: redirect_uri. Identities themselves survive a domain change: they bind on the
Keycloak subject, not the issuer (ADR-0063).
13.2 Login loop — the session cookie is dropped¶
Symptoms. Keycloak authenticates you, the browser returns to the app, and the app immediately redirects to the login page again, forever. No error is displayed.
Cause. The BFF session cookie is Secure and something in the chain is not HTTPS: the app built
an http:// redirect URI (missing X-Forwarded-Proto), or someone set LQMS_COOKIE_SECURE=false
from the LAN-demo instructions.
Fix. Confirm the app sees the proxy headers —
QUARKUS_HTTP_PROXY_PROXY_ADDRESS_FORWARDING, QUARKUS_HTTP_PROXY_ALLOW_X_FORWARDED and
QUARKUS_HTTP_PROXY_ENABLE_FORWARDED_HOST are all true in the merged config — and that
QUARKUS_OIDC_AUTHENTICATION_COOKIE_FORCE_SECURE is true. If a redirect URI still comes out as
http://, force it: QUARKUS_OIDC_AUTHENTICATION_FORCE_REDIRECT_HTTPS_SCHEME=true.
13.3 Certificates are not issued¶
Caddy logs could not get certificate from issuer or repeated ACME failures.
- Both names must resolve to this host:
dig +short <name>. - Port 80 must reach Caddy from the internet (the HTTP-01 challenge). A cloud security group is a second firewall — check it as well as ufw.
- An AAAA record without working IPv6 breaks validation; remove it or fix routing.
- Let's Encrypt rate-limits failures (5 per account per hour). While debugging, add
acme_ca https://acme-staging-v02.api.letsencrypt.org/directoryto the Caddyfile's global block — staging certificates are untrusted (expect a browser warning) but unlimited. Remove it afterwards and delete/data/caddy/acmeinside the caddy volume so production certificates are requested. - Certificates live in the
lqms-caddy-datavolume. Deleting that volume forces re-issuance and can run you into the rate limit — it is part of the deployment, not a cache.
13.4 exec format error when the app container starts¶
The image was built for another architecture. On the supported path this cannot happen — CI
publishes linux/amd64 only (§4) — so seeing it means the running image is not the published
one: somebody built it by hand on an Apple Silicon Mac (§4.4) and docker loaded it under the same
tag. Confirm with
which must match uname -m on the server (amd64 ↔ x86_64). The fix is to delete the local image
and pull the published one: docker image rm then ./lqms-compose pull app.
13.5 The app refuses to boot: fan-out deployment check¶
FanoutDeploymentCheck fails when the migration role cannot bypass RLS (ADR-0035 §7). On a server
this almost always means the PostgreSQL volume was initialised without postgres-init.sql — for
example by a docker compose up that used only the base file in a directory where the mount path
resolved differently. deployment.md §4 has the recovery. The init script runs only on an empty
data volume.
13.6 Database authentication failure after a restore¶
FATAL: password authentication failed for user "lqms_migration". The restore recreated the
PostgreSQL volume with the demo password while the app uses the generated one — §9.4 has the
ALTER ROLE step.
13.7 HTTP 431 / a wedged browser profile¶
Documented in deployment.md §5.5 (finding #18): clear the site's cookies. It fails clean by design;
never treat it by raising the header limit.
13.8 The image will not pull¶
| What the pull says | What it means |
|---|---|
denied / unauthorized |
no usable credential for a private package. Either no docker login on this box, or a login as a different user than the one running compose (root vs. you — §4.2), or the token expired, or the token is a fine-grained one, which ghcr.io does not accept. Re-issue a classic token with read:packages. |
manifest unknown |
LQMS_VERSION names a tag that was never published. Check the package's Versions list; remember a workflow_dispatch without publish_tag publishes <ref>-<sha>, not a version tag. |
no matching manifest for linux/amd64 |
you are pulling on a non-amd64 host. Only amd64 is published (§4). |
| compose starts a build instead of pulling | you ran a base-file-only command (restore.sh, verify-backup.sh, a bare docker compose up) — §7.1. Export LQMS_IMAGE, or use the lqms-compose wrapper, which always loads both files. |
The stack keeps running through all of these: an image that cannot be pulled blocks an upgrade, it does not stop containers that are already up.
14. What this deployment deliberately does not do¶
Named so that none of it reads as an oversight:
- Keycloak still uses the
dev-file(H2) database. It runs in production mode (start, notstart-dev) with a strict hostname, but its store is a single file on thelqms-kcvolume. It is backed up as a realm export including users on every run ofbackup.sh(ADR-0060), and identities bind to Keycloak subjects (ADR-0063) — so that volume is load-bearing. Moving Keycloak onto PostgreSQL is a separate piece of work. - The app still runs on the single BYPASSRLS datasource. Row-level security is bypassed at
runtime and tenant separation rests on the application layer, exactly as in the demo stack. The
two-role hardening is specified in
deployment.md§4 and is not applied by this override. - No SMTP. Notification email is disabled (ADR-0041); there is no mail channel to configure yet.
- No log aggregation, no metrics stack. Container logs are size-capped (10 MB × 3 per service) and
read with
docker compose logs. The health endpoint plus the backup dead-man ping are the whole monitoring story — and on the reference installation the ping half is not configured yet (§9.1, gap 2), so today the health endpoint is the only live signal there. - Caddy access logs are off. Enabling them records client IPs and document URLs — personal data needing a retention decision. The Caddyfile shows how, deliberately commented out.
smoke.shwas not adapted to run against a public URL. It builds and tears down withdown -v; making it server-safe is more than a parameter. §12 is the checklist that replaces it.- The published image is neither signed nor attested. No cosign signature, and the workflow
deliberately turns provenance/SBOM attestations off (the reason is in its comments). What the
image does carry is the ADR-0090 identity —
productVersion+ git sha at/api/version— and the release SBOM thesbomjob attaches to the GitHub release. The image's own layer (L3,syft) is the registered SBOM follow-up now that a job builds the image at all. - The registry credential is one personal classic token. GHCR offers nothing narrower than an
account-wide
read:packagesscope (§4.2), so the deployment's pull right is coupled to one person's account. A dedicated machine account is the hardening when this stops being a single-owner setup.