Production deployment for TOSTI runs on a Docker Compose stack on a VM, orchestrated by .github/workflows/deploy.yaml. It fires after the CI workflow (test + lint + image build) succeeds on master.
The deploy runs on a self-hosted GitHub Actions runner installed on the VM itself. The runner polls GitHub over outbound HTTPS; GitHub never connects inbound. This was necessary because the CNCZ network blocks inbound SSH from the public internet, so a push-based model would have needed a VPN or an SSH hole.
| File | Purpose |
|---|---|
docker-compose.yml |
Service definitions: caddy, yivi, database, web, cron. All tuneable values come from .env. |
Caddyfile |
Caddy reverse proxy config for tosti.science.ru.nl and yivi.tosti.science.ru.nl. |
.env.example |
Template for the .env file the workflow writes on the VM. Never commit a real .env. |
- A
deploy-tostisystem user owns/opt/tosti/(mode 770, group-writable). - A
github-runnersystem user exists, in groupsdockeranddeploy-tostiso it can rundocker composeand write to/opt/tosti/. - Docker Engine + Compose plugin installed.
- The self-hosted runner is installed under
~github-runner/actions-runner/and managed by systemd (actions.runner.KiOui-TOSTI.tosti-vm.service). - SAML private key + public cert are under
/opt/tosti/saml/(overwritten by each deploy from the GitHub secrets). Docker Compose exposes them to thewebcontainer via Docker secrets, mounted at/run/secrets/saml_private_keyand/run/secrets/saml_public_cert. In non-swarm Compose, the secret file preserves the host file's mode, so the deploy workflow sets the host files to mode 644 (readable by the container'snobodyuser); the parent/opt/tostiis mode 770 owned bydeploy-tosti:deploy-tosti, which gates host-side access.
Follow GitHub's self-hosted runner instructions — they show the exact curl + ./config.sh commands for the current runner version. Use these settings:
- User to run as:
github-runner(create withuseradd --system --create-home --shell /bin/bash --groups docker,deploy-tosti github-runner). - Runner name:
tosti-vm. - Labels:
tosti-vm,production.tosti-vmidentifies the machine;productionis the tier thatdeploy.yaml'sruns-on:filters on. When a staging runner is added later, it would be labeledtosti-staging-vm,stagingand a staging workflow would useruns-on: [self-hosted, staging]. - Install as a service:
sudo ./svc.sh install github-runner && sudo ./svc.sh start.
The workflow runs against a GitHub Environment named tosti.science.ru.nl. Scope secrets to this Environment (Settings → Environments) so workflows on other branches cannot exfiltrate them. Add required reviewers for extra safety — every deploy waits for human approval.
| Secret | Purpose |
|---|---|
POSTGRES_PASSWORD |
Postgres password. |
DJANGO_SECRET_KEY |
Django secret key. |
YIVI_TOKEN |
Yivi server token. |
SENTRY_DSN |
Sentry DSN for error reporting. |
SAML_PRIVATE_KEY |
Full PEM of the SAML SP private key. |
SAML_PUBLIC_CERT |
Full PEM of the SAML SP public certificate. |
| Variable | Example | Purpose |
|---|---|---|
DEPLOY_DIR |
/opt/tosti |
Where the compose stack lives on the VM. |
DEPLOY_HOST |
tosti.vm.science.ru.nl |
VM's direct hostname; used by Caddy for the vhost redirect tosti.vm → tosti. |
DJANGO_HOSTNAME |
tosti.science.ru.nl |
Public hostname served by Caddy for the Django app. |
YIVI_HOSTNAME |
yivi.tosti.science.ru.nl |
Public hostname served by Caddy for the Yivi server. |
DJANGO_ALLOWED_HOSTS |
tosti.science.ru.nl |
Django's ALLOWED_HOSTS. |
SAML_ENTITY_ID |
tosti.science.ru.nl |
SAML entity ID. |
EMAIL_HOST |
smtp.science.ru.nl |
SMTP server. |
EMAIL_PORT |
25 |
SMTP port. |
EMAIL_ADDRESS |
www-tosti@science.ru.nl |
From/sender address; used for EMAIL_HOST_USER, EMAIL_DEFAULT_SENDER, DEFAULT_FROM_EMAIL, SERVER_EMAIL. |
Runs directly on the VM via the self-hosted runner:
- Checks out the commit that passed CI.
- Writes SAML key/cert from secrets into
$DEPLOY_DIR/saml/. - Copies
docker-compose.ymlandCaddyfilefrom the repo into$DEPLOY_DIR/. - Writes
$DEPLOY_DIR/.env(mode 600) from the Environment's secrets and vars. - Runs
docker compose pull && docker compose up -d --remove-orphans. - Polls
docker inspectuntil thewebcontainer reportshealthy; fails the job if it doesn't within ~5 minutes.
The CI image gets tagged by source:
| Source | Tags applied |
|---|---|
Push to master |
sha-<40-char-sha>, latest |
Published release v1.2.3 |
sha-<40-char-sha>, 1.2.3, 1.2, 1, latest (unless pre-release) |
Published pre-release v1.2.3-rc1 |
sha-<40-char-sha>, 1.2.3-rc1, 1.2, 1 (no latest) |
To cut a release: on GitHub → Releases → "Draft a new release" → create a new tag vX.Y.Z targeting master → click "Generate release notes" → publish. CI picks up the release: published event, runs test + lint, and pushes the image with the semver tags. The :latest tag moves forward too, so the next auto-deploy picks up the released build.
Deploys still trigger from master pushes only — releases don't trigger an extra deploy, they just add stable tags for rollback.
Every build is published with an immutable sha-<commit-sha> tag, and every release adds semver tags (1.2.3, 1.2, 1). To roll back:
ssh jdoesburg@<vm>
sudo -iu deploy-tosti
cd /opt/tosti
# Edit docker-compose.yml to pin a previous image. Prefer a semver tag if available:
# image: ghcr.io/kioui/tosti:1.2.3
# Or an exact commit:
# image: ghcr.io/kioui/tosti:sha-abc123...
docker compose up -d- The runner executes any code that lands on
master. Branch protection onmaster+ required reviewers on thetosti.science.ru.nlEnvironment are the main defenses. - Never set "Run workflows from fork pull requests" without approval — a malicious PR could exfiltrate secrets or run commands on the VM.
- The
github-runneruser should not have sudo. If it needs elevated privileges for something specific, use a tightly scoped sudoers rule.