Standard patterns for all Compose-managed stacks in the VeriPath ecosystem.
All stacks that need to communicate with the GP booking app must use the shared external network:
networks:
gp_booking_network:
external: true
name: gp_booking_app_gp_booking_network
Do not define a new bridge network unless the stack is fully isolated.
Every service must use the standard logging YAML anchor:
x-logging: &default-logging
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
Applied to each service as:
services:
myservice:
logging: *default-logging
curl -f http://localhost:PORT/path/pg_isready -U user -d dbnameredis-cli pingnginx -tnc -z localhost PORTAll healthchecks must have interval, timeout, retries, and start_period defined.
| Service Type | CPU | Memory |
|---|---|---|
| Django app | 1.0 | 1G |
| Celery worker | 1.0 | 1G |
| Celery beat | 0.5 | 512M |
| PostgreSQL (main) | 1.5 | 2G |
| PostgreSQL (client) | 1.0 | 1G |
| Redis | 0.5 | 256M |
| Nginx reverse proxy | 0.25 | 128M |
| LiveKit | 1.0 | 512M |
| ERPNext web | 1.0 | 1G |
| ERPNext worker | 1.0 | 1G |
| ERPNext DB | 1.0 | 1.5G |
| Utility/stats services | 0.25-0.5 | 128-256M |
Set via deploy.resources.limits.
Never hardcode secrets. Use a .env file in the stack directory (gitignored):
services:
app:
env_file:
- ./.env
environment:
- CELERY_BROKER_URL=redis://:${REDIS_PASSWORD}@redis:6379/0
The .env file must never be committed. Use variable substitution in Compose files.
All exposed ports must bind to 127.0.0.1 only — never 0.0.0.0 — unless there is a specific reason for external access.
| Port | Service | Stack |
|---|---|---|
| 5433 | PostgreSQL (main) | GP Booking |
| 5434 | PostgreSQL (SIAAS) | GP Booking |
| 5436 | PostgreSQL (ERPNext) | ERPNext |
| 6379 | Redis | GP Booking |
| 3000 | Forgejo web | Forgejo |
| 3002 | Wiki.js | Wiki |
| 3050 | Forgejo API | Forgejo |
| 3051 | Webhooks | Forgejo |
| 3443 | Wiki.js (alt) | Wiki |
| 8000 | Django app | GP Booking |
| 8001 | AES PKI | AES |
| 8002 | AES TSA | AES |
| 8003 | AES Signer | AES |
| 8004 | AES Portal | AES |
| 8005 | ERPNext web | ERPNext |
| 8006 | Security Dashboard | Security |
| 8080 | DefectDojo nginx | DefectDojo |
| 8081 | Patient Portal | Portal |
| 8082 | Roundcube | |
| 8443 | Keycloak | Auth |
| 7880-7881 | LiveKit | GP Booking |
| 9000 | MinIO | Infra |
| 1025 | MailHog SMTP | Dev |
| 8025 | MailHog UI | Dev |
| 8010 | Dental app (app engine; reached from the primary over the tun-apps-vps2 tunnel) |
Dental |
| 8011 | Dental app (primary VPS local runtime — retired 2026-09-11) | Dental |
| 4242 | Orthanc DICOM | Dental |
| 8042 | Orthanc REST (loopback only) | Dental |
| 8020 | Reserved — Pharmacy app (planned) | Pharmacy |
| 8030 | Reserved — Care home app (planned) | Care home |
| 5435 | Client DB tunnel — GP test-client | SIAAS |
| 5436 | Client DB tunnel — Dental test-client-dental | SIAAS |
| 5437 | Client DB tunnel — dev-client (mothballed) | SIAAS |
| 5438 | Client DB tunnel — spare/socat bridge | SIAAS |
All proxy_pass directives must target 127.0.0.1:PORT — never internal Docker IPs (172.x.x.x).
# Rebuild and restart a single stack
cd /path/to/stack/
docker compose build
docker compose up -d
# Check health
docker compose ps
docker compose logs --tail=20
.envA stack may be deployed to more than one host, so any value that differs per host must come
from the stack .env with a sensible default — never hardcoded in the compose file:
environment:
- DB_HOST=${DB_HOST:-gp_booking_postgres} # container name on the primary VPS,
- DB_PORT=${DB_PORT:-5432} # tunnel address on the app engine
ports:
- "127.0.0.1:${APP_PORT:-8010}:8000"
This was learned the hard way on 2026-09-11: the dental compose hardcoded the primary-VPS
database host name and published the app on a port the tunnel did not meet, so the same file
deployed to the app engine could not work — and the divergence went unnoticed until the
container was recreated. One portable file per stack; per-host values only in .env.
Never run two deployments of the same app at the same time, on the same host or across
hosts. Two runtimes against one database means two code versions writing the same records, and
when the database has been migrated by the newer one, the older is writing into a schema it was
not built for. This happened with the dental app (primary VPS and app engine both live, one
three migrations behind, both fronted by different vhosts) and it was only caught by audit.
Before starting a stack, check that no other deployment of the same app is running elsewhere:
# on each host, list the app's containers and confirm the other host's are stopped
docker ps --format '{{.Names}} {{.Status}}' | grep -i <app>
Retire the old deployment by stopping (never docker compose down) so rollback stays
available, and update any host-side self-healer/guard so it does not resurrect it — a guard
that still lists the retired containers as required will restart them within minutes.
Hand-copied trees drift silently — different ports, different database hosts, stale code, and
no history to diff against. Every runtime is deployed from the repository:
# on the host that runs the app
cd /root/work/<app>
git pull # the deployed branch
docker compose build
docker compose up -d
The primary VPS keeps the development working copy; the app host runs the deployed clone. The
working copy is not a runtime.
A healthcheck such as ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"] puts the
credential in container metadata, where it shows up in docker inspect, docker events and
any monitoring scrape. Pass it through the environment inside the container instead, and assert
on the expected reply so the check cannot pass on an auth error:
environment:
- REDIS_PASSWORD=${REDIS_PASSWORD:?must be set in .env}
healthcheck:
test: ["CMD-SHELL", "REDISCLI_AUTH=\"$$REDIS_PASSWORD\" redis-cli ping | grep -q PONG"]