ERPNext v15 runs under Docker Compose on VPS3 (77.68.112.126), the auxiliary node beside DefectDojo — it has lived there since the 3-VPS topology was restored, and was re-verified on the hosts on 2026-09-11. The stack is shared by every practice: one bench site per practice, and only the site plus its database differ. Practice databases stay on the practice's own database host and are reached over SSH tunnels, so the ERPNext host holds no client data.
Three sites are live:
| Site | Database | Where that database actually lives |
|---|---|---|
accounts.veripath.co.uk |
veripath_accounting |
Local to VPS3 — erpnext_veripath_postgres (postgres:15-alpine), published at 172.18.0.1:5438. This is the platform's own accounting, not a client's. |
test-client.accounts.gp.veripath.co.uk |
erpnext_test_client |
On the client DB host (VPS1) client_vps_postgres, reached from VPS3 as 172.18.0.1:5435 |
test-client.accounts.dental.veripath.co.uk |
erpnext_test_client_dental |
Reached from VPS3 as 172.18.0.1:5436 |
Access is through VPS1 nginx: accounts.veripath.co.uk and the *.accounts.gp / *.accounts.dental hostnames proxy to 127.0.0.1:8005, which is the tun-aux-vps3.service tunnel to VPS3:8005 — so there is no ERPNext port on the internet. Host placement is owned by VPS Re-Arrangement 3.
Earlier versions of this page placed the stack on the primary VPS. That reflected the pre-restore layout; every correction on this page was verified on both hosts on 2026-09-11.
ERPNext host: VPS3 (77.68.112.126) Client DB host / client VPS
+----------------------------------------+ +---------------------------+
| | | Docker: client_db |
| erpnext-deploy (Docker Compose) | | postgres:15-alpine |
| erpnext_web (v15) port 8005 | | |
| erpnext_worker |<-->| SSH reverse tunnel |
| erpnext_scheduler | | |
| erpnext_veripath_postgres (:5438) | | |
| | | |
| site_config.json: | | |
| db_host: 172.18.0.1 (tunnel bind) | | |
| db_port: 5435 / 5436 / 5438 | | |
+----------------------------------------+ +---------------------------+
|
v tun-vps1-erpnext.service (autossh on VPS3)
172.18.0.1:{port} -> VPS1 loopback 127.0.0.1:{port} -> client DB
erpnext_web (VPS3) -> 172.18.0.1:{port} -> autossh tunnel (VPS3) -> VPS1 loopback:127.0.0.1:{port} -> client DB
Each practice gets its own tunnel port — GP 5435, dental 5436; the platform's own accounting uses 5438, which is local. Because the tunnel is bound on the ERPNext host by tun-vps1-erpnext.service, the site config's db_host: 172.18.0.1 resolves to VPS3's own Docker gateway.
On VPS1 the same ports are additionally bridged for VPS1-local containers by the tenant-gateway@<port>.service socat units (172.18.0.1:{port} -> 127.0.0.1:{port}). Those bridges are not part of the ERPNext path. The ERPNext host stores only its own accounting database; every practice's data stays on that practice's database host.
Stack location: /root/work/erpnext-deploy/ on VPS3 (the runtime). The same path on VPS1 is a git clone of matthew/erpnext-deploy (private, Forgejo) and is the source of truth — HEAD 25e0727. The VPS3 tree is a plain deployed directory, not a clone, so change the repo and deploy it rather than editing on VPS3.
# docker-compose.yml — 3 services
services:
web: # container_name: erpnext_web
# ports: 127.0.0.1:8005:8000
# limits: 1.0 CPU / 1G RAM
# healthcheck: curl -H "Host: ..." /api/method/ping
worker: # container_name: erpnext_worker
# command: bench worker
# limits: 1.0 CPU / 1G RAM
scheduler: # container_name: erpnext_scheduler
# command: bench schedule
# limits: 0.5 CPU / 512M RAM
gp_booking_app_gp_booking_networkerpnext_sites_data (external, preserved), logs_data (new named)A fourth container,
erpnext_veripath_postgres, runs alongside those three services but was created outside compose: it carries no compose labels, sodocker compose pswill not list it anddocker compose upwill not recreate it. It holds the platform's own accounting database and is published at172.18.0.1:5438. Its restart policy isunless-stopped, so it survives a reboot by policy rather than by declaration.
The Docker image (erpnext_v15:latest) is built from /root/work/erpnext-deploy/Dockerfile:
FROM frappe/erpnext:v15
# + postgresql-client, netcat-openbsd, procps
# + healthcare_accounting app (custom Frappe app)
Rebuild and deploy:
cd /root/work/erpnext-deploy
docker build -t erpnext_v15:latest .
docker compose up -d --force-recreate
Note: rebuilding bakes in healthcare_accounting (including its patches.txt) and patches/gunicorn_app.py. Use --force-recreate so the running containers pick up the new image.
| File | Location |
|---|---|
| Compose file | /root/work/erpnext-deploy/docker-compose.yml |
| Dockerfile | /root/work/erpnext-deploy/Dockerfile |
| Site config | erpnext_sites_data volume → {site}/site_config.json |
| Common config | erpnext_sites_data volume → common_site_config.json |
| Secrets | /root/work/erpnext-deploy/.env |
Run these on VPS3 (the runtime host):
# Start / restart
docker compose -f /root/work/erpnext-deploy/docker-compose.yml up -d
# View status
docker compose -f /root/work/erpnext-deploy/docker-compose.yml ps
# View logs
docker compose -f /root/work/erpnext-deploy/docker-compose.yml logs -f
# Rebuild image
docker build -t erpnext_v15:latest /root/work/erpnext-deploy/
# Enter bench console
docker exec -it erpnext_web bash
The healthcare_accounting Frappe app provides Keycloak SSO and API integration:
| Endpoint | Purpose |
|---|---|
healthcare_accounting.api.login.login_via_keycloak |
OIDC callback — exchanges auth code, creates Frappe user session, redirects to invoice |
healthcare_accounting.api.get_patient_customer |
Lookup patient as ERPNext Customer |
healthcare_accounting.api.create_patient_customer |
Create patient as ERPNext Customer |
healthcare_accounting.api.get_sales_invoice_status |
Check invoice status |
healthcare_accounting.api.create_payment_entry |
Create Payment Entry |
healthcare_accounting.api.get_insurer_customer |
Lookup insurer as Customer |
healthcare_accounting.api.create_insurer_customer |
Create insurer as Customer |
Source: /root/work/erpnext-deploy/healthcare_accounting/
Config per site (in site_config.json):
{
"keycloak_realm": "veripath",
"keycloak_client_secret": "<client-secret-from-keycloak>",
"encryption_key": "<generated-by-bench>",
"setup_complete": 1
}
bench new-site writes the db_*, encryption_key and setup_complete fields itself; the keycloak fields are appended afterwards.
The client VPS runs git.veripath.co.uk/infra/client-postgres:ssh-v1.1.0 which:
CLIENT_DB, CLIENT_USER, POSTGRES_PASSWORD)pg_hba.conf (scram-sha-256 only, reject all non-local)| Component | Detail |
|---|---|
| Compose location | /root/work/erpnext-deploy/ |
| Custom app | healthcare_accounting (baked into image) |
| Forgejo registry | git.veripath.co.uk/v2/ (deploy token for pulls) |
| SSH deploy key | Locked to permitlisten="127.0.0.1:{port}","172.18.0.1:{port}" |
| Client-DB tunnel | systemctl status tun-vps1-erpnext.service on the ERPNext host — binds 172.18.0.1:5435 / :5436 |
| Tenant gateway bridges | systemctl status tenant-gateway@<port>.service on VPS1 — socat 172.18.0.1:{port} -> 127.0.0.1:{port} for VPS1-local containers, not the ERPNext path |
| Source of truth | matthew/erpnext-deploy (private Forgejo repo), cloned at /root/work/erpnext-deploy on VPS1 |
| ERPNext site | one per practice, e.g. test-client.accounts.gp.veripath.co.uk, test-client.accounts.dental.veripath.co.uk |
For future clients, use the automated approach documented at:
https://wiki.veripath.co.uk/infrastructure/SIAAS/on-boarding-new-clients
ERPNext generates MySQL-flavoured SQL (lenient GROUP BY, IF(), "literal" strings, HAVING on output aliases). The PostgreSQL backend's built-in modify_query does not fully convert these, so gunicorn_app.py (/opt/erpnext/gunicorn_app.py, repo patches/gunicorn_app.py) applies additional rewrites on every query:
SELECT bare (non-aggregated) columns not present in GROUP BY are rejected by PostgreSQL. They are wrapped in MAX(col) AS col (preserving the alias) rather than added to the GROUP BY — adding them to GROUP BY would split rows MySQL aggregates together (e.g. Payment Ledger rows that differ in due_date), corrupting outstanding amounts. Handles multiple GROUP BY clauses inside CTEs (the Payment Ledger Entry outstanding query).HAVING on an ungrouped, non-aggregate query and won't resolve output aliases in all cases. Output-alias references are resolved to their source expression and, for ungrouped non-aggregate queries, HAVING is converted to WHERE.CASE WHEN <numeric> ... is invalid; the condition is wrapped as <cond> IS NOT NULL AND <cond> <> 0."literal" (e.g. "Sales Invoice" as voucher_type) is converted to 'literal', but only for backtick-quoted (legacy raw SQL) queries so PostgreSQL-style double-quoted identifiers are untouched.These patches are required for Sales Invoice submission and Payment Entry flows on PostgreSQL (not just the trend reports). After modifying the file, the container must be fully restarted:
docker restart erpnext_web
The patch also excludes disabled accounts from the Chart of Accounts tree view (_patch_account_tree()), keeping the view clean while preserving all data.
Currency GBP had smallest_currency_fraction_value = 0.0 on all three sites, which made round_based_on_smallest_currency_fraction round every total and outstanding to whole pounds (£18.40 → £18.00). It is set to 0.01, and the repo now guards against regression: the Frappe patch healthcare_accounting.patches.ensure_gbp_precision (registered in patches.txt under [post_model_sync]) plus the bootstrap scripts setting 0.01 on fresh provisioning. Committed as 25e0727.
The standard ERPNext Chart of Accounts is extended with healthcare-specific accounts for clinics and dental surgeries. New partners should select "UK Healthcare Clinic" as the Chart of Accounts template when creating their Company. This is available in the Company creation wizard for UK companies.
| Name | Location | Discovery |
|---|---|---|
| UK Healthcare Clinic | healthcare_accounting/setup/gb_uk_healthcare_clinic.json |
Auto-detected for UK companies (gb_ prefix) |
| Build script | healthcare_accounting/setup/build_uk_healthcare_coa_json.py |
Merges Standard with Numbers + healthcare accounts |
The template is copied into the erpnext verified/ directory during Docker build (see Dockerfile).
These accounts are added on top of the Standard with Numbers template:
| # | Account | Parent | Type |
|---|---|---|---|
| 1010 | Clinic Till / Cash on Hand | Cash In Hand | Cash |
| 1030 | Stripe Clearing Account | Bank Accounts | Bank |
| 1120 | Accounts Receivable - Patients | Accounts Receivable | Receivable |
| 1130 | Accounts Receivable - Insurers | Accounts Receivable | Receivable |
| 1140 | Accounts Receivable - Corporate Memberships | Accounts Receivable | Receivable |
| 1420 | Medical Supplies Stock | Stock Assets | Stock |
| 1520 | Medical Equipment | Fixed Assets | Fixed Asset |
| 1530 | Computer & IT Hardware | Fixed Assets | Fixed Asset |
| 1550 | Accum. Deprec. - Medical Equipment | Fixed Assets | Accumulated Depreciation |
| 1560 | Accum. Deprec. - IT Hardware | Fixed Assets | Accumulated Depreciation |
| 2010 | Patient Deposits / Prepaid Fees | Current Liabilities | — |
| 2020 | Wages Payable | Current Liabilities | Payable |
| 2030 | Pension Liabilities | Current Liabilities | — |
| 2130 | PAYE & NI Payable | Duties and Taxes | Tax |
| 4010 | Fee Income - Private Consultations | Direct Income | — |
| 4020 | Fee Income - Medical Insurance | Direct Income | — |
| 4030 | Fee Income - Occupational / Corporate Health | Direct Income | — |
| 4040 | Prescribing & Dispensing Income | Direct Income | — |
| 4050 | Missed Appointment / Cancellation Fees | Direct Income | — |
| 5010 | Private Lab Analysis Fees | Direct Expenses | — |
| 5020 | Medical Consumables Expensed | Direct Expenses | — |
| 5030 | Clinician Commissions / Associate Fees | Direct Expenses | — |
| 6010 | Clinical Waste Disposal | Indirect Expenses | — |
| 6020 | Software Licenses & Subscriptions | Indirect Expenses | — |
| 6030 | Medical Professional Indemnity | Indirect Expenses | — |
| 6040 | CQC Registration & Professional Fees | Indirect Expenses | — |
| 6050 | Equipment Calibration & Testing | Indirect Expenses | — |
| 6510 | Employer National Insurance | Indirect Expenses | — |
| 6520 | Employer Pension Contributions | Indirect Expenses | — |
| 6530 | Staff Training & Mandatory Compliance | Indirect Expenses | — |
| 7010 | Clinic Cleaning & Sanitisation | Indirect Expenses | — |
| 7510 | Stripe / Card Processing Fees | Indirect Expenses | — |
| 7520 | Bank Charges | Indirect Expenses | — |
For companies created before this template was available (e.g. Test Client), run the migration script:
import frappe
frappe.init(site='{site}')
frappe.connect()
from healthcare_accounting.setup.add_healthcare_coa import add_healthcare_coa
result = add_healthcare_coa('{company_name}')
print(f'Created: {result["created"]}')
The template is registered in two ways:
Dockerfile copies gb_uk_healthcare_clinic.json to erpnext's verified/ directory during build/home/frappe/frappe-bench/apps/erpnext/erpnext/accounts/doctype/account/chart_of_accounts/verified/gb_uk_healthcare_clinic.jsonTo rebuild the template after changing healthcare accounts:
cd /home/frappe/frappe-bench && source env/bin/activate
python3 -c "
from healthcare_accounting.setup.build_uk_healthcare_coa_json import write_json
write_json()
"
These accounts exist for historical data but are hidden (disabled=1) since they're irrelevant for medical clinics:
| Account | Reason |
|---|---|
| Plants and Machineries | Manufacturing only |
| CWIP Account | Construction only |
| Stock Expenses (group) | Retail/trading COGS |
| Cost of Goods Sold | Trading only |
| Stock Adjustment | Trading only |
| Expenses Included In Asset/Stock Valuation | Manufacturing only |
| Stock Liabilities (group) | Trading only |
| Stock Received / Asset Received But Not Billed | Trading only |
| Commission on Sales | Not typical for clinics |
| Entertainment Expenses | Not typical for clinics |
| Freight and Forwarding Charges | Not relevant |
| Temporary Accounts / Temporary Opening | Setup only |
| Impairment | Not typical |
| Sales Expenses | Not typical |
The gunicorn_app.py (loaded at container startup) patches erpnext.accounts.utils.get_children to exclude disabled accounts from the Chart of Accounts tree view. This keeps the view clean while preserving all data for existing transactions.
Source: /opt/erpnext/gunicorn_app.py → _patch_account_tree()