Safe OnlineExam
Deploy the application

Docker / VPS

Run the same container on any Linux host with Docker Compose — host prep, mounted secrets, the migration gate, a TLS reverse proxy, and the two-pass bootstrap.

This mode runs the same production image on any provider that gives you a persistent Linux host with Docker. You operate DNS, TLS, host security, backups, monitoring, and upgrades. The checked-in Compose topology includes the application, PostgreSQL, a one-shot migration job, an opt-in cleanup job, and a named data volume.

Use the mounted-secrets variant

These steps use .env.compose.secrets.example and the compose.secrets.yaml override so application secrets are mounted as files rather than stored as plain Compose environment values. This is the recommended posture for a real host.

Prepare the host

Use a supported Linux distribution with current security updates, Docker Engine, the Docker Compose v2 plugin, and age (or another approved backup-encryption tool). Then configure:

  • a stable DNS name pointing at the host;
  • inbound TCP 80 and 443 for certificate issuance and HTTPS;
  • SSH only from trusted administrative networks;
  • no public PostgreSQL port;
  • encrypted disks, automatic security updates, time sync, and off-host monitoring.
docker version
docker compose version
git clone YOUR_REPOSITORY_URL seb-canvas-lti
cd seb-canvas-lti

Build or select an image

Build locally with an immutable tag, or pull a trusted published image by digest. Never use an unpinned moving tag in production.

export APP_IMAGE="canvas-seb-lti:$(git rev-parse --short=12 HEAD)"
docker build --tag "$APP_IMAGE" .

# or, by digest:
# export APP_IMAGE="ghcr.io/your-organization/seb-canvas-lti@sha256:REPLACE"
# docker pull "$APP_IMAGE"

The same image runs the application, migrations, and cleanup.

Generate keys and prepare secrets

umask 077
cp .env.compose.secrets.example .env.secrets
chmod 600 .env.secrets
mkdir -p secrets
chmod 700 secrets

node scripts/generate-lti-private-key.mjs production >secrets/lti_private_key
openssl rand -base64 48 >secrets/database_password
openssl rand -base64 48 >secrets/session_secret
openssl rand -base64 48 >secrets/state_encryption_key
printf '%s' 'your-canvas-api-client-secret' >secrets/canvas_api_client_secret
: >secrets/seb_quit_password

mkdir -p .local
openssl rand -base64 48 >.local/docker-seb-p12-password
SEB_CERT_NAME=seb-config-encryption \
SEB_CERT_SUBJECT="/CN=Safe Online Exam Configuration Encryption/O=Organization" \
bash scripts/generate-seb-config-cert.sh \
  .local/docker-seb-certs \
  .local/docker-seb-p12-password
cp .local/docker-seb-certs/seb-config-encryption.crt.pem \
  secrets/seb-config-encryption.crt.pem

chmod 644 secrets/*

Only the public certificate stays on the host

Move the .p12, the private key, and the passphrase off the host into your device-deployment vault. The host keeps only secrets/seb-config-encryption.crt.pem. See Certificate management.

Edit .env.secrets and set at least:

  • APP_IMAGE — the exact tag or digest from Step 2;
  • TOOL_URL — the final HTTPS origin;
  • CANVAS_DOMAIN and the exact OAuth callback;
  • LTI_CLIENT_ID and LTI_DEPLOYMENT_ID — or bootstrap-pending for both during the first pass;
  • CANVAS_API_CLIENT_ID;
  • DATABASE_NAME=canvas_seb and DATABASE_USER=canvas_seb;
  • APP_ENV=prod, NODE_ENV=production, and the hardened defaults already present.

Validate and start

Check the resolved configuration without printing secrets, then bring up the stack. PostgreSQL starts, the one-shot migrate service runs, and app waits for it to succeed.

docker compose \
  --env-file .env.secrets \
  -f compose.yaml \
  -f compose.secrets.yaml \
  config --quiet

docker compose \
  --env-file .env.secrets \
  -f compose.yaml \
  -f compose.secrets.yaml \
  up --detach --wait

curl -fsS http://127.0.0.1:8080/health
curl -fsS http://127.0.0.1:8080/ready

The topology has four roles: postgres (with the persistent postgres_data volume, no published port), migrate (the one-shot gate), app (bound to loopback), and cleanup (opt-in maintenance).

Add HTTPS with a reverse proxy

Keep the app on loopback and terminate TLS in front of it. Example Caddyfile:

Caddyfile
seb.example.edu {
  reverse_proxy 127.0.0.1:8080
  header {
    Strict-Transport-Security "max-age=31536000; includeSubDomains"
  }
}

Set TOOL_URL=https://seb.example.edu and CANVAS_REDIRECT_URI=https://seb.example.edu/api/oauth2callback, reload the proxy, restart the app after any environment change, and verify the public origin:

curl -fsS https://seb.example.edu/health
curl -fsS https://seb.example.edu/ready
curl -fsS https://seb.example.edu/.well-known/jwks.json
curl -fsS https://seb.example.edu/lti/config
curl -fsS https://seb.example.edu/js/canvas-seb-detector.js | head

Do not expose port 8080 directly, publish port 5432, or use a plain-HTTP TOOL_URL.

Finish Canvas setup and recreate (pass 2)

Now that ${TOOL_URL}/lti/config is public, create and install the LTI Developer Key (Connect Canvas). Replace the two bootstrap-pending values in .env.secrets with the real client and deployment IDs, then recreate the app through the full dependency gate:

docker compose \
  --env-file .env.secrets \
  -f compose.yaml \
  -f compose.secrets.yaml \
  up --detach --wait

Schedule cleanup

Run cleanup at least daily — a root-owned systemd timer is preferred; cron is fine on a simple host. Test the exact command interactively first:

docker compose \
  --env-file .env.secrets \
  -f compose.yaml \
  -f compose.secrets.yaml \
  --profile maintenance run --rm cleanup

Example root crontab entry with absolute paths:

root crontab
17 3 * * * cd /opt/seb-canvas-lti && /usr/bin/docker compose --env-file .env.secrets -f compose.yaml -f compose.secrets.yaml --profile maintenance run --rm cleanup >>/var/log/canvas-seb-cleanup.log 2>&1

Protect and rotate the cleanup log, and alert when the scheduled command fails or stops running.

A simpler, non-mounted start

For evaluation or a hardened host that does not require file-mounted secrets, the project also supports a direct .env file:

docker build -t canvas-seb-lti:local .
cp .env.compose.example .env
chmod 600 .env
mkdir -p secrets
# set real values in .env, place the public cert at secrets/seb-config-encryption.crt.pem
APP_IMAGE=canvas-seb-lti:local docker compose up -d --wait
curl -fsS http://127.0.0.1:8080/health

Compose binds to loopback by default; still put a TLS reverse proxy in front before Canvas can reach it.

Day-2 operations

Encrypted backups and restore drills, upgrades, rollback, host recovery, and pool sizing are in Operations. Run the verification sequence before any real exam.

Next: Configuration reference · Certificate management · or jump to Connect Canvas.

On this page