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 edgeCopy deploy/ to the server, then:
cd deploy
docker login ghcr.io -u YOUR-GITHUB-USERNAME # read:packages tokenPoint 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 -dCaddy 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 .envFill in .env:
| Line | Value |
|---|---|
SCHOOL | Short lower-case name, e.g. adsu. It names the stack and the address Caddy points at (adsu-app). |
POSTGRES_PASSWORD | Long random text |
DATABASE_URL | The same password in place of CHANGE_ME |
SYNC_KEY | npm 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 -dThen 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/CaddyfileFinishing 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 psrequest_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.