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.
What you get
Section titled “What you get” 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 |
Prerequisites
Section titled “Prerequisites”- 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.ymladd 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-cloudflaredirectory) 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.
Install
Section titled “Install”-
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. -
Get the files onto the server.
Terminal window ssh you@your-vpsgit clone --depth 1 https://github.com/ulams-dev/ulams.gitcd ulams/deploy/vps-cloudflareCopy
cloudflared/credentials.jsonthere ifcloudflare-setup.shran on another machine. For a dry run on your laptop first,scripts/install.sh --localstarts the same stack onhttp://*.ulams.localhost:8480with MinIO instead of R2 and changes nothing on the host. -
Run the installer.
Terminal window sudo scripts/install.sh --domain ulams.app --admin-email you@example.comIt 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
ulamssystem 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;.envfrom.env.examplewith generated secrets (it never overwrites a value that is set); the image tag pinned tosha-<short>; the images; the stack; the H5P configuration; checks; the daily backup timer. A re-run also runsulams:upgrade. -
Fill in
/opt/ulams/.envwhere the installer asked (S3_ENDPOINT,S3_KEY,S3_SECRET, theMAIL_*values) and run the installer again. -
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 openhttps://<domain>andhttps://admin.<domain>in a browser. The administrator’s password isPLATFORM_ADMIN_PASSWORDin.env(printed once on the first run); change it after the first sign-in. -
Store the secrets in a password manager:
.env(above allAPP_KEY, the Passport keys andBACKUP_PASSPHRASE).APP_KEYdecrypts every tenant’s database password; without it a database backup is useless. -
Create the first tenant (First tenant), then the production switches.
Cloudflare setup
Section titled “Cloudflare setup”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.
export CLOUDFLARE_API_TOKEN=... # in your shell only, not in .envexport 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 |
Origin CA instead of the tunnel
Section titled “Origin CA instead of the tunnel”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.
DNS and TLS
Section titled “DNS and TLS”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 sendsCache-Control: public, s-maxage=...(the catalogue endpoints do, seeapi/config/http_cache.php; everything else saysprivate). The rule bypasses requests with anAuthorizationheader or a_tokenparameter, and passesVarythrough (Host,Accept-Language,X-Locale), because Cloudflare ignoresVaryunless 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.
First tenant
Section titled “First tenant”cd /opt/ulamsscripts/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.
Before launch
Section titled “Before launch”- Demo mode off.
create-tenant.shcreates 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_TENANTSempty). Check a tenant withdocker compose exec api php artisan ulams:tenant:list. - Landing status.
ULAMS_LANDING_STATUS=actualis the default in this compose file: the landing keeps the honest Coming and Partial labels.finalrenders 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 inGET /api/admin/csp-reports(see Security headers), setCSP_ENFORCE=trueandULAMS_CSP_HEADER=Content-Security-Policyin.envand rundocker compose up -d(issue #121). - Secrets. Change the platform administrator’s and the tenant admin’s passwords; keep
.envin a password manager. - A restore drill (Backups) on a spare server, once, before real data arrives.
Backups and restore
Section titled “Backups and restore”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):
# same serverscripts/restore.sh --remote latest# new server: install the files and docker, put S3_*/BACKUP_*/ULAMS_DOMAIN in a fresh .env, thenBACKUP_PASSPHRASE=... scripts/restore.sh --remote latest --restore-secretsThe passphrase and APP_KEY are the two things that must exist outside the server. The longer background is in
Backups and restore.
Upgrades
Section titled “Upgrades”cd /opt/ulamsgit -C ~/ulams pull && sudo scripts/install.sh # new compose and scripts, if you want themscripts/upgrade.sh latest # or a sha-<short> / release tagupgrade.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.
Monitoring
Section titled “Monitoring”- Health checks. Every container has one (
docker compose psshowshealthy). Public:https://api.<domain>/api/healthandhttps://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. SetSENTRY_DSN(API) andADMIN_SENTRY_DSNfor 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.
Security hardening
Section titled “Security hardening”The installer does the first four; the rest is yours.
- ufw denies everything except SSH (with the tunnel nothing else listens;
ss -tlnpshould show only sshd). fail2banfor SSH; unattended security updates; the stack runs as containers, the deployment directory is owned by theulamsuser and.envis mode 600.- Secrets are generated, never defaults;
APP_KEYand the Passport keys are never regenerated by the scripts. - SSH: keys only, no root login. In
/etc/ssh/sshd_config.d/10-ulams.confputPasswordAuthentication noandPermitRootLogin no, checksshd -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 (
clamavprofile of the generic example, see Security).
Troubleshooting
Section titled “Troubleshooting”| 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 |
Sizing and scaling
Section titled “Sizing and scaling”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).
Options
Section titled “Options”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.
The documentation site is static. It deploys to GitHub Pages from CI today. For docs.<domain> create the project in
Pages (or enable Pages custom domain in the repository) and add the proxied CNAME for docs
(--docs-target). An explicit docs record wins over the * record to the tunnel.
Not built. The front uses the Astro Node adapter in a container. Moving it to Workers means swapping the adapter and its Node-only code (BFF, session cookies, the in-memory cache) and is a later option.
The files
Section titled “The files”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.
# 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 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 (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 validatecredentials-file: /etc/cloudflared/credentials.jsonno-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# 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=GENERATECLOUDFLARE_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 derivedULAMS_SCHEME=httpsULAMS_PORT_SUFFIX=ULAMS_FCGI_HTTPS=onULAMS_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=GENERATEJWT_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 24POSTGRES_DB=ulamsPOSTGRES_USER=ulamsPOSTGRES_PASSWORD=GENERATEVALKEY_PASSWORD=GENERATE
# Shared secrets for API -> service calls (X-Internal-Token). Manual: openssl rand -hex 32H5P_INTERNAL_TOKEN=GENERATEPDF_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.comPLATFORM_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.comS3_REGION=auto# An R2 API token with "Admin Read & Write" (it must create buckets for new tenants)S3_KEY=CHANGES3_SECRET=CHANGES3_PLATFORM_BUCKET=ulamsS3_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 separatedULAMS_STORAGE_ORIGINS=https://files.ulams.app# Backups (scripts/backup.sh): bucket, rotation and optional separate credentials (default: S3_KEY/S3_SECRET)BACKUP_BUCKET=ulams-backupsBACKUP_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=GENERATEBACKUP_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.comMAIL_PORT=587MAIL_USERNAME=CHANGEMAIL_PASSWORD=CHANGEMAIL_ENCRYPTION=tlsMAIL_FROM_ADDRESS=no-reply@ulams.appMAIL_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=falseULAMS_CSP_HEADER=Content-Security-Policy-Report-OnlyULAMS_COOKIE_SECURE=true# The platform HTTP API for managing tenants (ADR 0078): leave off unless you use the CLI against itTENANCY_PLATFORM_API=false
# Error tracking (optional)SENTRY_DSN=SENTRY_ENVIRONMENT=productionADMIN_SENTRY_DSN=