VPS Build Plan

A

VPS Build Plan — EduNimbus Production

Live infrastructure at 167.233.208.4 · Coolify v4.1.2 · Docker Compose on Traefik · Cloudflare DNS

Infrastructure is live

Coolify is running at coolify.edunimbus.co.za· Projects created: “QCTO Submissions” and “SDP LMS System” · Traefik handles SSL for all *.edunimbus.co.za subdomains automatically.

Two-Project Architecture

P1

QCTO Submissions — QCTOCloud

Admin-facing compliance engine. Sold to SDPs for QCTO workflow management & NLRD submission.

qctocloud.<sdp>.co.za

dashboard.edunimbus.co.za (demo)

Stack: Next.js 15 + PostgreSQL 16 + Drizzle

P2

SDP LMS System — EduNimbus LMS

Learner-facing LMS. Up-sell to P1 clients. Offline-first PWA for load shedding resilience.

learner.<sdp>.co.za

lms-sdp.edunimbus.co.za (demo)

Stack: Nuxt 3 PWA + Directus 11 + PostgreSQL 16

POPIA architecture:Each college runs on a dedicated VPS with isolated Docker volumes. Cross-tenant data leakage is architecturally impossible. Learner PII never leaves the college's own server. This is the core sales argument against cloud-hosted competitors.

Live DNS Records — edunimbus.co.zaCloudflare · NS: becky + theo

RecordTargetProxiedPurpose
coolify.edunimbus.co.za167.233.208.4→ DirectCoolify panel — must be unproxied (direct)
dashboard.edunimbus.co.za167.233.208.4☁ ProxiedQCTOCloud demo/sales instance
api-qcto.edunimbus.co.za167.233.208.4→ DirectQCTOCloud API — unproxied for TLS passthrough
api-submissions.edunimbus.co.za167.233.208.4→ DirectQCTO MIS submission relay (future)
api-eisa.edunimbus.co.za167.233.208.4→ DirectEISA integration endpoint (future)
lms-sdp.edunimbus.co.za167.233.208.4☁ ProxiedLMS demo instance (Project 2)
api-lms.edunimbus.co.za167.233.208.4☁ ProxiedLMS Directus API (Project 2)
edunimbus.co.za167.233.208.4☁ ProxiedMarketing site (apex)

Important: coolify.edunimbus.co.za must stay unproxied (DNS only)in Cloudflare. Coolify uses WebSockets for real-time deployments — Cloudflare's proxy breaks these. API endpoints that need direct TLS passthrough should also be unproxied.

In Coolify → Projects → QCTO Submissions → + Add Resource → Docker Compose (Empty). Paste the compose below. Set all environment variables in Coolify's env panel — never in the compose file.

1

Go to Coolify → Projects

2

QCTO Submissions → + Add Resource

3

Docker Compose (Empty) → paste below

docker-compose.yml → paste into Coolify resource
# QCTOCloud — Coolify Docker Compose Resource
# Add via Coolify: Project → QCTO Submissions → + Add Resource → Docker Compose
# Coolify Traefik handles SSL automatically — no Nginx/Caddy needed
# Set all env vars in Coolify → Resource → Environment Variables (not in this file)

version: "3.9"

services:
  db:
    image: postgres:16-alpine
    restart: unless-stopped
    environment:
      POSTGRES_DB: ${DB_NAME:-qctocloud}
      POSTGRES_USER: ${DB_USER:-qcto}
      POSTGRES_PASSWORD: ${DB_PASSWORD}
    volumes:
      - pgdata:/var/lib/postgresql/data
    networks: [qctocloud_net]
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-qcto} -d ${DB_NAME:-qctocloud}"]
      interval: 10s
      timeout: 5s
      retries: 5
    expose: ["5432"]   # internal only — never expose DB port to host

  app:
    image: ghcr.io/edunimbus/qctocloud:${IMAGE_TAG:-latest}
    restart: unless-stopped
    environment:
      NODE_ENV: production
      DATABASE_URL: postgres://${DB_USER:-qcto}:${DB_PASSWORD}@db:5432/${DB_NAME:-qctocloud}
      NEXTAUTH_URL: https://${APP_DOMAIN}
      NEXTAUTH_SECRET: ${NEXTAUTH_SECRET}
    depends_on:
      db:
        condition: service_healthy
    networks: [qctocloud_net]
    expose: ["13000"]
    # Coolify Traefik labels (auto-added by Coolify — listed here for reference)
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.qctocloud-${TENANT_SLUG}.rule=Host(`${APP_DOMAIN}`)"
      - "traefik.http.routers.qctocloud-${TENANT_SLUG}.tls=true"
      - "traefik.http.routers.qctocloud-${TENANT_SLUG}.tls.certresolver=letsencrypt"
      - "traefik.http.services.qctocloud-${TENANT_SLUG}.loadbalancer.server.port=13000"
    healthcheck:
      test: ["CMD-SHELL", "curl -sf http://localhost:13000/api/health || exit 1"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 20s

volumes:
  pgdata:
    name: qctocloud_${TENANT_SLUG}_pgdata

networks:
  qctocloud_net:
    name: qctocloud_${TENANT_SLUG}_net
    driver: bridge

Tenant Resource Naming Convention

TENANT_SLUGApp DomainDB Volume NameStatus
edunimbus-demodashboard.edunimbus.co.zaqctocloud_edunimbus-demo_pgdataprovision
mzansi-trainingqctocloud.mzansitraining.co.zaqctocloud_mzansi-training_pgdatatemplate
cape-skillsqctocloud.capeskills.co.zaqctocloud_cape-skills_pgdatatemplate
<new-tenant>qctocloud.<sdp>.co.zaqctocloud_<slug>_pgdatatemplate

Volume names are deterministic: qctocloud_{TENANT_SLUG}_pgdata. Never delete a named volume without a verified backup. Coolify labels containers with the resource name automatically.

Project 2 — SDP LMS System (awareness)

The LMS (learner.<sdp>.co.za) is a separate up-sell product using Nuxt 3 PWA + Directus 11. It uses the same VPS and Coolify project “SDP LMS System” but is a completely independent stack. Engineering build plan is documented. Build begins after QCTOCloud auth is shipped.

Backend

Directus 11

Frontend

Nuxt 3 PWA

Offline

Dexie.js IndexedDB