Status: COMPLETE, verified 2026-09-13. The pharmacy test client's data has moved off the platform's
shared client-tier Postgres instance and onto the client-owned box, on its own dedicated tunnel.
The client host is unchanged, the pharmacy app still runs on the app engine, and the dental client's
tunnel, database and containers were not touched. Evidence is at the foot of this page.
Related: Pharmacy App ·
Test client ·
SIAAS: On-boarding New Clients ·
SIAAS: Dental Client VPS Onboarding
test-client-pharmacy.pharmacy.veripath.co.uk.ClientVPS inventory row is recorded for the pharmacy client (one row per client, linked to the| Step | Outcome |
|---|---|
| Role and database on the box | A dedicated login role and a database it owns, created in the box's existing cluster (alongside the dental client's). The public schema was handed to the role and its CREATE privilege verified, not assumed |
| Its own tunnel | A separate reverse-tunnel unit on the box for this client only; the dental client's unit was left running untouched (it has not restarted since 2026-09-11). The port was added to the box tunnel key's permitlisten restriction on the primary host before the unit was started |
| Primary host | The port is exposed to the docker bridge by a tenant-gateway@ unit for it, alongside the existing ones |
| App engine | A separate tunnel unit carries this client's port to the app container, so it can be restarted without disturbing the central/client/dental DB forwards that the running apps depend on |
| Tenant row | Retargeted through the app's own seed command (seed_pharmacy_sector), so the credential is encrypted with the application key. Database name unchanged; user, host, port and password updated |
| Schema and data | Applied with the app's migrate_client --app-label pharmacy, then re-seeded (synthetic premises; the two synthetic pharmacists already existed centrally, and their credentials were not changed) |
| Verification | A real OIDC login through the pharmacy realm and the full RP cycle, then the client database and the apex database checked separately |
| Inventory | ClientVPS row recorded, linked to the client's tenant; the RAM drift found on the shared box's existing row was corrected at the same time |
Unchanged: the client hostname, its vhost and certificate, the app deployment, the Keycloak wiring,
and the dental client's tunnel, database and containers.
| Aspect | Finding |
|---|---|
| Host | Ubuntu, 2 vCPU, 1.8 GB RAM (no swap), 87 GB disk (81 GB free), effectively idle |
| Docker | Three containers (client_db, minio, dental_orthanc), all restart: unless-stopped, ~245 MiB together |
client_db |
client-postgres image, PostgreSQL 15.18 with pgvector, --network host (binds the host's loopback only) |
| Databases | test_client_dental (21 MB, 141 tables), erpnext_test_client_dental (45 MB), plus the new pharmacy database |
| Roles | the dental role (not a superuser), the ERPNext role, postgres, plus the new pharmacy role |
| Tunnel | One unit for the dental client (DB, MinIO, Orthanc) and now a separate one for the pharmacy client (DB only) |
| Access | key-based from the primary host; the box's own tunnel key is restricted with permitlisten to exactly the ports in use |
It is a data tier only — the apps run on the app engine.
The box carries a database, an object store and an imaging service; a second database in the same
PostgreSQL cluster with its own login role is a rounding error on both memory and disk, and the pharmacy
workload is DB-only.
Two things to be clear-eyed about: one box now holds two clients' data, so its fate is shared (load
is not the issue — blast radius is), and this is appropriate for test clients, not for a real independent
pharmacy client, which the SIAAS model gives its own box.
Decision: the pharmacy tenant should have its own Client VPS, and he is arranging it. Until it is
provisioned the shape stays exactly as this page describes — one box serving test-client-dental and
test-client-pharmacy on two separate tunnels — and per the patching
design §6 both tenants see the same box's maintenance
history, with one line on the page saying the server is shared.
Why, in his words (2026-10-15): "we are using that client VPS for both apps — I need to arrange for
another vps so that the pharmacy app has its own client-vps." That closes the caveat recorded under "Why
reuse works" above: a shared box's blast radius is acceptable for a test estate and wrong for a client that
is meant to own its data path.
When the VPS lands this is a migration, not a fresh build — the tenant is live, so it is the mirror image
of the reuse runbook below:
unattended-upgrades, the sameclient_db, and the box's outbound reverse-tunnel key.test_client_pharmacy and erpnext_test_client_pharmacy (both live inpublic-schema privileges (PostgreSQL 15 makes object creation the schema owner's right).db_name / db_user / db_password / db_host / db_port written through the app's ownpharmacy_app and both Celery containers. Not optional: until that restart the app silently keepsClientVPS row for the pharmacy's own box, linked to its tenant (replacing themigrate_client --slug test-client-pharmacy; re-seed the syntheticNot started, and nothing here changes today's estate — the shared box keeps its two tenants and its one
history until the replacement exists.
public schema before migrating — PostgreSQL 15permitlisten restriction (authorized_keys is re-read pertenant-gateway@<port> unit for it.db_name, db_user, db_password, db_host, db_port) — writtenpharmacy_app and the two Celery containers). This step is notmigrate_client --slug <slug> --app-label pharmacy, then re-seed the syntheticClientVPS row for the client, linked to its tenant. Leave the credential fieldsThe executable form of the new steps is in the ops repository
(scripts/retarget-pharmacy-tenant-db.py, scripts/seed-pharmacy-client-demo.sh,
scripts/verify-pharmacy-client-e2e.py, scripts/record-pharmacy-clientvps.py), with the two tunnel
units under systemd/ and the app-engine forward added to infra/bring-up.sh so it returns after a
reboot. Every unit is enabled on its host.
| Check | Observed |
|---|---|
| Role/database created on the box | CREATE DATABASE + ALTER SCHEMA public OWNER TO … OK; has_schema_privilege(…,'public','CREATE') = true |
| Credential through the new tunnel | Probe from the primary host and from inside the app container both return the new database, the new role, public CREATE = true |
| Tenant row | Updated by the app seed; database name unchanged, user/host/port retargeted, stored password ciphertext changed |
| Schema applied | migrate_client --slug test-client-pharmacy --app-label pharmacy applied cleanly to the fresh database |
| Re-seed | One synthetic premises created; both synthetic pharmacists reported as already existing (credentials untouched) |
| Live OIDC login | Both synthetic pharmacists authenticate through the pharmacy realm on the client host |
| RP cycle on the client host | Sign-on as the first pharmacist; a second concurrent sign-on refused ("already on duty"); history lists the entry; sign-off; then the second pharmacist signs on and off |
| Client database on the box | 2 RP log rows, each signed on and off; 4 audit events; 1 premises |
| Apex pharmacy database | Unchanged: 1 premises, 1 RP log row, 2 audit events |
| Dental database on the box | Unchanged: 141 tables; its containers still up (4 weeks) |
| Dental tunnel | Never restarted: its unit has been active since 2026-09-11 |
| Central registry | 7 tenants, 34 users — unchanged by the move |
| Units survive reboot | Box tunnel, app-engine forward and the primary's gateway unit all enabled |
| Inventory | ClientVPS row recorded for the client, linked to its tenant; credential fields empty |
tenancy/routers.py::_load_dynamic_aliases, guarded by a module flag) into settings.DATABASES. Thepg_hba.conf. The image intends scram-sha-256, but the live filepostgres — without a password, soClientVPS row recorded 4 GB RAM; the box| Item | State |
|---|---|
| ERPNext for pharmacy clients | Not configured — pharmacy accounting is a later phase, so no ERPNext site or config row exists for this client (decision 5) |
| Roles and permissions guide | Still owed; it closes the Phase 1 documentation set |
| Pharmacy git history | DECIDED 2026-09-14 (Matthew): PURGE. The old demo credential is inert but should not linger where a future clone can read it; GP and dental were purged on 2026-09-11. The purge is a destructive force-push and is now explicitly authorised — OpenCode's task |
| Orphaned former database | DECIDED 2026-09-14 (Matthew): DROP. The pre-move database on the shared client-tier instance is left over from the move onto the client-owned box; nothing points at it and it carries only synthetic data. OpenCode to drop it |