Skip to content

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.

  • 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.
  1. Copy the three files into an empty directory on the server, e.g. /opt/ulams: docker-compose.yml, Caddyfile and .env.example (save it as .env).

  2. Fill in .env. Every CHANGE value 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_KEY
    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
    base64 < oauth-public.key | tr -d '\n' # JWT_PUBLIC_KEY_BASE64
    openssl rand -hex 32 # POSTGRES_PASSWORD, VALKEY_PASSWORD,
    # H5P_INTERNAL_TOKEN, PDF_INTERNAL_TOKEN

    Store APP_KEY and the key pair in your password manager as well: tenant secrets in the platform database are encrypted with APP_KEY (see Backups).

  3. 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).

  4. Pull and start.

    Terminal window
    docker compose pull
    docker compose up -d # add --profile minio for the bundled object store
    docker compose logs -f api # first start: migrations, permissions, platform admin

    The api container is ready when supervisord has started php-fpm, Horizon, the queue workers and the scheduler. Its image health check runs php artisan health:check every 30 seconds.

  5. Check the platform.

    Terminal window
    curl -fsS https://api.lms.example.com/api/health
    curl -fsS https://api.lms.example.com/h5p/health

    Sign in to the admin panel of the platform as PLATFORM_ADMIN_EMAIL (see First admin and first tenant for which URL to use).

  6. 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):

  1. Writes the platform .env from every LARAVEL_<NAME> variable (<NAME>=value), see Environment.
  2. Writes the platform Passport keys from JWT_PRIVATE_KEY_BASE64 / JWT_PUBLIC_KEY_BASE64 into storage/.
  3. Runs php artisan migrate --force for the platform, unless DISABLE_DB_MIGRATE=true.
  4. Runs php artisan ulams:tenant:sync-env --migrate: rebuilds every tenant’s .env.<host> file, domain registration and Passport keys from the tenants table, then migrates each tenant database. DISABLE_TENANT_SYNC=true skips it.
  5. api/init-keys.sh: key:generate only if APP_KEY is empty (it never replaces an existing one), and, only if storage/oauth-private.key is missing, passport:keys and passport:client --personal.
  6. Seeds permissions (PermissionsSeeder), which also creates the first platform admin while the users table is empty and INITIAL_USER_PASSWORD is set. DISABLE_DB_SEED=true skips it.
  7. Starts supervisord with php-fpm, Horizon (DISABLE_HORIZON), the tenant queue loop queue.sh (DISABLE_QUEUE) and the scheduler loop scheduler.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
docker-compose.yml
# 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"]
Caddyfile
# 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 API
platform.{$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
}
}
.env.example
# 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.com
ULAMS_CONTENT_DOMAIN=example-content.net
# ACME account e-mail for Let's Encrypt / ZeroSSL
ACME_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=CHANGE
JWT_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=ulams
POSTGRES_USER=ulams
POSTGRES_PASSWORD=CHANGE
# Valkey (Redis protocol): queues, cache, locks, H5P caches
VALKEY_PASSWORD=CHANGE
# Shared secrets for API -> service calls (X-Internal-Token). Any long random string:
# openssl rand -hex 32
H5P_INTERNAL_TOKEN=CHANGE
PDF_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.com
PLATFORM_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:9000
S3_PUBLIC_URL=https://storage.example-content.net
S3_REGION=us-east-1
S3_KEY=CHANGE
S3_SECRET=CHANGE
S3_PLATFORM_BUCKET=ulams
S3_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.com
MAIL_PORT=587
MAIL_USERNAME=CHANGE
MAIL_PASSWORD=CHANGE
MAIL_ENCRYPTION=tls
MAIL_FROM_ADDRESS=no-reply@example.com
MAIL_FROM_NAME=ulams
# Error tracking (optional)
SENTRY_DSN=
SENTRY_ENVIRONMENT=production
ADMIN_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=false
ULAMS_CSP_HEADER=Content-Security-Policy-Report-Only
  • ULAMS_LANDING_STATUS on the web service: the default final shows every roadmap item of the platform landing as delivered; set actual for the honest status (Environment). The example does not set it.
  • CSP_ENFORCE in .env (the example passes it to web, default false) and ULAMS_CSP_HEADER for the admin: report-only first, then enforced (Security headers).
  • Never turn on demo mode for a tenant with real data.
  • ClamAV (--profile av): virus scanning of uploads; also set LARAVEL_UPLOADS_SCANNER: clamd on api. See Security.
  • The older React front (ghcr.io/ulams-dev/front): not in the example. It takes VITE_APP_* variables at runtime, including VITE_APP_TENANT_API_HOST_PATTERN.
  • Platform admin panel host: the admin image maps {slug}.admin.<domain> to tenant APIs; the bare admin.<domain> host has no slug and falls back to the API URL baked in at build time. See First admin and first tenant.