openCBT

The cloud VPS

One server, one Caddy, one stack per school.

Each school's cloud companion is its own stack with its own database, reached at its own subdomain. One VPS holds several schools, the way a portal provider runs several institutions.

Once per server

A Hetzner CX22 (2 vCPU, 4 GB) carries several schools comfortably: a cloud companion serves a handful of lecturers, not a hall.

apt update && apt install -y docker.io docker-compose-v2
# The shared network Caddy and every school's app join
docker network create edge

Copy deploy/ to the server, then:

cd deploy
docker login ghcr.io -u YOUR-GITHUB-USERNAME   # read:packages token

Point a wildcard record at the server, or one record per school:

*.opencbt.example.com.  A  <server ip>

Set your address in the Caddyfile (the email line, for Let's Encrypt notices), then:

docker compose -f compose.caddy.yml up -d

Caddy is the only thing listening on 80 and 443, and it obtains and renews every certificate itself.

Per school

One directory per school keeps their settings, volumes and backups apart.

mkdir -p ~/schools/adsu && cd ~/schools/adsu
cp ~/deploy/compose.cloud.yml .
cp -r ~/deploy/scripts .
cp ~/deploy/cloud.env.example .env

Fill in .env:

LineValue
SCHOOLShort lower-case name, e.g. adsu. It names the stack and the address Caddy points at (adsu-app).
POSTGRES_PASSWORDLong random text
DATABASE_URLThe same password in place of CHANGE_ME
SYNC_KEYnpm run sync:key on your machine. The same value goes in the school's own .env — it is what proves a transfer came from that school.

No SUPER_ADMIN_*: every account, the principal included, arrives from the school's server on the first structure push. Seeding one here would give the cloud a second principal for the first push to shove aside.

docker compose -f compose.cloud.yml up -d

Then add a block to ~/deploy/Caddyfile:

adsu.opencbt.example.com {
	reverse_proxy adsu-app:3000 {
		health_uri /api/health
		health_interval 30s
		health_timeout 5s
	}
	request_body {
		max_size 60MB
	}
	encode zstd gzip
}

and reload:

cd ~/deploy
docker compose -f compose.caddy.yml exec caddy caddy reload --config /etc/caddy/Caddyfile

Finishing at the school

On the school's own server, set in its .env:

SYNC_KEY=<the same key>
CLOUD_URL="https://adsu.opencbt.example.com"

Restart it, open Cloud Sync, and press Test the connection. It reports the round trip and how far apart the clocks are. Then Send people and courses — until that runs, the cloud has no accounts and nobody can sign in there.

Checks

# the school's cloud, from the VPS
curl -s https://adsu.opencbt.example.com/api/health

# what its stack is doing
cd ~/schools/adsu && docker compose -f compose.cloud.yml ps

request_body max_size 60MB matters: a structure push for twelve thousand students is a few megabytes, and openCBT itself refuses anything over 50 MB. A proxy with a 1 MB default would break transfers with a confusing error.

Backups here

A VPS is always on, so each school's stack takes one every night into /data/backups, keeping BACKUP_KEEP of them, and they can be downloaded from that school's own Settings page. They are not off-site: add Hetzner's snapshots or your own copy job if you want that.

On this page