ERPNext v15 runs under Docker Compose on the primary VPS, with each practice's database on that practice's client VPS via the client-postgres image. The split-topology is fully operational, consistent with SIAAS principles. The same stack hosts one site per practice (GP, dental, etc.); the containers are shared and only the site + DB differ.
Primary VPS (88.208.212.211) Client VPS (77.68.97.101 example)
+----------------------------------------+ +---------------------------+
| erpnext-deploy (Docker Compose) | | Docker: client_db |
| erpnext_web (v15) port 8005 | | postgres:15-alpine |
| erpnext_worker |<-->| SSH reverse tunnel |
| erpnext_scheduler | SSH| Auto-creates DB on boot |
| |tun.| |
| site_config.json: |5435| |
| db_host: 172.18.0.1 (socat bridge) | :5432 |
| db_port: 5435 (per-practice port) | | |
+----------------------------------------+ +---------------------------+
|
v socat (systemd service)
172.18.0.1:{db_port} -> 127.0.0.1:{db_port}
erpnext_web -> 172.18.0.1:{port} -> socat -> 127.0.0.1:{port} -> SSH tunnel -> client VPS:5432
Each practice gets its own tunnel port on the primary (e.g. GP 5435, dental 5436). The primary never stores client data — only the ERPNext containers and the app's own platform DBs live there.
Stack location: /root/work/erpnext-deploy/
# 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)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 |
# 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}" |
| SoCat forward | systemctl status erpnext-tunnel-forward (per-practice port) |
| 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.
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()