Reference architecture for provisioning independent client VPS instances under the SIAAS model. Each client VPS is fully isolated, with zero public exposure — the app connects to the client database through an SSH reverse tunnel from the primary infrastructure VPS.
Important (2026-08-02): WireGuard was the original design, but UDP 51820 is filtered at the hosting-provider level between VPSes (confirmed live with packet tests in both directions). The SSH reverse tunnel (TCP 22) is the standard, working path. WireGuard configs may remain on hosts but are not relied upon for the DB path. See the On-boarding New Clients runbook for the canonical steps.
┌──────────────────────────────┐ SSH reverse tunnel (TCP 22) ┌──────────────────────────────┐
│ Primary Infrastructure │◄─────────────────────────────────►│ Client VPS (Isolated) │
│ VPS (88.208.212.211) │ client-postgres container │ │
│ │ opens: ssh -R <port>:5432 │ │
│ ┌────────────────────────┐ │ │ ┌────────────────────────┐ │
│ │ GP / Dental app │ │ socat (bridge) │ │ client-postgres │ │
│ │ ERPNext (Frappe) │──┼──► 172.18.0.1:543x ──► 127.0.0.1 │ │ (PostgreSQL 15, Docker) │ │
│ │ Nginx, Keycloak │ │ │ └────────────────────────┘ │
│ └────────────────────────┘ │ │ │
│ │ │ ┌────────────────────────┐ │
│ ┌────────────────────────┐ │ │ │ UFW: deny all public │ │
│ │ socat forward units │ │ │ │ ALLOW: SSH (tunnel) │ │
│ └────────────────────────┘ │ │ └────────────────────────┘ │
└──────────────────────────────┘ └──────────────────────────────┘
Key principles:
client-postgres:ssh-v1.1.0 Docker container (--network host), which opens the reverse tunnel itself on startup and creates the tenant DB/user.TUNNEL_PORT (env) sets the reverse-tunnel listener port on the primary (default 5435; dental uses 5436) — each client gets a dedicated port so tunnels never collide.172.18.0.1:<port> (Docker bridge) → 127.0.0.1:<port> (tunnel), so app containers can reach the client DB.# From the primary infrastructure VPS
ssh -o StrictHostKeyChecking=no root@<CLIENT_VPS_IP>
The setup is automated via OpenCode. Provide the agent with:
TUNNEL_PORT (default 5435; assign a unique port per client)The agent executes these blocks sequentially:
| Block | Action | Details |
|---|---|---|
| 1 | System update & base packages | apt update && apt upgrade -y |
| 2 | Firewall lockdown | ufw default deny incoming, allow SSH only |
| 3 | (Optional) WireGuard config | Keypair + wg0.conf — not relied upon for DB path |
| 4 | Docker + client-postgres | client-postgres:ssh-v1.1.0, --network host, CLIENT_DB/CLIENT_USER/TUNNEL_PORT env |
| 5 | Reverse tunnel | Established automatically by the container entrypoint |
| 6 | Primary-side socat forward | systemd unit bridging 172.18.0.1:<TUNNEL_PORT> → 127.0.0.1:<TUNNEL_PORT> |
| 7 | Handover package generation | Credentials, keys, instructions |
Primary-side prerequisites (before the container starts):
permitlisten entries (e.g. permitlisten="127.0.0.1:5436", permitlisten="172.18.0.1:5436").TUNNEL_PORT (copy dental-tunnel-forward.service, adjust the port).ssh -R 127.0.0.1:${TUNNEL_PORT}:127.0.0.1:5432 -N -o StrictHostKeyChecking=no \
-o ServerAliveInterval=30 -o ServerAliveCountMax=3 \
"root@${TUNNEL_HOST}" &
App containers run on a Docker network and cannot reach 127.0.0.1:<port>, so a socat unit bridges the bridge gateway to the tunnel listener:
# /etc/systemd/system/dental-tunnel-forward.service (port 5436 dental)
ExecStart=/usr/bin/socat TCP-LISTEN:5436,fork,reuseaddr,bind=172.18.0.1 TCP:127.0.0.1:5436
| Client | Tunnel port | Primary listener | Socat bridge | Forward unit |
|---|---|---|---|---|
GP test-client |
5435 | 127.0.0.1:5435 |
172.18.0.1:5435 |
erpnext-tunnel-forward.service |
Dental test-client-dental |
5436 | 127.0.0.1:5436 |
172.18.0.1:5436 |
dental-tunnel-forward.service |
Configs may exist (primary 10.0.0.1/24, client 10.0.0.x/24, port 51820, persistent keepalive 25). UDP 51820 is provider-filtered between VPSes — the handshake does not complete and the DB path must not depend on it. Do not block on WireGuard during onboarding.
Built from /opt/erpnext-client-db/ (Dockerfile on postgres:15-alpine + openssh-client). On startup the entrypoint:
/docker-entrypoint-initdb.d/01-create-tenant.sh:
CLIENT_USER role (password = POSTGRES_PASSWORD env).CLIENT_DB owned by that user.pg_hba.conf (scram-sha-256; reject all non-local).# Example run (dental)
docker run -d --name client_db --network host \
-e TUNNEL_PORT=5436 \
-e CLIENT_DB=test_client_dental \
-e CLIENT_USER=postgres \
-e POSTGRES_PASSWORD='<secure>' \
git.veripath.co.uk/infra/client-postgres:ssh-v1.1.0
The app connects to the tenant DB as the tenant user over the tunnel:
CREATE USER <tenant_user> WITH PASSWORD '<secure-password>';
GRANT ALL PRIVILEGES ON DATABASE <client_db> TO <tenant_user>;
Note: the original page described a
fh_app_userrestricted user; in the current image the tenant DB owner (CLIENT_USER) is used directly andpg_hbarestricts connectivity to the tunnel (rejecting all non-local). Review least-privilege on the DB role if stricter separation is required.
ufw default deny incoming
ufw default allow outgoing
ufw allow <custom-ssh-port>/tcp # SSH for the tunnel + admin
# Optionally: ufw allow 51820/udp # WireGuard (non-functional between VPSes)
ufw --force enable
The client connects as root@<primary> using the baked-in deploy key, restricted to port forwarding:
restrict,port-forwarding,permitlisten="127.0.0.1:5436",permitlisten="172.18.0.1:5436" ssh-ed25519 <deploy-key> deploy@erpnext
authorized_keys/opt/backup-client.py (16 KB, present 2026-07-17)./opt/backup-client.py nor the /etc/cron.d/backup-health cron. Automated per-client backups are not yet deployed on dental clients — see Client VPS Backup and schedule this.| Item | Schedule | Retention | Location |
|---|---|---|---|
| PostgreSQL dump (pg_dump --clean) | Daily 03:00 UTC | 14 days | /var/backups/practice/daily/ |
| Config files (postgresql.conf, pg_hba.conf) | Daily 03:00 UTC | 14 days | Included in config archive |
| SSH tunnel keys | Daily 03:00 UTC | 14 days | Included in config archive |
| Package manifest (dpkg -l) | Daily 03:00 UTC | 14 days | Included in config archive |
| SHA256 manifest | Daily 03:00 UTC | 14 days | JSON checksum file |
Backups are stored locally on the client VPS. Off-site sync can be configured via the config file (/etc/practice-backup.conf). GPG encryption is available if a recipient key is configured.
When a new client VPS is ready, provide the client with:
# On the client VPS — stop the reverse tunnel (container) which severs app access
docker stop client_db
(WireGuard equivalent, if ever functional: sudo systemctl stop wg-quick@wg0)Client VPS health is monitored via:
infrastructure/SIAAS/client_vps/dashboard-guide/etc/cron.d/backup-health (not yet deployed on dental clients — see gap above)The infrastructure dashboard at https://gp.veripath.co.uk/integrations/infrastructure/ provides a live view of all client VPS status including:
# On primary VPS — listener on the client's tunnel port
ss -tlnp | grep <TUNNEL_PORT>
# On client VPS
docker logs client_db | grep "SSH tunnel PID"
psql -h 127.0.0.1 -p <TUNNEL_PORT> -U <tenant_user> -d <client_db> -c "SELECT 1;"
# From Docker bridge (for app containers):
psql -h 172.18.0.1 -p <TUNNEL_PORT> -U <tenant_user> -d <client_db> -c "SELECT 1;"
ALTER USER <tenant_user> WITH PASSWORD '<new-password>';