Container images
.github/workflows/publish.yml
builds seven images and pushes them to GitHub Container Registry as ghcr.io/ulams-dev/<name>.
For running them in production, see Self-hosting. The build
workflow runs on every push to main, on v* tags and manually. The Adapt build service
(api/adapt-builder, GPL-3.0) is not among the published images; build it from its Dockerfile if you
turn on Adapt sources.
| Image | Dockerfile | Build context | Runs | Port | Licence label |
|---|---|---|---|---|---|
php |
api/docker/php/Dockerfile |
api/docker/php |
PHP 8.4 FPM runtime (base of api) |
9000 | Apache-2.0 |
api |
api/Dockerfile |
api |
Laravel: php-fpm, Horizon, tenant queue workers, scheduler (supervisord) | 9000 (FastCGI) | Apache-2.0 |
h5p |
api/h5p/Dockerfile |
repository root | H5P service (Lumi h5p-nodejs-library), Node 22 | 8080 | GPL-3.0-or-later |
pdf |
api/pdf/Dockerfile |
repository root | PDF renderer (pdfme), Node 22 | 3000 | MIT |
admin |
admin/Dockerfile |
repository root | Admin static build on nginx-unprivileged, non-root, runtime settings in runtime-config.json |
8080 | MIT |
front |
front/Dockerfile |
repository root | Legacy learner front, static build on nginx-unprivileged, non-root |
8080 | MIT |
web |
front/web/Dockerfile |
repository root | Reference frontend, Astro SSR on Node | 4321 | MIT |
Tags, platforms, attestations
Section titled “Tags, platforms, attestations”Every image is built with docker/build-push-action@v7 for linux/amd64 and linux/arm64
(QEMU), with provenance: mode=max and sbom: true. Tags come from docker/metadata-action@v6:
| Tag | When |
|---|---|
sha-<short> |
every build |
<branch> |
push to a branch (main) |
<version>, <major>.<minor> |
v* tags, for example v1.2.3 gives 1.2.3 and 1.2 |
latest |
default branch only |
8.4 |
php image only, on the default branch |
OCI labels (title, description, licenses) are written to the manifests and to the
multi-arch index (DOCKER_METADATA_ANNOTATIONS_LEVELS: manifest,index), so GHCR shows them.
The api image is built in the same job as php, with
--build-arg BASE_IMAGE=ghcr.io/ulams-dev/php@<digest>, so each api image sits on exactly the
php image pushed a moment earlier. The other five are a matrix job and receive
APP_VERSION / ADMIN_VERSION set to the computed version tag.
Build them locally
Section titled “Build them locally”docker build -t ulams/php:8.4 api/docker/phpdocker build -t ulams/api:dev api # builds the php-base stage inlinedocker build -f api/h5p/Dockerfile -t ulams/h5p:dev .docker build -f api/pdf/Dockerfile -t ulams/pdf:dev .docker build -f admin/Dockerfile -t ulams/admin:dev .docker build -f front/Dockerfile -t ulams/front:dev .docker build -f front/web/Dockerfile -t ulams/web:dev .The Node images use an allow-list Dockerfile.dockerignore next to their Dockerfile and install
only their own workspace (plus front/sdk and front/ui for web) against the frozen root
yarn.lock.
Per image
Section titled “Per image”php and api
Section titled “php and api”api/docker/php/install.sh is the single package list, used by api/docker/php/Dockerfile and
by the inline php-base stage of api/Dockerfile and api/Dockerfile.develop:
- base
php:8.4-fpm-alpine3.24, Composer 2.10,mlocati/php-extension-installer2.12.0; - extensions
apcu,bcmath,exif,gd,intl,pcntl,pdo_mysql,pdo_pgsql,redis,zip; - tools:
bash,supervisor,ffmpeg,jpegoptim,optipng,pngquant,gifsicle,unzip; - php-fpm pool defaults in
conf/php-fpm.d/00-ulams-base.conf.
api/Dockerfile copies the app, the supervisor config and docker/conf/php/ulams-custom-php.ini,
runs composer install --no-dev, fetches the pinned LiaScript player, and starts
init.sh. Its health check is
php artisan health:check. Dockerfile.develop (local compose only) adds the excimer profiler
and ulams-custom-develop-php.ini.
Runtime configuration. init.sh copies every LARAVEL_<NAME> environment variable into
.env as <NAME>, decodes JWT_PUBLIC_KEY_BASE64 / JWT_PRIVATE_KEY_BASE64 into Passport key
files, runs migrations and the tenant env sync, seeds permissions and starts supervisord. Switches:
DISABLE_PHP_FPM, DISABLE_HORIZON, DISABLE_QUEUE, DISABLE_SCHEDULER, DISABLE_DB_MIGRATE,
DISABLE_TENANT_SYNC, DISABLE_DB_SEED (each true to turn off), and MULTI_DOMAINS for the
older multi-domain mode. Details: api/docs/init-script.md
and Environment variables.
Multi-stage: manifests, full install, tsc plus the esbuild embed bundle, production
dependencies, and an alpine:3.22 stage that downloads the H5P core and editor at pinned commits
(H5P_CORE_REF, H5P_EDITOR_REF). Runs as node, declares the volume /data/libraries, health
check GET /h5p/health. Configuration is environment variables (TENANCY_MODE, ENV_DIR,
KEYS_DIR, DB_*, REDIS_*, S3_*, H5P_INTERNAL_TOKEN, …); the compose file shows a full set.
Same pattern without the core download. Ships api/pdf/fonts (PDF_FONTS_DIR), runs as node,
health check GET /health. Laravel calls it with X-Internal-Token (PDF_INTERNAL_TOKEN).
admin and front
Section titled “admin and front”The static build runs once on the build platform (--platform=$BUILDPLATFORM, node:22.12.0-bookworm),
and only the server stage is per architecture: nginxinc/nginx-unprivileged:1.29-alpine, pinned by
digest, running as uid 101 on port 8080 (ADR 0056).
Configuration is read when the container starts, not at build time:
admin/entrypoint.shandfront/entrypoint.shrun from/docker-entrypoint.d/and write/runtime-config.jsonfrom every environment variable that starts withREACT_APP_(admin) orVITE_APP_(front). Nothing else from the environment is exposed. The release name for Sentry (REACT_APP_SENTRY_RELEASE,VITE_APP_SENTRY_RELEASE) comes from the image version unless set.- The page loads that file before the app boots (a small inline script in
index.html) and copies the values towindow.<NAME>. If the file is missing, as in the dev server, build-time defaults apply. admin/nginx.confandfront/nginx.conf: SPA fallback toindex.html, a year of cache for hashed assets, no cache forindex.html, the service workers andruntime-config.json,GET /healthz. Security headers and the CSP come from the reverse proxy in front.- Source maps are not shipped in the images. The previous
MULTI_DOMAINSper-host mode and the PHP front controller are gone; use the tenant host patterns instead.
Leave REACT_APP_API_URL empty for a multi-tenant admin: it derives the tenant API from its host
with REACT_APP_TENANT_API_HOST_PATTERN, for example
{slug}.admin.ulams.app=>https://{slug}.api.ulams.app. To try an image on its own:
docker run --rm -p 8080:8080 \ -e REACT_APP_TENANT_API_HOST_PATTERN='{slug}.admin.localhost=>http://{slug}.localhost' \ ghcr.io/ulams-dev/admin:latestcurl -s http://localhost:8080/healthz # okcurl -s http://localhost:8080/runtime-config.jsonAstro SSR with @astrojs/node in standalone mode (node dist/server/entry.mjs), runs as node,
health check GET /healthz. The build downloads the self-hosted Google fonts, so it needs network
access. Nothing tenant-specific is baked in: every ULAMS_* variable and the demo login are read
from the environment when the server starts (see
front/web/.env.example).
docker run -p 4321:4321 \ -e ULAMS_TENANT_HOSTS='{slug}.ulams.app=>https://{slug}.api.ulams.app' \ ghcr.io/ulams-dev/web:latestLicences
Section titled “Licences”Source licences per folder are in LICENSING.md.
The php and api images contain GPL command-line tools from Alpine packages (ffmpeg,
pngquant, gifsicle, jpegoptim). api/docker/php/NOTICE lists them and where their source
is; install.sh copies it to /usr/local/share/doc/ulams-php/NOTICE in the image. (The php
image description in publish.yml points at /usr/local/src/ulams-php/NOTICE, a directory that
install.sh deletes at the end of the build.)