This page is the collaborative runbook for provisioning a new client VPS under the SIAAS model. Each step is annotated:
Both GP and Dental clients follow the same infrastructure. Where they differ, both paths are shown.
Primary VPS (88.208.212.211) Client VPS
+------------------------------+ +------------------+
| ERPNext (Frappe) | | Docker Container |
| GP Booking App (Django) |<------->| client-postgres |
| Nginx (reverse proxy) | Tunnel | PostgreSQL 15 |
| Keycloak (SSO) | | (hardened) |
+------------------------------+ +------------------+
Client DB connectivity uses the SSH reverse tunnel (TCP 22) as the standard path. WireGuard (UDP 51820) is optional/legacy: at the hosting provider level UDP is filtered between VPSes, so the SSH reverse tunnel is the reliable, documented path. Each client VPS gets a dedicated tunnel port:
| Client | Tunnel port | Primary listener | Socat bridge |
|---|---|---|---|
GP (test-client) |
5435 | 127.0.0.1:5435 |
172.18.0.1:5435 |
Dental (test-client-dental) |
5436 | 127.0.0.1:5436 |
172.18.0.1:5436 |
Pharmacy (test-client-pharmacy) |
5439 | 127.0.0.1:5439 |
172.18.0.1:5439 |
The TUNNEL_PORT is set via env on the client-postgres container (defaults to 5435). The primary side needs a matching socat forward unit (dental-tunnel-forward.service for 5436, erpnext-tunnel-forward.service for 5435) and the SSH deploy key extended with the matching permitlisten entries.
Two clients, one box — the pharmacy client (2026-09-13). The pharmacy test client did not get a
VPS of its own: it shares the box already serving the dental client, with its own database and login
role in that box's PostgreSQL cluster and its own reverse-tunnel unit (the dental client's unit is
not restarted or reconfigured by it). Its dedicated forward lands on the port in the table above, is
exposed to the docker bridge by atenant-gateway@unit, and is carried to the app engine by a
separate tunnel unit there (tun-vps1-pharmacy.service) — separate on purpose, so the running app
forwards for the central, client-tier and dental databases are never disturbed. Do not add the port to
the existing app-engine tunnel unit. The full record, runbook (which includes a mandatory app restart
after retargeting the tenant row), evidence and findings are on
Pharmacy App — Client VPS.
| Aspect | GP Client | Dental Client |
|---|---|---|
| URL pattern | {slug}.gp.veripath.co.uk |
{slug}.dental.veripath.co.uk |
| Nginx config | sites-enabled/{slug}.gp.veripath.co.uk.conf |
sites-enabled/{slug}.dental.veripath.co.uk.conf |
| Parent sector | GP | Dental |
| DB naming | {slug}_clinic |
{slug}_dental |
| User roles | CLINICIAN, RECEPTIONIST, etc. |
DENTAL_CLINICIAN, DENTAL_ADMIN, etc. |
| DNS wildcard | *.gp.veripath.co.uk |
*.dental.veripath.co.uk |
I DO — before the client's VPS exists.
/etc/wireguard/wg0.conf for existing peers)wg0.confbash wg genkey | tee /path/to/client.key | wg pubkey > /path/to/client.pub CLIENT_DB={slug}_clinic or {slug}_dentalTUNNEL_PORT — the per-client reverse-tunnel port (5435 GP default, 5436 dental)permitlisten in /root/.ssh/authorized_keys to include the client's tunnel port (both 127.0.0.1:<port> and 172.18.0.1:<port>)dental-tunnel-forward.service, adjusting the port)sites-available/ (not enabled until DNS resolves)YOU DO — in the Fasthosts Control Panel.
<IP>"runcmd:
- Installs docker.io, wireguard-tools, ufw
- ufw: default deny, allow SSH + tunnel subnet
- (optional) Writes /etc/wireguard/wg0.conf (pre-configured)
- (optional) Enables and starts wg-quick@wg0
- Logs into git.veripath.co.uk registry with deploy token
- docker run git.veripath.co.uk/infra/client-postgres:ssh-v1.1.0 # TUNNEL_PORT env set
The Docker image entrypoint (AUTO):
ssh -R 127.0.0.1:${TUNNEL_PORT}:127.0.0.1:5432CLIENT_DB / CLIENT_USER)pg_hba.conf (scram-sha-256 only, reject non-local)I DO — after you confirm the VPS is up.
bash wg show bash # On primary VPS — the client's tunnel port: psql -h 127.0.0.1 -p <TUNNEL_PORT> -U postgres -d <tenant_db> -c "SELECT 1;" # And from Docker bridge (for app containers): psql -h 172.18.0.1 -p <TUNNEL_PORT> -U postgres -d <tenant_db> -c "SELECT 1;" python PartnerOrg.objects.create(slug='{slug}', name='{Practice Name}', is_active=True) {slug}.gp.veripath.co.uk — Dental: {slug}.dental.veripath.co.ukpython Tenant.objects.create( sector=Sector.objects.get(slug='client'), subdomain='{slug}', name='{Practice Name}', db_name='{slug}_clinic', # or {slug}_dental for dental db_user='...', db_password='...', db_host='172.18.0.1', db_port=<TUNNEL_PORT>, is_active=True, ) migrate_client command (the dynamic per-client DB alias isn't registered at argparse time, so migrate --database=sector_client_{slug} fails):bash docker exec dental_app python manage.py migrate_client --slug {slug} # e.g. python manage.py migrate_client --slug test-client-dental migrate --database=sector_client_{slug}. Client data migrations (clinical_data.0010, users.0013, tenancy.0002, dashboards.0004) are DB-aware and skip on client-family DBs automatically — no --fake needed.bash /opt/kcadmin.sh update clients/<CLIENT_ID> -r veripath \ -s "redirectUris+=[\"https://{slug}.gp.veripath.co.uk/oidc/*\"]" # Dental: https://{slug}.dental.veripath.co.uk/oidc/* *.dental.veripath.co.uk wildcard already covers dental client subdomains — no Keycloak client change needed.{slug}.gp.veripath.co.uk → 88.208.212.211{slug}.dental.veripath.co.uk → 88.208.212.211*.gp / *.dental wildcards already in place.{slug}"sites-enabled/ and reload:bash ln -s ../sites-available/{slug}.gp.veripath.co.uk.conf /etc/nginx/sites-enabled/ nginx -t && systemctl reload nginx bash certbot certonly --webroot -w /var/www/letsencrypt -d {slug}.gp.veripath.co.uk certbot --nginx -d {slug}.gp.veripath.co.uk # adds SSL block to nginx config bash curl -I https://{slug}.gp.veripath.co.uk I DO
python CustomUser.objects.using('sector_client_{slug}').create( username='admin@{slug}', email='admin@{slug}', role='PRACTICE_MANAGER', # or DENTAL_ADMIN for dental is_staff=True, is_active=True, ) ERPNext (GP only): ERPNext is the GP accounting subsystem (Frappe, per-practice site
{practice}.accounts.gp.veripath.co.uk). The ERPNext step (site_config.jsonpointing at the client DB) applies to GP clients only. Dental does not currently run ERPNext — dental billing is Stripe-based. Dental→ERPNext integration is parked/pending (see Dental App Developer Plan); do NOT configure an ERPNextsite_config.jsonfor dental clients.
Patching is part of the managed service, and the client can see it on their own dashboard — so a box is not
finished at handover until its maintenance plumbing is live. Four things, in order.
1. The box is linked, not just registered. The staff and client maintenance views both resolve through the
ClientVPS row's link to the client's tenant. A box with no linked tenant shows the client nothing, and
their "request a run" control refuses with 403 — which is the isolation working, not a bug to route around.
2. Cadence rows exist for the box. One per kind (operating-system patch, container image refresh), each
with the box's own slot — created in the app's admin (Maintenance cadence). Without them the client's card
says "not scheduled": honest, but not yet useful.
3. Install the patch agent. On the app host:
python manage.py client_patch_credentials <client_name>
It prints the box's ingest token and signed-job secret and the exact installer line. Then, on the box,
as root, from a veripath-ops checkout:
sudo scripts/client-patch/install-client-patch.sh \
--client-vps <client_name> \
--token <maintenance_token> \
--job-secret <job_secret>
/etc/veripath/client-patch.env and nowhere else — not into4. Check both sides see it.
First installed 2026-10-15, on the dental test-estate box — the canary, in SSH/dev mode, exactly as this
phase describes. The agent reported its first inventory through the ingest and the box now carries its own slot
(02:23) with all four timers. The remaining step for the estate as a whole is to repeat this on the other two
boxes and then switch each to tunnel-only, which is what proves the production posture. Nothing on this page
is unexercised any more.
Two gaps to expect on a box that has been hand-built (both found on the first install): the box may have no
docker compose at all — in which case image_refresh can pull and report but cannot recreate anything; and the
installer's env file needs its values written quoted-safe (a generated token containing $ would otherwise be
expanded when the file is sourced).
ss -tlnp | grep <TUNNEL_PORT> on primary shows the listenerpsql -h 127.0.0.1 -p <TUNNEL_PORT> -U postgres -c "SELECT 1;"systemctl status dental-tunnel-forward (or erpnext-tunnel-forward)docker ps (verify via client)dsp_clinic or veripath_dental databasesclient_patch_credentials + install-client-patch.sh), reporting, and the box'sBuilt from /opt/erpnext-client-db/:
Dockerfile (/opt/erpnext-client-db/Dockerfile):
FROM postgres:15-alpine
RUN apk add --no-cache openssh-client
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh && mkdir -p /docker-entrypoint-initdb.d
ENTRYPOINT ["/entrypoint.sh"]
CMD ["postgres", "-c", "listen_addresses=*"]
Entrypoint (/opt/erpnext-client-db/entrypoint.sh) performs on startup:
~/.ssh/id_ed25519ssh -R 127.0.0.1:${TUNNEL_PORT}:127.0.0.1:5432 (TUNNEL_PORT env, default 5435)/docker-entrypoint-initdb.d/01-create-tenant.shdocker-entrypoint.shBuild and push:
docker build -t git.veripath.co.uk/infra/client-postgres:ssh-v1.1.0 /opt/erpnext-client-db
docker push git.veripath.co.uk/infra/client-postgres:ssh-v1.1.0
Enabled via Forgejo config (app.ini):
[packages]
ENABLED = true
Deploy user for automated pulls:
deployread:package, read:repositoryinfra org as ownerGenerate:
ssh-keygen -t ed25519 -f ~/.ssh/id_erpnext_deploy -N "" -C "deploy@erpnext"
Add to ~/.ssh/authorized_keys with port-forwarding restriction — extend permitlisten for each client tunnel port:
restrict,port-forwarding,permitlisten="127.0.0.1:5435",permitlisten="172.18.0.1:5435",permitlisten="127.0.0.1:5436",permitlisten="172.18.0.1:5436" ssh-ed25519 <key> deploy@erpnext
This key is baked into the Docker image — no key transfer needed at deployment time.
App containers run on a Docker network and can't reach 127.0.0.1:<tunnel_port>. A socat forward bridges this. One unit per client tunnel port:
Systemd service (e.g. /etc/systemd/system/dental-tunnel-forward.service for port 5436):
[Unit]
Description=Forward dental client DB tunnel to Docker bridge
After=network.target
[Service]
Type=simple
ExecStart=/usr/bin/socat TCP-LISTEN:5436,fork,reuseaddr,bind=172.18.0.1 TCP:127.0.0.1:5436
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
(GP port 5435 equivalent: erpnext-tunnel-forward.service with TCP-LISTEN:5435,bind=172.18.0.1 ... TCP:127.0.0.1:5435.)