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 |
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.
| 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.
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 databasesBuilt 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.)