Self-hosting on one server
Needs review
Needs review: The example compose file and Caddyfile pass `docker compose config` and were derived from api/docker-compose.yml, api/docker/conf/Caddyfile and the image entrypoints, but have not been run end to end with TLS: check a full install (on-demand TLS ask check, H5P env file copy loop, profile calls through caddy:8081).
This guide installs the whole platform on one Linux server with Docker Compose and the images
that CI publishes to ghcr.io/ulams-dev. The three files below live in the repository at
front/docs-site/examples/production/;
this page renders them from there, so the page and the files cannot drift apart.
Before you start
Section titled “Before you start”- A server that meets the requirements, with Docker and Compose v2.
- Two domains and the DNS records pointing at the server.
- An SMTP relay and, unless you use the bundled MinIO, an S3-compatible bucket service.
Install
Section titled “Install”-
Copy the three files into an empty directory on the server, e.g.
/opt/ulams:docker-compose.yml,Caddyfileand.env.example(save it as.env). -
Fill in
.env. EveryCHANGEvalue is required; the compose file refuses to start without the critical ones. Generate the secrets on the server and paste them in:Terminal window echo "base64:$(openssl rand -base64 32)" # APP_KEYopenssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:4096 -out oauth-private.keyopenssl rsa -in oauth-private.key -pubout -out oauth-public.keybase64 < oauth-private.key | tr -d '\n' # JWT_PRIVATE_KEY_BASE64base64 < oauth-public.key | tr -d '\n' # JWT_PUBLIC_KEY_BASE64openssl rand -hex 32 # POSTGRES_PASSWORD, VALKEY_PASSWORD,# H5P_INTERNAL_TOKEN, PDF_INTERNAL_TOKENStore
APP_KEYand the key pair in your password manager as well: tenant secrets in the platform database are encrypted withAPP_KEY(see Backups). -
Choose object storage. Either point the
S3_*values at an external S3-compatible service with an existing platform bucket, or start the bundled MinIO with--profile minio(see Object storage). -
Pull and start.
Terminal window docker compose pulldocker compose up -d # add --profile minio for the bundled object storedocker compose logs -f api # first start: migrations, permissions, platform adminThe
apicontainer is ready when supervisord has started php-fpm, Horizon, the queue workers and the scheduler. Its image health check runsphp artisan health:checkevery 30 seconds. -
Check the platform.
Terminal window curl -fsS https://api.lms.example.com/api/healthcurl -fsS https://api.lms.example.com/h5p/healthSign in to the admin panel of the platform as
PLATFORM_ADMIN_EMAIL(see First admin and first tenant for which URL to use). -
Create the first tenant and open its learner front and admin panel: First admin and first tenant.
What happens when the api container starts
Section titled “What happens when the api container starts”init.sh in the API image runs on every start (api/init.sh):
- Writes the platform
.envfrom everyLARAVEL_<NAME>variable (<NAME>=value), see Environment. - Writes the platform Passport keys from
JWT_PRIVATE_KEY_BASE64/JWT_PUBLIC_KEY_BASE64intostorage/. - Runs
php artisan migrate --forcefor the platform, unlessDISABLE_DB_MIGRATE=true. - Runs
php artisan ulams:tenant:sync-env --migrate: rebuilds every tenant’s.env.<host>file, domain registration and Passport keys from thetenantstable, then migrates each tenant database.DISABLE_TENANT_SYNC=trueskips it. api/init-keys.sh:key:generateonly ifAPP_KEYis empty (it never replaces an existing one), and, only ifstorage/oauth-private.keyis missing,passport:keysandpassport:client --personal.- Seeds permissions (
PermissionsSeeder), which also creates the first platform admin while the users table is empty andINITIAL_USER_PASSWORDis set.DISABLE_DB_SEED=trueskips it. - Starts supervisord with php-fpm, Horizon (
DISABLE_HORIZON), the tenant queue loopqueue.sh(DISABLE_QUEUE) and the scheduler loopscheduler.sh(DISABLE_SCHEDULER); see Queues and the scheduler.
How the example differs from the development stack
Section titled “How the example differs from the development stack”| Topic | Development (api/docker-compose.yml) |
This example |
|---|---|---|
| Images | Built from source | ghcr.io/ulams-dev/*:${ULAMS_VERSION} |
| Hosts | *.localhost, plain HTTP |
Your domains, HTTPS (see DNS and TLS) |
| Laravel env files for H5P | The api/ source tree mounted read-only |
A loop in the api command copies .env* to the tenant_env volume every 5 seconds |
| Passport keys for H5P | Same mount | The api_storage volume mounted read-only |
| H5P profile calls | http://caddy |
http://caddy:8081, an internal plain-HTTP listener (port 80 redirects to HTTPS) |
| Object storage | MinIO always on | External S3, or MinIO behind --profile minio |
| Tools | Adminer, MailHog, Soketi | None; broadcasting stays on the log driver as in development |
| PostgreSQL | postgres:12 (also used in CI) |
postgres:17-alpine Recommended |
The files
Section titled “The files”# ulams on a single server with the published images (ghcr.io/ulams-dev/*).## Example, not tested end to end by the project: read the Operators section of the docs first.# Usage: copy this file, Caddyfile and .env.example (as .env) to one directory, fill in .env,# then `docker compose up -d` (add `--profile minio` for the bundled object store).## Differences from the development stack (api/docker-compose.yml): published images instead of# builds, TLS in Caddy, no Adminer/MailHog, secrets from .env, and a least-privilege config# directory for the H5P service (volume h5p_service_config, written by `ulams:h5p:export-config`)# instead of a bind mount of the source tree.
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: # Copies of the Laravel env files (.env, .env.<tenant host>), written by the api service. Only # Caddy reads them (the on-demand TLS check); it is replaced by an API check in a later release. tenant_env: # What the H5P service reads: per tenant only the database, bucket and URL settings plus the # Passport PUBLIC key (H5PServiceConfigExporter, same layout as api/h5p/compose.h5p.prod.yml) h5p_service_config: # Installed H5P libraries, shared by every tenant h5p_libraries: caddy_data: caddy_config: minio_data:
services: caddy: <<: *service image: caddy:2 ports: - "80:80" - "443:443" - "443:443/udp" environment: ULAMS_DOMAIN: ${ULAMS_DOMAIN:?set ULAMS_DOMAIN} ULAMS_CONTENT_DOMAIN: ${ULAMS_CONTENT_DOMAIN:?set ULAMS_CONTENT_DOMAIN} ACME_EMAIL: ${ACME_EMAIL:?set ACME_EMAIL} # admin CSP: report-only until the reports are clean (docs: operators/security-headers) ULAMS_CSP_HEADER: ${ULAMS_CSP_HEADER:-Content-Security-Policy-Report-Only} volumes: - ./Caddyfile:/etc/caddy/Caddyfile:ro - caddy_data:/data - caddy_config:/config # read only: the on-demand TLS check issues certificates for provisioned tenants only - tenant_env:/tenant-env:ro depends_on: - api - h5p - web - admin
# 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. api: <<: *service image: ghcr.io/ulams-dev/api:${ULAMS_VERSION:-latest} # Caddy's on-demand TLS check looks for the tenant env files in the tenant_env volume, so a # small loop copies them there every 5 seconds before init.sh starts. command: - bash - -c - | ( while true; do for f in /var/www/html/.env /var/www/html/.env.*; do [ -f "$$f" ] || continue case "$${f##*/}" in .env.example|.env.backup) continue ;; esac t="/tenant-env/$${f##*/}" if ! cmp -s "$$f" "$$t"; then cp "$$f" "$$t.tmp" && chgrp 82 "$$t.tmp" && chmod 0640 "$$t.tmp" && mv "$$t.tmp" "$$t" fi done for t in /tenant-env/.env*; do [ -f "$$t" ] || continue [ -f "/var/www/html/$${t##*/}" ] || rm -f "$$t" done sleep 5 done ) & exec /var/www/html/init.sh 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); # tenant env files start from it. LARAVEL_APP_NAME: ulams LARAVEL_APP_ENV: production LARAVEL_APP_DEBUG: "false" LARAVEL_APP_KEY: ${APP_KEY:?set APP_KEY} LARAVEL_APP_URL: https://api.${ULAMS_DOMAIN} # the exact-Origin check of the API (api/docs/content-origin.md): the platform's own apps; # tenants get theirs from their env file. Extra first-party origins: LARAVEL_TRUSTED_ORIGINS. LARAVEL_FRONTEND_URL: https://app.${ULAMS_DOMAIN} LARAVEL_ADMIN_URL: https://admin.${ULAMS_DOMAIN} 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 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} 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 LARAVEL_FILESYSTEM_DRIVER: s3 LARAVEL_AWS_ACCESS_KEY_ID: ${S3_KEY} LARAVEL_AWS_SECRET_ACCESS_KEY: ${S3_SECRET} LARAVEL_AWS_DEFAULT_REGION: ${S3_REGION:-us-east-1} LARAVEL_AWS_BUCKET: ${S3_PLATFORM_BUCKET:-ulams} LARAVEL_AWS_ENDPOINT: ${S3_ENDPOINT} LARAVEL_AWS_URL: ${S3_PUBLIC_URL}/${S3_PLATFORM_BUCKET:-ulams} 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} # Where the H5P config is exported (the volume below). Written at start (`ulams:tenant:sync-env`), # when a tenant is created, synced or deleted, and by `ulams:h5p:export-config` (also a step of # `ulams:upgrade`). Run that command once after the first start and after creating tenants. 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: https://platform.${ULAMS_CONTENT_DOMAIN} LARAVEL_INITIAL_USER_EMAIL: ${PLATFORM_ADMIN_EMAIL} LARAVEL_INITIAL_USER_PASSWORD: ${PLATFORM_ADMIN_PASSWORD} LARAVEL_SENTRY_LARAVEL_DSN: ${SENTRY_DSN:-} LARAVEL_SENTRY_ENVIRONMENT: ${SENTRY_ENVIRONMENT:-production} # Tenancy (packages/tenancy): names and URLs of new tenants LARAVEL_TENANCY_PLATFORM_HOSTS: api.${ULAMS_DOMAIN},caddy,api,localhost,127.0.0.1 LARAVEL_TENANCY_SCHEME: https LARAVEL_TENANCY_API_HOST: "{slug}.api.${ULAMS_DOMAIN}" LARAVEL_TENANCY_FRONT_HOST: "{slug}.app.${ULAMS_DOMAIN}" LARAVEL_TENANCY_ADMIN_HOST: "{slug}.admin.${ULAMS_DOMAIN}" LARAVEL_TENANCY_CONTENT_HOST: "{slug}.${ULAMS_CONTENT_DOMAIN}" LARAVEL_TENANCY_EMAIL_DOMAIN: "{slug}.${ULAMS_DOMAIN}" LARAVEL_TENANCY_STORAGE_PUBLIC_URL: ${S3_PUBLIC_URL} LARAVEL_TENANT_DEMO_PASSWORD: ${TENANT_INITIAL_PASSWORD:?set TENANT_INITIAL_PASSWORD} volumes: - api_storage:/var/www/html/storage - h5p_service_config:/var/www/html/storage/h5p-service - tenant_env:/tenant-env 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:-latest} # 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: https://api.${ULAMS_DOMAIN} TENANCY_MODE: env-files # the exported config directory (compose.h5p.prod.yml): env subsets and public keys only ENV_DIR: /config KEYS_DIR: /config/keys PLATFORM_HOSTS: api.${ULAMS_DOMAIN} CORS_ORIGINS: https://app.${ULAMS_DOMAIN},https://admin.${ULAMS_DOMAIN},https://api.${ULAMS_DOMAIN} TENANT_FRONT_ORIGIN_PATTERNS: https://{slug}.app.${ULAMS_DOMAIN},https://{slug}.admin.${ULAMS_DOMAIN} 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:-us-east-1} 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" # nothing writable except the libraries volume and /tmp read_only: true tmpfs: - /tmp volumes: - h5p_libraries:/data/libraries - h5p_service_config:/config:ro depends_on: - api - valkey
# PDF renderer (certificates); internal only pdf: <<: *service image: ghcr.io/ulams-dev/pdf:${ULAMS_VERSION:-latest} environment: PORT: "3000" LOG_LEVEL: info PDF_INTERNAL_TOKEN: ${PDF_INTERNAL_TOKEN} PDF_MAX_CONCURRENCY: "2" PDF_MAX_QUEUE: "16"
# Learner front (Astro SSR). Server-side it calls https://<slug>.api.<ULAMS_DOMAIN>, so the # server must be able to reach its own public host names. web: <<: *service image: ghcr.io/ulams-dev/web:${ULAMS_VERSION:-latest} environment: ULAMS_TENANT_HOSTS: "{slug}.app.${ULAMS_DOMAIN}=>https://{slug}.api.${ULAMS_DOMAIN}" ULAMS_ADMIN_URL: "https://{slug}.admin.${ULAMS_DOMAIN}" ULAMS_PLATFORM_HOSTS: app.${ULAMS_DOMAIN} ULAMS_DEMO_TENANTS: "" ULAMS_WARM_TENANTS: "" ULAMS_DEFAULT_TENANT: "" # CSP of the pages: report-only until the reports are clean, then CSP_ENFORCE=true CSP_ENFORCE: ${CSP_ENFORCE:-false} ULAMS_CONTENT_ORIGIN: "https://{slug}.${ULAMS_CONTENT_DOMAIN}" ULAMS_STORAGE_ORIGINS: https://storage.${ULAMS_CONTENT_DOMAIN}
# Admin and author panel (static build on nginx-unprivileged, port 8080); the tenant API comes from the host admin: <<: *service image: ghcr.io/ulams-dev/admin:${ULAMS_VERSION:-latest} environment: REACT_APP_TENANT_API_HOST_PATTERN: "{slug}.admin.${ULAMS_DOMAIN}=>https://{slug}.api.${ULAMS_DOMAIN}" REACT_APP_SENTRYDSN: ${ADMIN_SENTRY_DSN:-}
postgres: <<: *service image: postgres:17-alpine environment: POSTGRES_DB: ${POSTGRES_DB:-ulams} POSTGRES_USER: ${POSTGRES_USER:-ulams} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} 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
# Valkey (BSD-3-Clause), Redis protocol valkey: <<: *service image: valkey/valkey:8-alpine command: ["sh", "-c", "exec valkey-server --requirepass \"$$VALKEY_PASSWORD\" --appendonly yes"] environment: VALKEY_PASSWORD: ${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
# MJML rendering for e-mail templates (internal only) mjml: <<: *service image: danihodovic/mjml-server:latest
# Optional bundled object store: `docker compose --profile minio up -d`. MinIO is AGPL-3.0 # and this image line is no longer maintained upstream; prefer an external S3-compatible # service. MINIO_DEFAULT_BUCKETS creates the platform bucket with public read. minio: <<: *service image: bitnamilegacy/minio:latest profiles: ["minio"] environment: MINIO_ROOT_USER: ${S3_KEY} MINIO_ROOT_PASSWORD: ${S3_SECRET} MINIO_DEFAULT_BUCKETS: ${S3_PLATFORM_BUCKET:-ulams}:download volumes: - minio_data:/bitnami/minio/data
# Optional virus scanning of uploads (UPLOADS_SCANNER=clamd), GPL-2.0, separate process: # `docker compose --profile av up -d` and add LARAVEL_UPLOADS_SCANNER: clamd to the api service. clamav: <<: *service image: clamav/clamav:1.4 profiles: ["av"]# Production Caddyfile for the single-server example (docker-compose.yml next to it).# Derived from the development config api/docker/conf/Caddyfile, with HTTPS host names.# Not tested end to end by the project.## Hosts (D = ULAMS_DOMAIN, C = ULAMS_CONTENT_DOMAIN, <slug> = tenant). C is a separate registrable# domain (strongest) or a subdomain of D's site such as content.D (supported, see# api/docs/content-origin.md for the mitigations):# api.D, <slug>.api.D Laravel API (php-fpm) and /h5p/* (H5P service)# app.D, <slug>.app.D learner front (web)# admin.D, <slug>.admin.D admin panel# platform.C, <slug>.C content origins (SCORM, cmi5, Adapt, LiaScript packages)# storage.C bundled MinIO (only with the `minio` profile)## Certificates: fixed host names get regular certificates. Tenant host names use on-demand# TLS: a certificate is requested on the first TLS handshake, after the `ask` check below# confirms the tenant exists (its env file is present in /tenant-env).
{ email {$ACME_EMAIL} on_demand_tls { ask http://localhost:5555/check }}
(on_demand) { tls { on_demand }}
# 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 { # `?`: only when the API did not set one. The API sets the CSP of interactive packages itself, # per version (ADR 0086); every other package type gets this one. ?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/* /interactive/* } 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 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). 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' https://*.{$ULAMS_DOMAIN} wss://*.{$ULAMS_DOMAIN}; frame-src 'self' https://*.{$ULAMS_CONTENT_DOMAIN} https://*.{$ULAMS_DOMAIN} 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
# 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 } } }
# 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 } } } }
# 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 } } } }
# 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 } } } }
@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/interactive /api/admin/interactive/*/versions /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 } } @origin_upload { header Origin * not header_regexp Origin ^(null|https?://([^/:]+\.)*content\.) } 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 } }
@origin { header Origin * not header_regexp Origin ^(null|https?://([^/:]+\.)*content\.) } 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}
api.{$ULAMS_DOMAIN} { import api}
*.api.{$ULAMS_DOMAIN} { import on_demand import api}
app.{$ULAMS_DOMAIN} { import web}
*.app.{$ULAMS_DOMAIN} { import on_demand import web}
admin.{$ULAMS_DOMAIN} { import admin https://api.{$ULAMS_DOMAIN}}
*.admin.{$ULAMS_DOMAIN} { import on_demand # the slug is the label in front of .admin.<domain>; its reports go to <slug>.api.<domain> @tenant vars_regexp slug {http.request.host} ^([a-z][a-z0-9]{1,29})\.admin\.{$ULAMS_DOMAIN}$ handle @tenant { import admin https://{re.slug.1}.api.{$ULAMS_DOMAIN} } handle { respond "Not found" 404 }}
# Platform content origin -> platform APIplatform.{$ULAMS_CONTENT_DOMAIN} { import content_origin api.{$ULAMS_DOMAIN} https://api.{$ULAMS_DOMAIN} "https://app.{$ULAMS_DOMAIN} https://admin.{$ULAMS_DOMAIN}"}
# Tenant content origins: <slug>.C -> tenant API <slug>.api.D# The slug is the label in front of ULAMS_CONTENT_DOMAIN, whether that is a separate domain# (example-content.net) or a subdomain of the app's site (content.example.com).*.{$ULAMS_CONTENT_DOMAIN} { import on_demand @tenant vars_regexp slug {http.request.host} ^([a-z][a-z0-9]{1,29})\.{$ULAMS_CONTENT_DOMAIN}$ handle @tenant { import content_origin {re.slug.1}.api.{$ULAMS_DOMAIN} https://{re.slug.1}.api.{$ULAMS_DOMAIN} "https://{re.slug.1}.app.{$ULAMS_DOMAIN} https://{re.slug.1}.admin.{$ULAMS_DOMAIN}" } handle { respond "Not found" 404 }}
# Bundled MinIO (profile `minio`). Uploaded files must not run as active content here: no MIME# sniffing, and SVG/HTML/XML opened directly run no script. The dev config covers SVG only.storage.{$ULAMS_CONTENT_DOMAIN} { header >X-Content-Type-Options nosniff @active path *.svg *.svgz *.html *.htm *.xhtml *.xml header @active Content-Security-Policy "script-src 'none'; sandbox" reverse_proxy minio:9000}
# 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 } }}
# On-demand TLS check (not published). 200 only for host names of a provisioned tenant, i.e.# when /tenant-env/.env.<slug>.api.D exists; everything else gets 404 and no certificate.http://:5555 { @lms_host vars_regexp lms {query.domain} ^([a-z][a-z0-9]{1,29})\.(api|app|admin)\.{$ULAMS_DOMAIN}$ @content_host vars_regexp content {query.domain} ^([a-z][a-z0-9]{1,29})\.{$ULAMS_CONTENT_DOMAIN}$
handle @lms_host { @lms_known file { root /tenant-env try_files /.env.{re.lms.1}.api.{$ULAMS_DOMAIN} } handle @lms_known { respond 200 } handle { respond 404 } }
handle @content_host { @content_known file { root /tenant-env try_files /.env.{re.content.1}.api.{$ULAMS_DOMAIN} } handle @content_known { respond 200 } handle { respond 404 } }
handle { respond 404 }}# Production example for a single server (see the Operators section of the docs).# Copy to .env next to docker-compose.yml and replace every value marked CHANGE.# Never commit the filled-in file.
# Image tag of ghcr.io/ulams-dev/{api,h5p,pdf,admin,web}. `latest` follows main; pin a# `sha-<short>` tag (or a `<version>` tag once releases are cut) so upgrades are deliberate.ULAMS_VERSION=latest
# Domains. ULAMS_DOMAIN carries the API, learner front and admin hosts:# api.<ULAMS_DOMAIN>, app.<ULAMS_DOMAIN>, admin.<ULAMS_DOMAIN> (platform)# <slug>.api.<ULAMS_DOMAIN>, <slug>.app.<ULAMS_DOMAIN>, <slug>.admin.<ULAMS_DOMAIN> (tenants)# ULAMS_CONTENT_DOMAIN carries the third-party package code (SCORM, LiaScript, ...):# <slug>.<ULAMS_CONTENT_DOMAIN> (tenant content origins), platform.<...>, storage.<...># Strongest: a different registrable domain (example-content.net). Supported: a subdomain of the# app's site, e.g. content.example.com next to ULAMS_DOMAIN=lms.example.com, which keeps package# code same-site with the app; the mitigations are listed in api/docs/content-origin.md (host-only# `__Host-` cookies, exact-Origin checks, sandboxed frames, CORP/COOP headers).ULAMS_DOMAIN=lms.example.comULAMS_CONTENT_DOMAIN=example-content.net# ACME account e-mail for Let's Encrypt / ZeroSSLACME_EMAIL=ops@example.com
# Laravel application key of the platform. Encrypts tenant secrets in the tenants table:# losing or changing it makes every tenant unrecoverable. Generate once:# echo "base64:$(openssl rand -base64 32)"APP_KEY=CHANGE-base64:...
# Platform Passport key pair, base64 of the PEM files on one line. Required: without them the# container generates new keys AND a new APP_KEY on first start (init.sh). Generate once:# 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' (and the same for oauth-public.key)JWT_PRIVATE_KEY_BASE64=CHANGEJWT_PUBLIC_KEY_BASE64=CHANGE
# PostgreSQL superuser and platform database. The platform connects with this role and uses it# to create one role and one database per tenant (ulams_<slug>).POSTGRES_DB=ulamsPOSTGRES_USER=ulamsPOSTGRES_PASSWORD=CHANGE
# Valkey (Redis protocol): queues, cache, locks, H5P cachesVALKEY_PASSWORD=CHANGE
# Shared secrets for API -> service calls (X-Internal-Token). Any long random string:# openssl rand -hex 32H5P_INTERNAL_TOKEN=CHANGEPDF_INTERNAL_TOKEN=CHANGE
# First platform administrator, created by PermissionsSeeder on the first start while the# users table is empty. Change the password after the first login.PLATFORM_ADMIN_EMAIL=admin@example.comPLATFORM_ADMIN_PASSWORD=CHANGE
# Initial password of the admin, tutor and demo students that `ulams:tenant:create` creates in# every new tenant (TENANT_DEMO_PASSWORD). Use a strong value and change the passwords after# provisioning.TENANT_INITIAL_PASSWORD=CHANGE
# Object storage (S3 compatible). The platform bucket must exist; tenant buckets# (ulams-<slug>) are created by ulams:tenant:create with a public-read policy, so the key needs# CreateBucket and PutBucketPolicy rights. S3_PUBLIC_URL is the browser-facing base URL; the# bucket name is appended to it.# With the optional MinIO service (`--profile minio`): S3_ENDPOINT=http://minio:9000,# S3_PUBLIC_URL=https://storage.<ULAMS_CONTENT_DOMAIN>, S3_USE_PATH_STYLE=true.S3_ENDPOINT=http://minio:9000S3_PUBLIC_URL=https://storage.example-content.netS3_REGION=us-east-1S3_KEY=CHANGES3_SECRET=CHANGES3_PLATFORM_BUCKET=ulamsS3_USE_PATH_STYLE=true
# Outgoing e-mail (SMTP). MAIL_FROM_NAME must not contain spaces (the value is written to the# Laravel .env unquoted); tenants send as no-reply@<slug>.<ULAMS_DOMAIN> with their own name.MAIL_HOST=smtp.example.comMAIL_PORT=587MAIL_USERNAME=CHANGEMAIL_PASSWORD=CHANGEMAIL_ENCRYPTION=tlsMAIL_FROM_ADDRESS=no-reply@example.comMAIL_FROM_NAME=ulams
# Error tracking (optional)SENTRY_DSN=SENTRY_ENVIRONMENT=productionADMIN_SENTRY_DSN=
# Content Security Policy (docs: operators/security-headers). Both start report-only. After about a# week without unexpected rows in GET /api/admin/csp-reports, set CSP_ENFORCE=true (learner front) and# ULAMS_CSP_HEADER=Content-Security-Policy (admin) and restart.CSP_ENFORCE=falseULAMS_CSP_HEADER=Content-Security-Policy-Report-OnlySettings to review before going public
Section titled “Settings to review before going public”ULAMS_LANDING_STATUSon thewebservice: the defaultfinalshows every roadmap item of the platform landing as delivered; setactualfor the honest status (Environment). The example does not set it.CSP_ENFORCEin.env(the example passes it toweb, defaultfalse) andULAMS_CSP_HEADERfor the admin: report-only first, then enforced (Security headers).- Never turn on demo mode for a tenant with real data.
Optional services
Section titled “Optional services”- ClamAV (
--profile av): virus scanning of uploads; also setLARAVEL_UPLOADS_SCANNER: clamdonapi. See Security. - The older React front (
ghcr.io/ulams-dev/front): not in the example. It takesVITE_APP_*variables at runtime, includingVITE_APP_TENANT_API_HOST_PATTERN. - Platform admin panel host: the admin image maps
{slug}.admin.<domain>to tenant APIs; the bareadmin.<domain>host has no slug and falls back to the API URL baked in at build time. See First admin and first tenant.