Skip to content

Install on a VPS with Cloudflare

Needs review

Needs review: Prepared and validated locally (compose config, Caddy and cloudflared validation, shellcheck, a local run of install.sh with MinIO standing in for R2), not yet deployed to a VPS or run against a real Cloudflare account: do the first install on a staging domain and check the Cloudflare API steps, the R2 custom domain and the tunnel.

This is the reference production install: one VPS runs the whole platform with Docker Compose, Cloudflare provides DNS, TLS, the CDN cache and object storage (R2). It costs about EUR 10 to 25 a month and is the setup the product owner chose for ulams.app. The files are in deploy/vps-cloudflare/; this page embeds the important ones at the end, so the page and the files cannot drift apart. The decisions behind it are in ADR 0092.

browser ──TLS──> Cloudflare (DNS, TLS, cache, DDoS) ──tunnel (outbound only)──> cloudflared ─> Caddy ─┬─> web (Astro, Node)
│ ├─> admin (static, nginx)
│ ├─> api (php-fpm, Horizon, workers, scheduler)
└──> R2: files.<domain>, <slug>-files.<domain> (public read) ├─> h5p, pdf, mjml
ulams-backups (private) └─> postgres, valkey
Host (flat layout, the default) Serves Where
ulams.app platform landing (and the platform learner front) VPS, web
docs.ulams.app this documentation GitHub Pages or Cloudflare Pages
acme.ulams.app learner front of the tenant acme VPS, web
acme-admin.ulams.app, admin.ulams.app admin panel of a tenant, of the platform VPS, admin (or Cloudflare Pages)
acme-api.ulams.app, api.ulams.app tenant API (and /h5p/*), platform API VPS, api and h5p
acme-content.ulams.app, platform-content.ulams.app content origin for SCORM, cmi5, Adapt and LiaScript packages VPS, Caddy to api
files.ulams.app, acme-files.ulams.app public files of the platform and of a tenant R2 custom domains

Prices change; the figures below were read from Cloudflare’s documentation on 2026-10-09 or are marked as examples. Check the current price before you buy.

Item Monthly cost Source and notes
VPS, 4 vCPU and 8 GB, Ubuntu 24.04 or Debian 12 about EUR 8 to 20 Example: Hetzner Cloud shared-vCPU plans; others: OVHcloud VPS, Contabo, DigitalOcean. Example only, check the current price
Cloudflare plan USD 0 (Free) or USD 25 (Pro) Plans. Free is enough; Pro raises the upload limit to the same 100 MB, so it is no reason to upgrade
Cloudflare Tunnel USD 0 Tunnel docs
Universal SSL (flat hosts) USD 0 Universal SSL
Advanced Certificate Manager + Total TLS (nested hosts only) about USD 10 Advanced Certificate Manager; the docs do not list the price, check the dashboard
R2 storage USD 0.015 per GB-month; first 10 GB free R2 pricing (Standard storage)
R2 operations Class A (writes) USD 4.50 per million, Class B (reads) USD 0.36 per million; 1 and 10 million free same page
R2 egress USD 0 same page
Cloudflare Pages (admin and docs, optional) USD 0 Static asset requests are free and unlimited (Pages pricing)
SMTP provider USD 0 to 10 Any provider; most have a free tier for a few hundred mails a day. Example only
Domain about EUR 15 a year ulams.app is already bought
Total about EUR 10 to 25 VPS plus a few cents to a few euros of R2
  • A VPS with Ubuntu 24.04 LTS or Debian 12, 4 vCPU, 8 GB RAM and 80 GB SSD, with SSH access (key only) and sudo. The memory limits in compose.yml add up to 6.65 GB; see Sizing and scaling.
  • A domain whose DNS is on Cloudflare (a zone in your account, nameservers switched), for example ulams.app.
  • A Cloudflare account with R2 enabled (it asks for a payment method even when you stay in the free tier).
  • An SMTP account for outgoing e-mail (E-mail).
  • On your own machine: this repository (or the deploy/vps-cloudflare directory) and an SSH client.
  • Two API credentials, created in the dashboard: a Cloudflare API token for the setup script and an R2 API token (S3 access key) for the application. Cloudflare setup lists the permissions.
  1. Create the Cloudflare pieces first (about 15 minutes), so that the install can finish in one go: follow Cloudflare setup, either the checklist or scripts/cloudflare-setup.sh. You need the tunnel credentials, the R2 buckets and an R2 access key.

  2. Get the files onto the server.

    Terminal window
    ssh you@your-vps
    git clone --depth 1 https://github.com/ulams-dev/ulams.git
    cd ulams/deploy/vps-cloudflare

    Copy cloudflared/credentials.json there if cloudflare-setup.sh ran on another machine. For a dry run on your laptop first, scripts/install.sh --local starts the same stack on http://*.ulams.localhost:8480 with MinIO instead of R2 and changes nothing on the host.

  3. Run the installer.

    Terminal window
    sudo scripts/install.sh --domain ulams.app --admin-email you@example.com

    It stops at the first thing that needs you (R2 values, tunnel), says what, and can be run again. Every run does the same steps and skips what is done: packages and Docker; a ulams system user; ufw (SSH only, with the tunnel); automatic security updates; a 2 GB swap file if the host has none; the install directory /opt/ulams; .env from .env.example with generated secrets (it never overwrites a value that is set); the image tag pinned to sha-<short>; the images; the stack; the H5P configuration; checks; the daily backup timer. A re-run also runs ulams:upgrade.

  4. Fill in /opt/ulams/.env where the installer asked (S3_ENDPOINT, S3_KEY, S3_SECRET, the MAIL_* values) and run the installer again.

  5. Check it. The installer ends with checks through Caddy: api.<domain>/api/health, /h5p/health, the front and the admin, and that the platform administrator exists. Then open https://<domain> and https://admin.<domain> in a browser. The administrator’s password is PLATFORM_ADMIN_PASSWORD in .env (printed once on the first run); change it after the first sign-in.

  6. Store the secrets in a password manager: .env (above all APP_KEY, the Passport keys and BACKUP_PASSPHRASE). APP_KEY decrypts every tenant’s database password; without it a database backup is useless.

  7. Create the first tenant (First tenant), then the production switches.

Everything here can be done by hand (checklist) or with scripts/cloudflare-setup.sh, which reads CLOUDFLARE_API_TOKEN from the environment and never prints it. Run scripts/cloudflare-setup.sh --dry-run all to see the plan without a token.

API token (dashboard, My Profile, API Tokens, scope it to the zone and the account; an account-owned token works too, set CLOUDFLARE_ACCOUNT_ID so that the script can verify it): Zone: Zone Read, DNS Edit, Zone Settings Edit, SSL and Certificates Edit, Cache Rules Edit. Account: Cloudflare Tunnel Edit, Workers R2 Storage Edit.

Terminal window
export CLOUDFLARE_API_TOKEN=... # in your shell only, not in .env
export CLOUDFLARE_ACCOUNT_ID=...
scripts/cloudflare-setup.sh --docs-target ulams-dev.github.io all
Step Checklist (dashboard) Script step
SSL/TLS SSL/TLS, Overview: mode Full (strict). Edge Certificates: Always Use HTTPS on, minimum TLS 1.2, TLS 1.3 on ssl
Tunnel Zero Trust, Networks, Tunnels, create ulams-ulams-app (locally managed config: use the CLI or the script, so that the credentials file exists); the ingress is a single catch-all to http://caddy:80 (cloudflared/config.yml) tunnel
DNS Proxied (orange) CNAME records @ and * to <tunnel id>.cfargotunnel.com; docs as a CNAME to your Pages target (explicit records win over the wildcard) dns
R2 R2, create ulams and ulams-backups; on ulams: Settings, Custom Domains, files.<domain>; CORS: GET and HEAD from *; create an S3 API token with Admin Read and Write (Manage API tokens) r2 (the token is dashboard-only)
Cache rules Caching, Cache Rules: see Cache cache

Use it only when you cannot run cloudflared. Set ULAMS_ORIGIN_MODE=origin_ca and COMPOSE_PROFILES= in .env; run scripts/cloudflare-setup.sh --origin-ip <server ip> all (proxied A records instead of the tunnel, and Authenticated Origin Pulls); create an Origin CA certificate (SSL/TLS, Origin Server) for the apex and *.<domain>, save it as certs/origin.pem and certs/origin.key, and download authenticated_origin_pull_ca.pem into certs/. The installer then uses compose.origin.yml (port 443, mutual TLS) and scripts/cloudflare-firewall.sh, which lets only Cloudflare’s address ranges reach 443. Docker publishes ports around ufw, so the script also writes rules into the DOCKER-USER chain; systemd/ulams-firewall.service re-applies them after a reboot. The tunnel needs none of this, which is why it is the default.

With the tunnel there is no certificate on the server: Cloudflare terminates TLS (Universal SSL) and cloudflared carries plain HTTP to Caddy over the private Docker network. Two wildcard records (* and the apex) cover every platform host and every tenant, so creating a tenant needs no DNS change.

ULAMS_HOST_STYLE in .env selects the layout. install.sh writes everything that depends on it into the block between # BEGIN derived and # END derived (tenant host patterns for the API, the front and the admin, and the regular expressions Caddy routes by); do not edit that block.

flat (default) nested
Tenant hosts acme.d, acme-admin.d, acme-api.d, acme-content.d acme.d, acme.admin.d, acme.api.d, acme.content.d
Certificates Universal SSL, free Advanced Certificate Manager + Total TLS (about USD 10 a month)
DNS records @, * @, *, *.admin, *.api, *.content
Extra step none order Advanced Certificate Manager, then cloudflare-setup.sh --total-tls ssl

The content origin is a same-site subdomain, as decided in ADR 0014 (amended): the mitigations listed in Content origin apply unchanged. The Caddy CORS rule that keeps content origins out of the API’s allow-list follows the layout through ULAMS_RE_CONTENT_ORIGIN.

Bucket Public What it holds
ulams files.<domain> Files of the platform
ulams-<slug> <slug>-files.<domain> A tenant’s uploads: images, video, H5P content, s3-disk packages
ulams-backups no Output of scripts/backup.sh

How it is wired (all variables in .env): S3_ENDPOINT=https://<account id>.r2.cloudflarestorage.com, S3_REGION=auto, path-style addressing. R2 has no bucket policies, so TENANCY_S3_PUBLIC_READ_POLICY=false makes ulams:tenant:create skip that call, and TENANCY_BUCKET_PUBLIC_URL=https://{slug}-files.<domain> gives every tenant bucket its own public host. scripts/create-tenant.sh creates the bucket (through the S3 API) and attaches the custom domain (through the Cloudflare API, when CLOUDFLARE_API_TOKEN is set). The S3 key must be allowed to create buckets (Admin Read and Write). One key serves every tenant for now; per-tenant keys come with ADR 0041.

Private data never goes into a public bucket. Uploaded course sources stay on the Course Builder’s private local disk (the api_storage volume, which the backup includes). The objects in R2 are durable but not versioned and not part of backup.sh; mirror the buckets with rclone sync to another provider if you need that.

scripts/cloudflare-setup.sh cache creates two cache rules and leaves other rules alone (its own rules start with ulams: ):

  • Hashed front assets: any path under /_astro/ is cached for a year (the file names carry a content hash).
  • Public catalogue: anonymous GET /api/* on the API hosts is eligible for cache, but only where the API sends Cache-Control: public, s-maxage=... (the catalogue endpoints do, see api/config/http_cache.php; everything else says private). The rule bypasses requests with an Authorization header or a _token parameter, and passes Vary through (Host, Accept-Language, X-Locale), because Cloudflare ignores Vary unless a rule asks for it.

Static files of the admin (hashed JavaScript and CSS) are cached by Cloudflare’s default rules by extension. To turn the catalogue cache off, set HTTP_CACHE_PUBLIC=false on the API (the API then sends private everywhere).

Set the MAIL_* variables to any SMTP provider (host, port 587 with tls, user, password). Tenants send as no-reply@<slug>.<domain> with the tenant’s name, so add SPF, DKIM and DMARC for the sending domain at the provider (the provider’s setup page shows the DNS records; add them in the Cloudflare zone as DNS only). Cloudflare Email Routing can forward mail that arrives at @<domain> to a mailbox you read, but it is inbound only and cannot send, so it does not replace SMTP. Test with a password reset on the platform admin.

Terminal window
cd /opt/ulams
scripts/create-tenant.sh acme --name "Acme Academy"

It runs ulams:tenant:create (database and role, bucket, env file, migrations, keys, permissions) with demo mode off and no demo students, refreshes the H5P configuration, attaches the R2 domain, and prints the four hosts, the admin user (admin@acme.<domain>, password TENANT_INITIAL_PASSWORD) and a health check. Re-running it is safe. Slugs are 2 to 30 lowercase letters and digits; docs, files, content, status, mail, cdn and assets are reserved with the earlier ones. See First admin and first tenant for the platform admin panel.

  • Demo mode off. create-tenant.sh creates tenants with --demo=off. Demo mode (password-less login, hourly reset of the tenant, see Demo mode) must never be on in production. The compose file sets no demo tenants on the landing (ULAMS_DEMO_TENANTS empty). Check a tenant with docker compose exec api php artisan ulams:tenant:list.
  • Landing status. ULAMS_LANDING_STATUS=actual is the default in this compose file: the landing keeps the honest Coming and Partial labels. final renders everything as delivered; do not use it for a public launch (issue #92).
  • CSP. Both Content Security Policies start in report-only mode (CSP_ENFORCE=false, ULAMS_CSP_HEADER=Content-Security-Policy-Report-Only). After about a week of real traffic without unexpected rows in GET /api/admin/csp-reports (see Security headers), set CSP_ENFORCE=true and ULAMS_CSP_HEADER=Content-Security-Policy in .env and run docker compose up -d (issue #121).
  • Secrets. Change the platform administrator’s and the tenant admin’s passwords; keep .env in a password manager.
  • A restore drill (Backups) on a spare server, once, before real data arrives.

scripts/backup.sh runs daily from ulams-backup.timer (installed by install.sh; systemd/ulams-backup.cron if you have no systemd) and writes to the private ulams-backups bucket, under <domain>/<UTC time>/:

File Content
globals.sql PostgreSQL roles
ulams.dump, ulams_<slug>.dump pg_dump -Fc of the platform database and of every tenant database
volumes.tar.gz api_storage (keys, local disks) and h5p_libraries
secrets.tar.gz.enc .env, tunnel credentials and origin certificates, AES-256 with BACKUP_PASSPHRASE

Rotation deletes backups older than BACKUP_KEEP_DAYS (14). Check that it ran: systemctl list-timers ulams-backup.timer and journalctl -u ulams-backup. Restore (destructive, asks you to type RESTORE):

Terminal window
# same server
scripts/restore.sh --remote latest
# new server: install the files and docker, put S3_*/BACKUP_*/ULAMS_DOMAIN in a fresh .env, then
BACKUP_PASSPHRASE=... scripts/restore.sh --remote latest --restore-secrets

The passphrase and APP_KEY are the two things that must exist outside the server. The longer background is in Backups and restore.

Terminal window
cd /opt/ulams
git -C ~/ulams pull && sudo scripts/install.sh # new compose and scripts, if you want them
scripts/upgrade.sh latest # or a sha-<short> / release tag

upgrade.sh takes a backup, resolves and pins the new tag, pulls every image while the old containers keep serving, recreates the stateless services (pdf, mjml, web, admin, h5p) one at a time, then the api (its start migrates the platform and every tenant), then runs ulams:upgrade and the checks. The API answers 502 while it restarts, typically 30 to 90 seconds, and Cloudflare keeps serving cached catalogue responses; it is “zero-downtime-ish”, not zero. Rolling back is scripts/upgrade.sh <previous tag> (kept as ULAMS_VERSION_PREVIOUS); migrations are not reverted, so restore a backup to undo one. More in Upgrades.

  • Health checks. Every container has one (docker compose ps shows healthy). Public: https://api.<domain>/api/health and https://api.<domain>/h5p/health.
  • Uptime. Point an external monitor at those two URLs and at https://<domain>/healthz (for example the free tier of a hosted uptime service). Cloudflare’s own health shows tunnel status under Zero Trust, Networks, Tunnels.
  • Logs. docker compose logs -f --tail 100 api; Docker keeps 5 files of 20 MB per container. Caddy logs JSON with access tokens redacted. Set SENTRY_DSN (API) and ADMIN_SENTRY_DSN for error tracking. See Monitoring.
  • Disk, memory. docker stats --no-stream, df -h. Alert at 80% disk: the Docker volumes and the backups staging directory live on the same disk.

The installer does the first four; the rest is yours.

  • ufw denies everything except SSH (with the tunnel nothing else listens; ss -tlnp should show only sshd).
  • fail2ban for SSH; unattended security updates; the stack runs as containers, the deployment directory is owned by the ulams user and .env is mode 600.
  • Secrets are generated, never defaults; APP_KEY and the Passport keys are never regenerated by the scripts.
  • SSH: keys only, no root login. In /etc/ssh/sshd_config.d/10-ulams.conf put PasswordAuthentication no and PermitRootLogin no, check sshd -t, reload, and test a second session before closing the first.
  • Cloudflare: turn on 2FA on the account; scope API tokens (the setup token can be deleted after the install); consider a WAF rate-limiting rule on /api/auth/* and the platform login; enable “Block AI bots” or Bot Fight Mode if you do not want crawlers; keep the SSL mode on Full (strict).
  • Do not publish container ports. Do not run the optional MinIO or Adminer services on this host.
  • Uploads: virus scanning is optional (clamav profile of the generic example, see Security).
Symptom Check
Error 1016 or 530 The tunnel is down: docker compose ps cloudflared, docker compose logs cloudflared; wrong CLOUDFLARE_TUNNEL_ID or credentials
Error 502 or 521 Caddy or the api is not running: docker compose ps, docker compose logs caddy api
Browser certificate error on acme.admin.<domain> Nested layout without Total TLS: use the flat layout or order Advanced Certificate Manager
404 Not found from Caddy The host matches no pattern: compare the host with ULAMS_RE_* in .env; a slug has no hyphen and must be created (ulams:tenant:list)
404 from the API on a tenant host The tenant does not exist or its env file was lost: docker compose exec api php artisan ulams:tenant:sync-env
413 on an upload Cloudflare’s 100 MB request limit (Free and Pro). Smaller files or chunking; a plan with a higher limit
524 after about two minutes A request took longer than Cloudflare’s proxy timeout (125 s). Long jobs must run in the queue
Files do not load from <slug>-files.<domain> The R2 custom domain is missing or still initialising: R2, bucket, Settings; run cloudflare-setup.sh --slug <slug> bucket
NotImplemented on tenant create TENANCY_S3_PUBLIC_READ_POLICY is not false (R2 has no bucket policies), or the image is older than ADR 0092
The api never becomes healthy docker compose logs api (first start migrates); a wrong POSTGRES_PASSWORD after the volume exists: the password only applies when the data directory is created
Sessions break after a restart JWT_*_BASE64 or APP_KEY changed; restore them from the password manager
E-mail does not arrive MAIL_*, then SPF and DKIM; docker compose logs api horizon for SMTP errors
The front is slow on every page Each server-side API call goes through Cloudflare and back; check the cache rule for the catalogue and ULAMS_CACHE_TTL

The limits in compose.yml (PostgreSQL 1.5 GB, API 2.5 GB, H5P 0.75 GB, PDF, front 0.5 GB each, Valkey, MJML 0.25 GB each, Caddy and cloudflared 0.15 GB each, admin 0.1 GB) leave about 1.3 GB of an 8 GB host for the system. That is the profile of Requirements for a few tenants and about 100 concurrent learners; these are estimates, not load-test results. Watch docker stats: raise the API limit first if Horizon workers are killed (OOMKilled in docker inspect), and move to 16 GB before you add video-heavy tenants. When one host is not enough, the next steps are a bigger VPS, PostgreSQL on its own host (a managed service or a second VPS), then the topology in High availability: several API replicas behind the tunnel (run another cloudflared with the same credentials), shared object storage (already R2) and one scheduler lock (ADR 0068).

The admin is a static build, so Pages can serve it for free instead of the VPS. Build it from admin/ with corepack yarn workspace admin build and publish admin/dist to a Pages project. The runtime settings that the container writes at start (REACT_APP_TENANT_API_HOST_PATTERN, REACT_APP_API_URL) are baked in at build time instead; set them in the Pages build environment. Pages serves one site per hostname, while the admin needs *-admin.<domain>: attach a custom domain per tenant (acme-admin.<domain>) or keep the admin on the VPS. This is why the VPS is the default.

deploy/vps-cloudflare/scripts/ holds install.sh, upgrade.sh, backup.sh, restore.sh, create-tenant.sh, cloudflare-setup.sh, cloudflare-firewall.sh and validate.sh (offline checks: compose, Caddy in both layouts and modes, cloudflared, shellcheck). systemd/ has the timer and units.

compose.yml
# ulams production on one VPS behind Cloudflare (guide: docs site, Operators, "Install on a VPS
# with Cloudflare"; decision: docs/decisions/0092-vps-cloudflare-hosting-reference.md).
#
# Usage (scripts/install.sh does all of this): copy .env.example to .env, fill it in, then
# docker compose --env-file .env -f compose.yml up -d (Cloudflare Tunnel, default)
# docker compose --env-file .env -f compose.yml -f compose.origin.yml up -d (Origin CA + open 443)
# `docker compose config` needs the variables, so validate with `--env-file .env.example`.
#
# Differences from front/docs-site/examples/production (the generic single-server example):
# no TLS or ACME in Caddy (Cloudflare terminates TLS; the tunnel reaches Caddy over plain HTTP on
# the private Docker network), no object store container (Cloudflare R2), image tags pinned, memory
# limits sized for an 8 GB host, health checks on every service, host patterns from the env.
#
# Memory budget (limits, not reservations): postgres 1.5G, api 2.5G, h5p 0.75G, pdf 0.5G, web 0.5G,
# valkey 0.25G, mjml 0.25G, caddy 0.15G, cloudflared 0.15G, admin 0.1G = 6.65G, which leaves
# about 1.3G for the kernel, page cache, sshd and Docker on an 8 GB host.
name: ulams
x-service: &service
restart: unless-stopped
logging:
driver: json-file
options:
max-size: "20m"
max-file: "5"
volumes:
postgres_data:
valkey_data:
# Laravel storage/: platform and tenant Passport keys, logs, local disks (e.g. SCORM_DISK=local)
api_storage:
# What the H5P service reads: per tenant only the database, bucket and URL settings plus the
# Passport PUBLIC key (H5PServiceConfigExporter), written by the api service
h5p_service_config:
# Installed H5P libraries, shared by every tenant
h5p_libraries:
caddy_data:
caddy_config:
services:
# Routes by host name (all the patterns of one install come from the ULAMS_* variables) and adds
# the content-origin and CSP headers. Plain HTTP on :80 inside the network; nothing is published
# in tunnel mode. compose.origin.yml publishes 443 and mounts the Origin CA certificate.
caddy:
<<: *service
image: caddy:2.11.7-alpine
environment:
ULAMS_DOMAIN: ${ULAMS_DOMAIN:?set ULAMS_DOMAIN}
ULAMS_SITE_ADDRESS: ${ULAMS_SITE_ADDRESS:-http://}
ULAMS_ORIGIN_MODE: ${ULAMS_ORIGIN_MODE:-tunnel}
ULAMS_RE_FRONT: ${ULAMS_RE_FRONT:?set ULAMS_RE_FRONT}
ULAMS_RE_ADMIN: ${ULAMS_RE_ADMIN:?set ULAMS_RE_ADMIN}
ULAMS_RE_API: ${ULAMS_RE_API:?set ULAMS_RE_API}
ULAMS_RE_CONTENT: ${ULAMS_RE_CONTENT:?set ULAMS_RE_CONTENT}
ULAMS_PLATFORM_CONTENT_HOST: ${ULAMS_PLATFORM_CONTENT_HOST:?set ULAMS_PLATFORM_CONTENT_HOST}
ULAMS_API_HOST_OF_SLUG: ${ULAMS_API_HOST_OF_SLUG:?set ULAMS_API_HOST_OF_SLUG}
ULAMS_FRONT_HOST_OF_SLUG: ${ULAMS_FRONT_HOST_OF_SLUG:?set ULAMS_FRONT_HOST_OF_SLUG}
ULAMS_ADMIN_HOST_OF_SLUG: ${ULAMS_ADMIN_HOST_OF_SLUG:?set ULAMS_ADMIN_HOST_OF_SLUG}
ULAMS_RE_CONTENT_ORIGIN: ${ULAMS_RE_CONTENT_ORIGIN:?set ULAMS_RE_CONTENT_ORIGIN}
ULAMS_SCHEME: ${ULAMS_SCHEME:-https}
ULAMS_PORT_SUFFIX: ${ULAMS_PORT_SUFFIX:-}
# "on" tells php-fpm the request was HTTPS (Cloudflare terminated TLS); "off" for local runs
ULAMS_FCGI_HTTPS: ${ULAMS_FCGI_HTTPS:-on}
# admin CSP: report-only until the reports are clean (docs: operators/security-headers, #121)
ULAMS_CSP_HEADER: ${ULAMS_CSP_HEADER:-Content-Security-Policy-Report-Only}
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
healthcheck:
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1:2019/config/ || exit 1"]
interval: 15s
timeout: 5s
retries: 5
deploy:
resources:
limits:
memory: 150M
depends_on:
- api
- h5p
- web
- admin
# Cloudflare Tunnel: an outbound-only connection from this host to Cloudflare. DNS records point
# at <tunnel id>.cfargotunnel.com; every request arrives here and goes to Caddy. Credentials come
# from scripts/cloudflare-setup.sh (cloudflared/credentials.json, mode 600, not in git; the tunnel
# id is CLOUDFLARE_TUNNEL_ID in .env).
cloudflared:
<<: *service
image: cloudflare/cloudflared:2026.9.3
profiles: ["tunnel"]
command: ["tunnel", "--no-autoupdate", "--config", "/etc/cloudflared/config.yml", "--metrics", "0.0.0.0:2000", "run", "${CLOUDFLARE_TUNNEL_ID:-}"]
volumes:
- ./cloudflared/config.yml:/etc/cloudflared/config.yml:ro
- ./cloudflared/credentials.json:/etc/cloudflared/credentials.json:ro
healthcheck:
test: ["CMD", "cloudflared", "--metrics", "127.0.0.1:2000", "tunnel", "ready"]
interval: 30s
timeout: 5s
retries: 3
security_opt:
- no-new-privileges:true
deploy:
resources:
limits:
memory: 150M
depends_on:
- caddy
# Laravel API: php-fpm (port 9000), Horizon, tenant queue workers and the scheduler under
# supervisord (init.sh). On start it writes .env from the LARAVEL_* variables, migrates the
# platform, rebuilds the tenant env files and keys (ulams:tenant:sync-env --migrate) and seeds
# permissions and the first platform admin.
api:
<<: *service
image: ghcr.io/ulams-dev/api:${ULAMS_VERSION:?set ULAMS_VERSION}
environment:
# read by init.sh directly
JWT_PRIVATE_KEY_BASE64: ${JWT_PRIVATE_KEY_BASE64:?set JWT_PRIVATE_KEY_BASE64}
JWT_PUBLIC_KEY_BASE64: ${JWT_PUBLIC_KEY_BASE64:?set JWT_PUBLIC_KEY_BASE64}
# LARAVEL_<NAME> becomes <NAME> in the platform .env (docker/envs/envs.php)
LARAVEL_APP_NAME: ulams
LARAVEL_APP_ENV: production
LARAVEL_APP_DEBUG: "false"
LARAVEL_APP_KEY: ${APP_KEY:?set APP_KEY}
LARAVEL_APP_URL: ${ULAMS_SCHEME:-https}://api.${ULAMS_DOMAIN}${ULAMS_PORT_SUFFIX:-}
LARAVEL_FRONTEND_URL: ${ULAMS_SCHEME:-https}://${ULAMS_DOMAIN}${ULAMS_PORT_SUFFIX:-}
LARAVEL_ADMIN_URL: ${ULAMS_SCHEME:-https}://admin.${ULAMS_DOMAIN}${ULAMS_PORT_SUFFIX:-}
LARAVEL_DB_CONNECTION: pgsql
LARAVEL_DB_HOST: postgres
LARAVEL_DB_PORT: "5432"
LARAVEL_DB_DATABASE: ${POSTGRES_DB:-ulams}
LARAVEL_DB_USERNAME: ${POSTGRES_USER:-ulams}
LARAVEL_DB_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}
LARAVEL_REDIS_HOST: valkey
LARAVEL_REDIS_PORT: "6379"
LARAVEL_REDIS_PASSWORD: ${VALKEY_PASSWORD:?set VALKEY_PASSWORD}
LARAVEL_CACHE_DRIVER: redis
LARAVEL_SESSION_DRIVER: cookie
LARAVEL_QUEUE_DRIVER: redis
LARAVEL_QUEUE_CONNECTION: redis
LARAVEL_BROADCAST_DRIVER: log
# The platform is behind Cloudflare: public catalogue responses carry s-maxage and Vary
# (config/http_cache.php), which the cache rules of scripts/cloudflare-setup.sh use.
LARAVEL_HTTP_CACHE_PUBLIC: "true"
LARAVEL_MAIL_DRIVER: smtp
LARAVEL_MAIL_HOST: ${MAIL_HOST:-}
LARAVEL_MAIL_PORT: ${MAIL_PORT:-587}
LARAVEL_MAIL_USERNAME: ${MAIL_USERNAME:-}
LARAVEL_MAIL_PASSWORD: ${MAIL_PASSWORD:-}
LARAVEL_MAIL_ENCRYPTION: ${MAIL_ENCRYPTION:-tls}
LARAVEL_MAIL_FROM_ADDRESS: ${MAIL_FROM_ADDRESS:-no-reply@example.com}
LARAVEL_MAIL_FROM_NAME: ${MAIL_FROM_NAME:-ulams}
# e-mail templates are rendered by the mjml service (the API image has no mjml binary)
LARAVEL_MJML_API_URL: http://mjml:15500/v1
# Object storage: Cloudflare R2 (or any S3 API). Region "auto" and path-style addressing.
LARAVEL_FILESYSTEM_DRIVER: s3
LARAVEL_AWS_ACCESS_KEY_ID: ${S3_KEY:?set S3_KEY}
LARAVEL_AWS_SECRET_ACCESS_KEY: ${S3_SECRET:?set S3_SECRET}
LARAVEL_AWS_DEFAULT_REGION: ${S3_REGION:-auto}
LARAVEL_AWS_BUCKET: ${S3_PLATFORM_BUCKET:-ulams}
LARAVEL_AWS_ENDPOINT: ${S3_ENDPOINT:?set S3_ENDPOINT}
LARAVEL_AWS_URL: ${S3_PLATFORM_PUBLIC_URL:?set S3_PLATFORM_PUBLIC_URL}
LARAVEL_AWS_USE_PATH_STYLE_ENDPOINT: ${S3_USE_PATH_STYLE:-true}
LARAVEL_H5P_SERVICE_URL: http://h5p:8080
LARAVEL_H5P_INTERNAL_TOKEN: ${H5P_INTERNAL_TOKEN:?set H5P_INTERNAL_TOKEN}
LARAVEL_H5P_SERVICE_CONFIG_DIR: /var/www/html/storage/h5p-service
LARAVEL_PDF_SERVICE_URL: http://pdf:3000
LARAVEL_PDF_INTERNAL_TOKEN: ${PDF_INTERNAL_TOKEN:?set PDF_INTERNAL_TOKEN}
LARAVEL_CONTENT_ORIGIN: ${ULAMS_SCHEME:-https}://${ULAMS_PLATFORM_CONTENT_HOST:?set ULAMS_PLATFORM_CONTENT_HOST}${ULAMS_PORT_SUFFIX:-}
# First platform administrator (PermissionsSeeder, only while the users table is empty)
LARAVEL_INITIAL_USER_EMAIL: ${PLATFORM_ADMIN_EMAIL:?set PLATFORM_ADMIN_EMAIL}
LARAVEL_INITIAL_USER_PASSWORD: ${PLATFORM_ADMIN_PASSWORD:?set PLATFORM_ADMIN_PASSWORD}
LARAVEL_SENTRY_LARAVEL_DSN: ${SENTRY_DSN:-}
LARAVEL_SENTRY_ENVIRONMENT: ${SENTRY_ENVIRONMENT:-production}
# Tenancy (packages/tenancy): names and URLs of new tenants. The host patterns are the ones
# the Caddyfile and the front/admin are configured with (ULAMS_HOST_STYLE in .env.example).
LARAVEL_TENANCY_PLATFORM_HOSTS: api.${ULAMS_DOMAIN},caddy,api,localhost,127.0.0.1
LARAVEL_TENANCY_SCHEME: ${ULAMS_SCHEME:-https}
LARAVEL_TENANCY_API_HOST: ${ULAMS_API_PATTERN:?set ULAMS_API_PATTERN}
LARAVEL_TENANCY_FRONT_HOST: ${ULAMS_FRONT_PATTERN:?set ULAMS_FRONT_PATTERN}
LARAVEL_TENANCY_ADMIN_HOST: ${ULAMS_ADMIN_PATTERN:?set ULAMS_ADMIN_PATTERN}
LARAVEL_TENANCY_CONTENT_HOST: ${ULAMS_CONTENT_PATTERN:?set ULAMS_CONTENT_PATTERN}
LARAVEL_TENANCY_EMAIL_DOMAIN: ${TENANCY_EMAIL_DOMAIN:?set TENANCY_EMAIL_DOMAIN}
LARAVEL_TENANCY_STORAGE_PUBLIC_URL: ${S3_PLATFORM_PUBLIC_URL}
# R2 has no bucket policies and one public URL per bucket (ADR 0092)
LARAVEL_TENANCY_S3_PUBLIC_READ_POLICY: ${TENANCY_S3_PUBLIC_READ_POLICY:-false}
LARAVEL_TENANCY_BUCKET_PUBLIC_URL: ${TENANCY_BUCKET_PUBLIC_URL:-}
LARAVEL_TENANT_DEMO_PASSWORD: ${TENANT_INITIAL_PASSWORD:?set TENANT_INITIAL_PASSWORD}
LARAVEL_TENANCY_PLATFORM_API: ${TENANCY_PLATFORM_API:-false}
volumes:
- api_storage:/var/www/html/storage
- h5p_service_config:/var/www/html/storage/h5p-service
deploy:
resources:
limits:
memory: 2560M
depends_on:
postgres:
condition: service_healthy
valkey:
condition: service_healthy
mjml:
condition: service_started
pdf:
condition: service_started
# H5P player, editor and content API (GPL-3.0-or-later, see LICENSING.md). Multi-tenant: the
# request host selects the Laravel env file and the Passport public key.
h5p:
<<: *service
image: ghcr.io/ulams-dev/h5p:${ULAMS_VERSION:?set ULAMS_VERSION}
# group of the Laravel storage files (www-data, gid 82): public keys and env copies are 0640
group_add:
- "82"
environment:
PORT: "8080"
LOG_LEVEL: info
PUBLIC_URL: ${ULAMS_SCHEME:-https}://api.${ULAMS_DOMAIN}${ULAMS_PORT_SUFFIX:-}
TENANCY_MODE: env-files
ENV_DIR: /config
KEYS_DIR: /config/keys
PLATFORM_HOSTS: api.${ULAMS_DOMAIN}
CORS_ORIGINS: ${ULAMS_SCHEME:-https}://${ULAMS_DOMAIN}${ULAMS_PORT_SUFFIX:-},${ULAMS_SCHEME:-https}://admin.${ULAMS_DOMAIN}${ULAMS_PORT_SUFFIX:-},${ULAMS_SCHEME:-https}://api.${ULAMS_DOMAIN}${ULAMS_PORT_SUFFIX:-}
TENANT_FRONT_ORIGIN_PATTERNS: ${ULAMS_SCHEME:-https}://${ULAMS_FRONT_PATTERN:?set ULAMS_FRONT_PATTERN},${ULAMS_SCHEME:-https}://${ULAMS_ADMIN_PATTERN:?set ULAMS_ADMIN_PATTERN}
REDIS_HOST: valkey
REDIS_PORT: "6379"
REDIS_PASSWORD: ${VALKEY_PASSWORD}
REDIS_DB: "0"
REDIS_KEY_PREFIX: "h5p:"
# fallbacks; each tenant's bucket and credentials come from its env file
S3_ENDPOINT: ${S3_ENDPOINT}
S3_REGION: ${S3_REGION:-auto}
S3_KEY: ${S3_KEY}
S3_SECRET: ${S3_SECRET}
S3_BUCKET: ${S3_PLATFORM_BUCKET:-ulams}
S3_PREFIX: h5p
S3_FORCE_PATH_STYLE: ${S3_USE_PATH_STYLE:-true}
JWT_PUBLIC_KEY_PATH: /config/keys/oauth-public.key
# profile calls (GET /api/profile/me) go to Caddy's internal listener with the tenant host
LARAVEL_API_URL: http://caddy:8081
LARAVEL_API_HOST: api.${ULAMS_DOMAIN}
H5P_INTERNAL_TOKEN: ${H5P_INTERNAL_TOKEN}
H5P_MAX_FILE_SIZE_MB: "64"
H5P_MAX_TOTAL_SIZE_MB: "256"
read_only: true
tmpfs:
- /tmp
volumes:
- h5p_libraries:/data/libraries
- h5p_service_config:/config:ro
deploy:
resources:
limits:
memory: 768M
depends_on:
- api
- valkey
# PDF renderer (certificates); internal only. The image has its own health check.
pdf:
<<: *service
image: ghcr.io/ulams-dev/pdf:${ULAMS_VERSION:?set ULAMS_VERSION}
environment:
PORT: "3000"
LOG_LEVEL: info
PDF_INTERNAL_TOKEN: ${PDF_INTERNAL_TOKEN}
PDF_MAX_CONCURRENCY: "2"
PDF_MAX_QUEUE: "16"
security_opt:
- no-new-privileges:true
deploy:
resources:
limits:
memory: 512M
# Learner front and platform landing (Astro SSR, Node adapter; the image has a /healthz check).
# Server-side it calls the tenant API by its public host name, so the host must resolve it (the
# request goes through Cloudflare and back; see the guide).
web:
<<: *service
image: ghcr.io/ulams-dev/web:${ULAMS_VERSION:?set ULAMS_VERSION}
environment:
ULAMS_TENANT_HOSTS: "${ULAMS_FRONT_PATTERN:?set ULAMS_FRONT_PATTERN}=>${ULAMS_SCHEME:-https}://${ULAMS_API_PATTERN:?set ULAMS_API_PATTERN}${ULAMS_PORT_SUFFIX:-}"
ULAMS_ADMIN_URL: "${ULAMS_SCHEME:-https}://${ULAMS_ADMIN_PATTERN}${ULAMS_PORT_SUFFIX:-}"
ULAMS_PLATFORM_HOSTS: ${ULAMS_DOMAIN}
# Production is not a demo: no demo tenants on the landing, nothing warmed up at start
ULAMS_DEMO_TENANTS: ""
ULAMS_WARM_TENANTS: ""
ULAMS_DEFAULT_TENANT: ""
# `final` shows every roadmap item as delivered; set `actual` before the public launch (#92)
ULAMS_LANDING_STATUS: ${ULAMS_LANDING_STATUS:-actual}
# CSP of the pages: report-only until the reports are clean, then CSP_ENFORCE=true (#121)
CSP_ENFORCE: ${CSP_ENFORCE:-false}
ULAMS_COOKIE_SECURE: ${ULAMS_COOKIE_SECURE:-true}
ULAMS_CONTENT_ORIGIN: "${ULAMS_SCHEME:-https}://${ULAMS_CONTENT_PATTERN:?set ULAMS_CONTENT_PATTERN}${ULAMS_PORT_SUFFIX:-}"
ULAMS_STORAGE_ORIGINS: ${ULAMS_STORAGE_ORIGINS:-}
security_opt:
- no-new-privileges:true
deploy:
resources:
limits:
memory: 512M
# Admin and author panel (static build on nginx-unprivileged, port 8080); the tenant API comes
# from the host name. Alternative: Cloudflare Pages (guide, "Admin on Cloudflare Pages").
admin:
<<: *service
image: ghcr.io/ulams-dev/admin:${ULAMS_VERSION:?set ULAMS_VERSION}
environment:
REACT_APP_TENANT_API_HOST_PATTERN: "${ULAMS_ADMIN_PATTERN:?set ULAMS_ADMIN_PATTERN}=>${ULAMS_SCHEME:-https}://${ULAMS_API_PATTERN}${ULAMS_PORT_SUFFIX:-}"
REACT_APP_API_URL: ${ULAMS_SCHEME:-https}://api.${ULAMS_DOMAIN}${ULAMS_PORT_SUFFIX:-}
REACT_APP_SENTRYDSN: ${ADMIN_SENTRY_DSN:-}
healthcheck:
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1:8080/ || exit 1"]
interval: 30s
timeout: 5s
retries: 3
security_opt:
- no-new-privileges:true
deploy:
resources:
limits:
memory: 128M
postgres:
<<: *service
image: postgres:17.11-alpine
environment:
POSTGRES_DB: ${POSTGRES_DB:-ulams}
POSTGRES_USER: ${POSTGRES_USER:-ulams}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}
# sized for a shared 8 GB host: about 25% of the 1.5 GB limit for shared_buffers; one database
# and one role per tenant means many connections, so keep max_connections moderate
command: ["postgres", "-c", "shared_buffers=384MB", "-c", "effective_cache_size=1GB", "-c", "max_connections=150", "-c", "work_mem=8MB", "-c", "log_min_duration_statement=1000"]
shm_size: 256m
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U \"$$POSTGRES_USER\" -d \"$$POSTGRES_DB\""]
interval: 10s
timeout: 5s
retries: 10
deploy:
resources:
limits:
memory: 1536M
# Valkey (BSD-3-Clause), Redis protocol: queues, cache, locks. Persist it (queued jobs).
valkey:
<<: *service
image: valkey/valkey:8.1.10-alpine
command: ["sh", "-c", "exec valkey-server --requirepass \"$$VALKEY_PASSWORD\" --appendonly yes --maxmemory 192mb --maxmemory-policy noeviction"]
environment:
VALKEY_PASSWORD: ${VALKEY_PASSWORD:?set VALKEY_PASSWORD}
volumes:
- valkey_data:/data
healthcheck:
test: ["CMD-SHELL", "valkey-cli -a \"$$VALKEY_PASSWORD\" --no-auth-warning ping | grep -q PONG"]
interval: 10s
timeout: 5s
retries: 10
deploy:
resources:
limits:
memory: 256M
# MJML rendering for e-mail templates (internal only)
mjml:
<<: *service
image: danihodovic/mjml-server:4.15.3
security_opt:
- no-new-privileges:true
deploy:
resources:
limits:
memory: 256M
Caddyfile
# Caddyfile of the VPS + Cloudflare install (guide: Operators, "Install on a VPS with Cloudflare").
# Derived from front/docs-site/examples/production/Caddyfile. Differences:
#
# - No ACME, no on-demand TLS: Cloudflare terminates TLS. ULAMS_ORIGIN_MODE picks how Cloudflare
# reaches this Caddy: `tunnel` (cloudflared over the Docker network, plain HTTP, nothing
# published) or `origin_ca` (Cloudflare Origin CA certificate on :443 plus Authenticated Origin
# Pulls, compose.origin.yml).
# - One catch-all site. The host patterns are regular expressions from the environment, so the
# flat layout (acme.D, acme-admin.D, acme-api.D, acme-content.D) and the nested one
# (acme.D, acme.admin.D, acme.api.D, acme.content.D) use the same file. The environment block that
# scripts/install.sh derives from ULAMS_HOST_STYLE holds them; each regex has ONE capture group,
# the tenant slug. Unknown tenants get 404 from the API (TENANCY_ENFORCE_HOSTS).
# - Platform hosts: D (landing and learner front), api.D, admin.D, platform-content.D (flat) or
# platform.content.D (nested). docs.D and files.D are not served here (Pages/GitHub Pages and R2).
{
auto_https off
servers {
# Cloudflare (and cloudflared, which connects from a private address) set these headers
trusted_proxies static private_ranges 173.245.48.0/20 103.21.244.0/22 103.22.200.0/22 103.31.4.0/22 141.101.64.0/18 108.162.192.0/18 190.93.240.0/20 188.114.96.0/20 197.234.240.0/22 198.41.128.0/17 162.158.0.0/15 104.16.0.0/13 104.24.0.0/14 172.64.0.0/13 131.0.72.0/22 2400:cb00::/32 2606:4700::/32 2803:f800::/32 2405:b500::/32 2405:8100::/32 2a06:98c0::/29 2c0f:f248::/32
client_ip_headers CF-Connecting-IP X-Forwarded-For
}
}
(tunnel) {
header -X-Powered-By
}
# Origin CA certificate + Authenticated Origin Pulls: only Cloudflare can complete the handshake
(origin_ca) {
tls /certs/origin.pem /certs/origin.key {
client_auth {
mode require_and_verify
trusted_ca_cert_file /certs/authenticated_origin_pull_ca.pem
}
}
}
# Content origin (api/docs/content-origin.md): package files only, read-only, own CSP, no
# cookies. Arguments: <API host> <API origin> <frame ancestors>
(content_origin) {
header {
Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; media-src 'self' blob:; font-src 'self' data:; connect-src 'self' {args[1]}; frame-ancestors 'self' {args[2]}; form-action 'none'; base-uri 'self'; object-src 'none'; report-uri {args[1]}/api/csp-report; report-to csp-endpoint"
Reporting-Endpoints `csp-endpoint="{args[1]}/api/csp-report"`
>X-Content-Type-Options nosniff
# Own browsing-context group (no window.opener link to the app); public package files
# that players in sandboxed frames (opaque origin, every request cross-origin) may load.
# No credentials ever travel to this origin.
>Cross-Origin-Opener-Policy same-origin
>Cross-Origin-Resource-Policy cross-origin
>Access-Control-Allow-Origin *
Referrer-Policy no-referrer
-Set-Cookie
-Server
}
@package {
method GET HEAD
path /scorm/* /cmi5/* /adapt/* /liascript/*
}
handle @package {
request_header -Cookie
request_header -Authorization
rewrite * /api/content{uri}
reverse_proxy api:9000 {
header_up Host {args[0]}
header_up X-Forwarded-Host {args[0]}
header_up X-Ulams-Content-Origin 1
header_down -Set-Cookie
header_down -Access-Control-Allow-Origin
transport fastcgi {
env SCRIPT_FILENAME /var/www/html/index.php
env HTTPS {$ULAMS_FCGI_HTTPS}
env HTTP_HOST {args[0]}
env SERVER_NAME {args[0]}
env REQUEST_URI {http.request.uri}
}
}
}
handle {
respond "Not found" 404
}
}
# CSP of the admin. The learner front sets its own in front/web (its middleware names the tenant's
# registered tool origins in frame-src, ADR 0044). The admin is staff only and frames tools for deep
# linking, so its frame-src allows https:. Report-only until ULAMS_CSP_HEADER=Content-Security-Policy
# (after about a week without unexpected reports: GET /api/admin/csp-reports, #121). Arguments: <API origin>
(app_csp_admin) {
header {$ULAMS_CSP_HEADER:Content-Security-Policy-Report-Only} "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' data: https://fonts.gstatic.com; img-src 'self' data: blob: https:; media-src 'self' blob: https:; connect-src 'self' {$ULAMS_SCHEME}://*.{$ULAMS_DOMAIN}{$ULAMS_PORT_SUFFIX}; frame-src 'self' {$ULAMS_SCHEME}://*.{$ULAMS_DOMAIN}{$ULAMS_PORT_SUFFIX} https:; frame-ancestors 'self'; object-src 'none'; base-uri 'self'; report-uri {args[0]}/api/csp-report; report-to csp-endpoint"
header Reporting-Endpoints `csp-endpoint="{args[0]}/api/csp-report"`
}
# Laravel API and the H5P service on one host
(api) {
encode zstd gzip
# Progress tracking of the package players, called from the content origin or from a
# sandboxed frame (`Origin: null`). They authenticate with the scoped X-Ulams-Tracking-Token
# header, never with cookies: any origin may call them, without credentials, and no cookie
# is passed on. Every other API path refuses to reflect a content origin.
@tracking path /api/scorm/content/* /api/liascript/progress/*
handle @tracking {
request_header -Cookie
request_header -Authorization
header Access-Control-Allow-Origin *
@tracking_preflight method OPTIONS
handle @tracking_preflight {
header {
Access-Control-Allow-Methods "GET, POST, OPTIONS"
Access-Control-Allow-Headers "Content-Type, X-Ulams-Tracking-Token"
Access-Control-Max-Age 600
}
respond 204
}
handle {
reverse_proxy api:9000 {
header_up -Access-Control-Allow-Origin
header_down -Access-Control-Allow-Origin
header_down -Set-Cookie
transport fastcgi {
env SCRIPT_FILENAME /var/www/html/index.php
env HTTPS {$ULAMS_FCGI_HTTPS}
}
}
}
}
# cmi5 AUs on the content origin (ADR 0046): the one-time launch token in the fetch URL and the LRS
# session token (`Authorization: Basic ulrs1.<...>`, valid on the LRS only) are the credentials, never
# cookies. Any origin may call these routes without credentials; no cookie is passed on, and
# Authorization stays because the session token travels in it. A `*` does not cross a `/`, so the
# two-segment documents (activities/state, agents/profile) need their own pattern.
@cmi5 path /api/cmi5/fetch /trax/api/*/xapi/std/* /trax/api/*/xapi/std/*/*
handle @cmi5 {
request_header -Cookie
header Access-Control-Allow-Origin *
header Access-Control-Expose-Headers "ETag, Last-Modified, X-Experience-API-Consistent-Through, X-Experience-API-Version"
@cmi5_preflight method OPTIONS
handle @cmi5_preflight {
header {
Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS"
Access-Control-Allow-Headers "Content-Type, Authorization, X-Experience-API-Version, If-Match, If-None-Match, Accept"
Access-Control-Max-Age 600
}
respond 204
}
handle {
reverse_proxy api:9000 {
header_up -Access-Control-Allow-Origin
header_down -Access-Control-Allow-Origin
header_down -Set-Cookie
transport fastcgi {
env SCRIPT_FILENAME /var/www/html/index.php
env HTTPS {$ULAMS_FCGI_HTTPS}
}
}
}
}
# Browsers report CSP violations of any page, content origins included, without credentials
# (ADR 0044): any origin may POST, nothing else is opened here.
@csp_report path /api/csp-report
handle @csp_report {
request_header -Cookie
request_header -Authorization
header Access-Control-Allow-Origin *
@csp_report_preflight method OPTIONS
handle @csp_report_preflight {
header {
Access-Control-Allow-Methods "POST, OPTIONS"
Access-Control-Allow-Headers "Content-Type"
Access-Control-Max-Age 600
}
respond 204
}
handle {
reverse_proxy api:9000 {
header_up -Access-Control-Allow-Origin
header_down -Access-Control-Allow-Origin
header_down -Set-Cookie
transport fastcgi {
env SCRIPT_FILENAME /var/www/html/index.php
env HTTPS {$ULAMS_FCGI_HTTPS}
}
}
}
}
@h5p path /h5p /h5p/*
handle @h5p {
request_body {
max_size 300MB
}
reverse_proxy h5p:8080 {
header_up X-Forwarded-Host {host}
transport http {
read_timeout 10m
write_timeout 10m
}
}
}
@large_uploads path /api/admin/scorm/upload /api/admin/scorm/parse /api/admin/cmi5 /api/admin/courses/zip/import
handle @large_uploads {
request_body {
max_size 1100MB
}
reverse_proxy api:9000 {
header_up -Access-Control-Allow-Origin
header_down -Access-Control-Allow-Origin
transport fastcgi {
env SCRIPT_FILENAME /var/www/html/index.php
env HTTPS {$ULAMS_FCGI_HTTPS}
}
}
@origin_upload {
header Origin *
not header_regexp Origin {$ULAMS_RE_CONTENT_ORIGIN}
}
header @origin_upload Access-Control-Allow-Origin {http.request.header.Origin}
header @origin_upload Access-Control-Allow-Credentials true
}
# Only the content origin's proxy may set this header
request_header -X-Ulams-Content-Origin
handle {
request_body {
max_size 600MB
}
reverse_proxy api:9000 {
header_up -Access-Control-Allow-Origin
header_down -Access-Control-Allow-Origin
transport fastcgi {
env SCRIPT_FILENAME /var/www/html/index.php
env HTTPS {$ULAMS_FCGI_HTTPS}
}
}
@origin {
header Origin *
not header_regexp Origin {$ULAMS_RE_CONTENT_ORIGIN}
}
header @origin Access-Control-Allow-Origin {http.request.header.Origin}
@excluded {
not header Origin {http.request.header.Origin}
}
header @excluded Access-Control-Allow-Origin *
header @origin Access-Control-Allow-Credentials true
}
}
(web) {
# The CSP of the pages comes from the front itself (front/web/src/middleware.ts)
encode zstd gzip
# The BFF and studio JSON is for this front only: a page of another origin (the content
# origin runs third-party code) cannot load it in no-cors mode.
header /bff/* Cross-Origin-Resource-Policy same-origin
header /studio/api/* Cross-Origin-Resource-Policy same-origin
reverse_proxy web:4321
}
(admin) {
import app_csp_admin {args[0]}
encode zstd gzip
reverse_proxy admin:8080
}
{$ULAMS_SITE_ADDRESS} {
import {$ULAMS_ORIGIN_MODE}
# H5P core sends the access token as ?_token=: keep it and the auth headers out of the log
log {
format filter {
wrap json
fields {
request>uri query {
replace _token REDACTED
}
request>headers>Authorization delete
request>headers>X-Internal-Token delete
}
}
}
@platform_front host {$ULAMS_DOMAIN}
@platform_api host api.{$ULAMS_DOMAIN}
@platform_admin host admin.{$ULAMS_DOMAIN}
@platform_content host {$ULAMS_PLATFORM_CONTENT_HOST}
@tenant_front vars_regexp slug {http.request.host} {$ULAMS_RE_FRONT}
@tenant_api vars_regexp slug {http.request.host} {$ULAMS_RE_API}
@tenant_admin vars_regexp slug {http.request.host} {$ULAMS_RE_ADMIN}
@tenant_content vars_regexp slug {http.request.host} {$ULAMS_RE_CONTENT}
handle @platform_front {
import web
}
handle @platform_api {
import api
}
handle @platform_admin {
import admin {$ULAMS_SCHEME}://api.{$ULAMS_DOMAIN}{$ULAMS_PORT_SUFFIX}
}
# Platform content origin -> platform API
handle @platform_content {
import content_origin api.{$ULAMS_DOMAIN} {$ULAMS_SCHEME}://api.{$ULAMS_DOMAIN}{$ULAMS_PORT_SUFFIX} "{$ULAMS_SCHEME}://{$ULAMS_DOMAIN}{$ULAMS_PORT_SUFFIX} {$ULAMS_SCHEME}://admin.{$ULAMS_DOMAIN}{$ULAMS_PORT_SUFFIX}"
}
handle @tenant_front {
import web
}
handle @tenant_api {
import api
}
handle @tenant_admin {
# its CSP reports go to the tenant API
import admin {$ULAMS_SCHEME}://{$ULAMS_API_HOST_OF_SLUG}{$ULAMS_PORT_SUFFIX}
}
# Tenant content origin -> the tenant API of the same slug
handle @tenant_content {
import content_origin {$ULAMS_API_HOST_OF_SLUG} {$ULAMS_SCHEME}://{$ULAMS_API_HOST_OF_SLUG}{$ULAMS_PORT_SUFFIX} "{$ULAMS_SCHEME}://{$ULAMS_FRONT_HOST_OF_SLUG}{$ULAMS_PORT_SUFFIX} {$ULAMS_SCHEME}://{$ULAMS_ADMIN_HOST_OF_SLUG}{$ULAMS_PORT_SUFFIX}"
}
handle {
respond "Not found" 404
}
}
# Internal listener (not published): the H5P service calls GET /api/profile/me here with the
# tenant API host in the Host header (LARAVEL_API_URL=http://caddy:8081).
http://:8081 {
request_header -X-Ulams-Content-Origin
reverse_proxy api:9000 {
transport fastcgi {
env SCRIPT_FILENAME /var/www/html/index.php
env HTTPS {$ULAMS_FCGI_HTTPS}
}
}
}
cloudflared/config.yml
# cloudflared (Cloudflare Tunnel), locally managed. Read by the `cloudflared` service of compose.yml.
#
# One catch-all rule: every host name whose DNS record points at the tunnel
# (<tunnel id>.cfargotunnel.com, proxied) is sent to Caddy, which routes by host. The routing logic
# and the list of valid hosts therefore live in the Caddyfile and in DNS, not here, and a new tenant
# needs no change to this file (the wildcard DNS record already covers it).
#
# `tunnel` and `credentials-file` come from scripts/cloudflare-setup.sh, which creates the tunnel and
# writes cloudflared/credentials.json (mode 600, never committed). The tunnel id is not a secret but
# is not needed here: the credentials file carries it.
#
# Validate: docker run --rm -v "$PWD/cloudflared:/etc/cloudflared:ro" cloudflare/cloudflared:2026.9.3 \
# tunnel --config /etc/cloudflared/config.yml ingress validate
credentials-file: /etc/cloudflared/credentials.json
no-autoupdate: true
originRequest:
connectTimeout: 10s
# the 100 s Cloudflare proxy limit applies on the edge side; keep idle upstream connections open
# for the course builder's server-sent events
keepAliveTimeout: 120s
keepAliveConnections: 100
ingress:
- service: http://caddy:80
.env.example
# ulams on a VPS behind Cloudflare. Copy to .env next to compose.yml (scripts/install.sh does this and
# fills every GENERATE value). Never commit the filled-in file; keep a copy in a password manager.
# Quote values that contain $ or { } with single quotes, as below.
# ---------------------------------------------------------------------------------------------
# Release
# ---------------------------------------------------------------------------------------------
# Image tag of ghcr.io/ulams-dev/{api,h5p,pdf,web,admin}. `latest` is only the starting value: install.sh
# resolves it to the sha-<short> tag of the newest main build and writes that tag here, so the install is
# pinned and upgrades are deliberate (scripts/upgrade.sh [tag|latest]). A release tag (v1.2.3) works too.
ULAMS_VERSION=latest
# ---------------------------------------------------------------------------------------------
# Domain and Cloudflare
# ---------------------------------------------------------------------------------------------
# The domain on Cloudflare. Platform hosts: ULAMS_DOMAIN (landing and learner front), api.<domain>,
# admin.<domain>. docs.<domain> (Pages) and files.<domain> (R2 custom domain) are not served by this host.
ULAMS_DOMAIN=ulams.app
# Host layout of the tenants (ADR 0092):
# flat acme.<domain> acme-admin.<domain> acme-api.<domain> acme-content.<domain>
# (default: all covered by Cloudflare's free Universal SSL certificate)
# nested acme.<domain> acme.admin.<domain> acme.api.<domain> acme.content.<domain>
# (the layout of issue #24; needs Advanced Certificate Manager + Total TLS on Cloudflare)
ULAMS_HOST_STYLE=flat
# How Cloudflare reaches this host: `tunnel` (default, nothing listens on the public internet) or
# `origin_ca` (add compose.origin.yml, an Origin CA certificate and scripts/cloudflare-firewall.sh).
ULAMS_ORIGIN_MODE=tunnel
# tunnel: COMPOSE_PROFILES=tunnel starts cloudflared. origin_ca: leave it empty.
COMPOSE_PROFILES=tunnel
# Set by scripts/cloudflare-setup.sh (or copy it from the Zero Trust dashboard)
CLOUDFLARE_TUNNEL_ID=GENERATE
CLOUDFLARE_ACCOUNT_ID=CHANGE
# CLOUDFLARE_API_TOKEN is read from the environment by scripts/cloudflare-setup.sh and
# scripts/create-tenant.sh. Do not put it in this file.
# ---------------------------------------------------------------------------------------------
# Derived hosts (written by scripts/install.sh from ULAMS_DOMAIN and ULAMS_HOST_STYLE: do not edit)
# ---------------------------------------------------------------------------------------------
# BEGIN derived
ULAMS_SCHEME=https
ULAMS_PORT_SUFFIX=
ULAMS_FCGI_HTTPS=on
ULAMS_SITE_ADDRESS='http://'
ULAMS_FRONT_PATTERN='{slug}.ulams.app'
ULAMS_ADMIN_PATTERN='{slug}-admin.ulams.app'
ULAMS_API_PATTERN='{slug}-api.ulams.app'
ULAMS_CONTENT_PATTERN='{slug}-content.ulams.app'
ULAMS_PLATFORM_CONTENT_HOST='platform-content.ulams.app'
TENANCY_EMAIL_DOMAIN='{slug}.ulams.app'
ULAMS_RE_FRONT='^([a-z][a-z0-9]*)[.]ulams[.]app$'
ULAMS_RE_ADMIN='^([a-z][a-z0-9]*)-admin[.]ulams[.]app$'
ULAMS_RE_API='^([a-z][a-z0-9]*)-api[.]ulams[.]app$'
ULAMS_RE_CONTENT='^([a-z][a-z0-9]*)-content[.]ulams[.]app$'
ULAMS_RE_CONTENT_ORIGIN='^(null|https?://[^/:]+-content[.]ulams[.]app(:[0-9]+)?)$'
ULAMS_API_HOST_OF_SLUG='{re.slug.1}-api.ulams.app'
ULAMS_FRONT_HOST_OF_SLUG='{re.slug.1}.ulams.app'
ULAMS_ADMIN_HOST_OF_SLUG='{re.slug.1}-admin.ulams.app'
# END derived
# ---------------------------------------------------------------------------------------------
# Secrets (install.sh generates every GENERATE value once and never overwrites a value that is set)
# ---------------------------------------------------------------------------------------------
# Encrypts tenant secrets in the tenants table: losing or changing it makes every tenant
# unrecoverable (ADR 0066). Back it up apart from the data. Manual: echo "base64:$(openssl rand -base64 32)"
APP_KEY=GENERATE
# Platform Passport key pair, base64 of the PEM files on one line. Manual:
# openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:4096 -out oauth-private.key
# openssl rsa -in oauth-private.key -pubout -out oauth-public.key
# base64 < oauth-private.key | tr -d '\n'
JWT_PRIVATE_KEY_BASE64=GENERATE
JWT_PUBLIC_KEY_BASE64=GENERATE
# PostgreSQL superuser and platform database. The platform connects with this role and creates one
# role and one database per tenant (ulams_<slug>). Manual: openssl rand -hex 24
POSTGRES_DB=ulams
POSTGRES_USER=ulams
POSTGRES_PASSWORD=GENERATE
VALKEY_PASSWORD=GENERATE
# Shared secrets for API -> service calls (X-Internal-Token). Manual: openssl rand -hex 32
H5P_INTERNAL_TOKEN=GENERATE
PDF_INTERNAL_TOKEN=GENERATE
# First platform administrator, created on the first start while the users table is empty.
# install.sh generates the password and prints it once; change it after the first login.
PLATFORM_ADMIN_EMAIL=admin@example.com
PLATFORM_ADMIN_PASSWORD=GENERATE
# Initial password of the admin, tutor and demo students that `ulams:tenant:create` creates in every
# new tenant (TENANT_DEMO_PASSWORD). Generated; change the passwords after provisioning.
TENANT_INITIAL_PASSWORD=GENERATE
# ---------------------------------------------------------------------------------------------
# Object storage: Cloudflare R2 (S3 API). Layout and token permissions: guide, "R2", and ADR 0092.
# ulams platform bucket, public through the custom domain files.<domain>
# ulams-<slug> one bucket per tenant, public through <slug>-files.<domain> (create-tenant.sh)
# ulams-backups private, written by scripts/backup.sh
# ---------------------------------------------------------------------------------------------
# https://<ACCOUNT_ID>.r2.cloudflarestorage.com ; region is always `auto`
S3_ENDPOINT=https://CHANGE.r2.cloudflarestorage.com
S3_REGION=auto
# An R2 API token with "Admin Read & Write" (it must create buckets for new tenants)
S3_KEY=CHANGE
S3_SECRET=CHANGE
S3_PLATFORM_BUCKET=ulams
S3_USE_PATH_STYLE=true
# Browser-facing URL of the platform bucket (custom domain; no bucket name in the path)
S3_PLATFORM_PUBLIC_URL=https://files.ulams.app
# Browser-facing URL pattern of a tenant bucket. Empty = <S3_PLATFORM_PUBLIC_URL>/<bucket> (MinIO, SeaweedFS)
TENANCY_BUCKET_PUBLIC_URL='https://{slug}-files.ulams.app'
# R2 has no bucket policies; public read comes from the custom domain. true = also call PutBucketPolicy (MinIO).
TENANCY_S3_PUBLIC_READ_POLICY=false
# Origins that serve uploaded files besides the tenant API (CSP frame-src/img-src), comma separated
ULAMS_STORAGE_ORIGINS=https://files.ulams.app
# Backups (scripts/backup.sh): bucket, rotation and optional separate credentials (default: S3_KEY/S3_SECRET)
BACKUP_BUCKET=ulams-backups
BACKUP_KEEP_DAYS=14
# Encrypts the secrets archive of each backup (.env, tunnel credentials). Generated; also store it in a
# password manager, a restore on a new server needs it before .env exists.
BACKUP_PASSPHRASE=GENERATE
BACKUP_S3_KEY=
BACKUP_S3_SECRET=
# ---------------------------------------------------------------------------------------------
# E-mail (any SMTP provider). Cloudflare Email Routing is inbound only and cannot send.
# Tenants send as no-reply@<TENANCY_EMAIL_DOMAIN> with their own name: add SPF, DKIM and DMARC for
# that domain at your provider. MAIL_FROM_NAME must not contain spaces.
# ---------------------------------------------------------------------------------------------
MAIL_HOST=smtp.example.com
MAIL_PORT=587
MAIL_USERNAME=CHANGE
MAIL_PASSWORD=CHANGE
MAIL_ENCRYPTION=tls
MAIL_FROM_ADDRESS=no-reply@ulams.app
MAIL_FROM_NAME=ulams
# ---------------------------------------------------------------------------------------------
# Launch switches
# ---------------------------------------------------------------------------------------------
# `actual` keeps the honest Coming/Partial labels on the landing (#92). `final` shows everything as delivered.
ULAMS_LANDING_STATUS=actual
# CSP starts report-only. After about a week without unexpected rows in GET /api/admin/csp-reports,
# set both and restart: CSP_ENFORCE=true and ULAMS_CSP_HEADER=Content-Security-Policy (#121).
CSP_ENFORCE=false
ULAMS_CSP_HEADER=Content-Security-Policy-Report-Only
ULAMS_COOKIE_SECURE=true
# The platform HTTP API for managing tenants (ADR 0078): leave off unless you use the CLI against it
TENANCY_PLATFORM_API=false
# Error tracking (optional)
SENTRY_DSN=
SENTRY_ENVIRONMENT=production
ADMIN_SENTRY_DSN=