Deployment¶
This page gives administrators a fast, copy-pasteable path to run eXeLearning in production with Docker. It embeds the official Docker Compose files, shows required environment variables, and highlights the few things you must secure.
Images & Architectures¶
We build and publish multi-architecture images for amd64 and arm64. Images are pushed to two registries to avoid potential access issues or rate limiting:
docker.io/exelearning/exelearningghcr.io/exelearning/exelearning
Choose your database¶
| Engine | Best for | File (embedded below) |
|---|---|---|
| SQLite | Single user / proof-of-concept | deploy/docker-compose.sqlite.yml |
| MariaDB | Most teams / general workloads | deploy/docker-compose.mariadb.yml |
| Postgres | Larger teams / high concurrency | deploy/docker-compose.postgres.yml |
Rule of thumb: SQLite for demos, MariaDB for most deployments, Postgres for heavier load.
1) SQLite (simplest)¶
Here is the exact Compose file used by releases:
# SQLite configuration for eXeLearning
# Use with: docker compose -f docker-compose.sqlite.yml up -d
# This is a minimal configuration as SQLite doesn't require a separate database service
services:
exelearning:
image: ghcr.io/exelearning/exelearning:${TAG:-latest}
build: ../../
ports:
- "${APP_PORT:-8080}:${APP_PORT:-8080}"
restart: unless-stopped
volumes:
- exelearning-data:/mnt/data:rw
environment:
# Application settings
APP_ENV: prod
APP_DEBUG: 0
APP_SECRET: "${APP_SECRET:-ChangeThisToASecretForSQLiteDeployment}"
APP_ONLINE_MODE: 1
ONLINE_THEMES_INSTALL: ${ONLINE_THEMES_INSTALL:-0}
# Database settings
DB_DRIVER: pdo_sqlite
DB_PATH: /mnt/data/exelearning.db
DB_SERVER_VERSION: 3.32
# Files directory
FILES_DIR: "/mnt/data/"
# Authentication settings (guest enabled for E2E tests)
APP_AUTH_METHODS: password,guest
AUTH_CREATE_USERS: true
# Admin user (created/updated when ADMIN_PASSWORD is set)
ADMIN_EMAIL: "${ADMIN_EMAIL}"
ADMIN_PASSWORD: "${ADMIN_PASSWORD}"
API_JWT_SECRET: "${API_JWT_SECRET:-ChangeThisToASecretForSQLiteDeployment}"
volumes:
exelearning-data:
Run it:
docker compose -f docker-compose.sqlite.yml up -d
Access the app at http://localhost:8080. Change APP_PORT if needed.
2) MariaDB¶
# MariaDB configuration for eXeLearning
# Use with: docker compose -f docker-compose.mariadb.yml up -d
services:
exelearning:
image: ghcr.io/exelearning/exelearning:${TAG:-latest}
build: ../../
ports:
- "${APP_PORT:-8080}:${APP_PORT:-8080}"
restart: unless-stopped
volumes:
- exelearning-data:/mnt/data:rw
environment:
# Application settings
APP_ENV: prod
APP_DEBUG: 0
APP_SECRET: "${APP_SECRET:-ChangeThisToASecretForMariaDBDeployment}"
APP_ONLINE_MODE: 1
ONLINE_THEMES_INSTALL: ${ONLINE_THEMES_INSTALL:-0}
# Database settings
DB_DRIVER: pdo_mysql
DB_HOST: mariadb
DB_PORT: 3306
DB_NAME: "${DB_NAME:-exelearning}"
DB_USER: "${DB_USER:-exelearning}"
DB_PASSWORD: "${DB_PASSWORD:-exelearning}"
DB_CHARSET: "${DB_CHARSET:-utf8mb4}"
DB_SERVER_VERSION: 10.6
# Files directory
FILES_DIR: "/mnt/data/"
# Authentication settings (guest enabled for E2E tests)
APP_AUTH_METHODS: password,guest
AUTH_CREATE_USERS: true
# Admin user (created/updated when ADMIN_PASSWORD is set)
ADMIN_EMAIL: "${ADMIN_EMAIL}"
ADMIN_PASSWORD: "${ADMIN_PASSWORD}"
API_JWT_SECRET: "${API_JWT_SECRET:-ChangeThisToASecretForMariaDBDeployment}"
depends_on:
- mariadb
mariadb:
image: mariadb:12.3
restart: unless-stopped
environment:
MARIADB_ROOT_PASSWORD: "${MARIADB_ROOT_PASSWORD:-root}"
MARIADB_DATABASE: "${DB_NAME:-exelearning}"
MARIADB_USER: "${DB_USER:-exelearning}"
MARIADB_PASSWORD: "${DB_PASSWORD:-exelearning}"
# ports:
# - "${DB_PORT:-3306}:3306"
volumes:
- mariadb-data:/var/lib/mysql
volumes:
exelearning-data:
mariadb-data:
Run it:
docker compose -f docker-compose.mariadb.yml up -d
Note: Default DB credentials in the file are for quick starts. Override them in a
.env(see Configuration).
3) PostgreSQL¶
# PostgreSQL configuration for eXeLearning
# Use with: docker compose -f docker-compose.postgres.yml up -d
services:
exelearning:
image: ghcr.io/exelearning/exelearning:${TAG:-latest}
build: ../../
ports:
- "${APP_PORT:-8080}:${APP_PORT:-8080}"
restart: unless-stopped
volumes:
- exelearning-data:/mnt/data:rw
environment:
# Application settings
APP_ENV: prod
APP_DEBUG: 0
APP_SECRET: "${APP_SECRET:-ChangeThisToASecretForPostgresDeployment}"
APP_ONLINE_MODE: 1
ONLINE_THEMES_INSTALL: ${ONLINE_THEMES_INSTALL:-0}
# Database settings
DB_DRIVER: pdo_pgsql
DB_HOST: postgres
DB_PORT: 5432
DB_NAME: "${DB_NAME:-exelearning}"
DB_USER: "${DB_USER:-postgres}"
DB_PASSWORD: "${DB_PASSWORD:-postgres}"
DB_CHARSET: "${DB_CHARSET:-utf8}"
DB_SERVER_VERSION: 18
# Files directory
FILES_DIR: "/mnt/data/"
# Authentication settings (guest enabled for E2E tests)
APP_AUTH_METHODS: password,guest
AUTH_CREATE_USERS: true
# Admin user (created/updated when ADMIN_PASSWORD is set)
ADMIN_EMAIL: "${ADMIN_EMAIL}"
ADMIN_PASSWORD: "${ADMIN_PASSWORD}"
API_JWT_SECRET: "${API_JWT_SECRET:-ChangeThisToASecretForPostgresDeployment}"
depends_on:
- postgres
postgres:
image: postgres:18-alpine
restart: unless-stopped
environment:
POSTGRES_PASSWORD: "${DB_PASSWORD:-postgres}"
POSTGRES_USER: "${DB_USER:-postgres}"
POSTGRES_DB: "${DB_NAME:-exelearning}"
# ports:
# - "${DB_PORT:-5432}:5432"
volumes:
- postgres-data:/var/lib/postgresql
volumes:
exelearning-data:
postgres-data:
Run it:
docker compose -f docker-compose.postgres.yml up -d
Heads-up: The sample sets
DB_SERVER_VERSIONand pins a Postgres image tag. Keep these aligned when you customize.
> Note: In all cases, if you experience write permission issues, try pruning unused Docker volumes.¶
Configuration¶
You can configure the app either:
- With a
.envliving next to yourdocker-compose.yml, or - Inline in Compose using
${VARIABLE:-default}.
Common knobs (all supported by the example files):
- Application:
APP_ENV,APP_DEBUG,APP_SECRET,APP_PORT,APP_ONLINE_MODE - Base path (subdirectory installs):
BASE_PATH - Database:
DB_DRIVER,DB_HOST,DB_PORT,DB_NAME,DB_USER,DB_PASSWORD,DB_CHARSET, engine-specific version flags - Files:
FILES_DIR(default:/mnt/data/) - Auth:
APP_AUTH_METHODS,AUTH_CREATE_USERS - Admin user:
ADMIN_EMAIL,ADMIN_PASSWORD(see Admin User Setup) - Platform integration (Moodle, etc.):
PROVIDER_URLS,PROVIDER_TOKENS,PROVIDER_IDS(see Platform integration) - Real-time (Yjs WebSocket): Uses the main server port, no additional configuration needed
- Post-configure hooks:
POST_CONFIGURE_COMMANDS(e.g., run custom scripts)
(See the embedded Compose files for the full set.)
Important: Always set strong secrets (
APP_SECRET, DB passwords) via.envor environment overrides—never commit them.
Subdirectory deployment (BASE_PATH)¶
You can deploy eXeLearning under a subdirectory (e.g., https://example.org/exelearning) by setting BASE_PATH.
- Do not include a trailing slash.
- Start with a slash
- Can be multi-level.
Examples:
# Install at root
BASE_PATH=
# One level
BASE_PATH=/exelearning
# Multi-level
BASE_PATH=/web/exelearning
What it does:
- Prefixes all application routes with
BASE_PATH(e.g.,/exelearning/workarea). - Keeps
/healthcheckworking: requests to/healthcheckare redirected to%BASE_PATH%/healthcheckwhenBASE_PATHis set. - Inside the container, Nginx rewrites
^$BASE_PATH/(.*)$to/$1so the app sees clean paths. The rewrite is generated automatically from the container configuration whenBASE_PATHis set.
Verification:
- Visit
https://your-host/%BASE_PATH%/healthcheckand expect{ "status": "ok" }. - If you hit
/healthcheckwithout the prefix whileBASE_PATHis set, you will be redirected to/%BASE_PATH%/healthcheck.
Platform integration (Moodle and other LMS)¶
When an LMS opens a project in eXeLearning (Moodle's mod_exescorm / mod_exeweb,
for example), it sends a signed JWT whose returnurl points back at the platform.
The server later contacts that URL to fetch or upload the package, so PROVIDER_URLS
acts as an allow-list of platforms the server is permitted to call.
PROVIDER_URLS fails closed: while it is empty, every platform callback is
rejected, and the platform shows Invalid token or unauthorized provider. This is
required — an open allow-list would let anyone holding a valid platform token point
the server at arbitrary internal hosts.
Entry syntax is [scheme://]host[:port][/path], matched against the parsed host.
Whatever a part you leave out is unconstrained:
# Exact host, any port, any path
PROVIDER_URLS=https://moodle.example.com
# Several platforms, comma-separated
PROVIDER_URLS=https://moodle.example.com,https://workplace.example.com
# Either http or https (scheme omitted)
PROVIDER_URLS=moodle.example.com
# Only this port / only URLs under this path
PROVIDER_URLS=https://moodle.example.com:8443
PROVIDER_URLS=https://example.com/moodle
Multi-tenant deployments. An entry may start with *. to cover subdomains. The
wildcard stands for exactly one label, like a TLS wildcard certificate:
PROVIDER_URLS=https://*.example.net
| URL | Allowed |
|---|---|
https://tenant.example.net/ |
Yes |
https://tenant.example.net/course/view.php?id=1 |
Yes |
https://a.b.example.net/ |
No — two labels |
https://example.net/ |
No — the bare domain is not covered |
https://evilexample.net/ |
No |
* on its own and a scheme-only entry such as https:// are rejected as malformed:
neither is a supported way to allow every host. A malformed entry is discarded on its
own and does not disable the rest of the list.
If you also use PROVIDER_TOKENS / PROVIDER_IDS, keep the three variables in
the same order and of the same length — the server reports a configuration mismatch
otherwise. Only PROVIDER_IDS and PROVIDER_TOKENS are bound to each other by
position: the JWT's provider_id is looked up in PROVIDER_IDS, and the token at
the same index is used to verify the signature.
PROVIDER_URLS is not bound to a provider. It is one global allow-list: a
callback is allowed when its URL matches any entry, whichever provider signed
the token. So a token signed by one configured provider may carry a returnurl
pointing at another configured provider's host. Every entry you add is authorized for
every provider — list only hosts you are willing to let the server contact.
PROVIDER_TOKENS / PROVIDER_IDS are optional: platforms signing with APP_SECRET
only need PROVIDER_URLS.
Troubleshooting:
Invalid token or unauthorized providerwith the server log line[PlatformJWT] Return URL not in allowed providers: <url>means the JWT verified correctly but<url>is not covered. Add its host toPROVIDER_URLSand restart.[PlatformJWT] Unsafe return URL rejected: <url>is different: the URL uses a non-http(s) scheme, or its host is an IP literal in a private, loopback or link-local range. Give the platform a public hostname instead.- Upgrading from v4.0.1 or earlier: an empty
PROVIDER_URLSused to mean allow everything. Since v4.0.2 it means allow nothing, so a previously working deployment that left it empty must now list its platforms.
Admin User Setup¶
eXeLearning can automatically create and maintain an admin user via environment variables. This is useful for:
- Initial deployment setup
- Admin password recovery (if locked out)
- Consistent admin access across container restarts
Configuration¶
Set these environment variables in your .env file or Docker Compose:
# Admin user email
ADMIN_EMAIL=admin@myorganization.org
# Admin password (required to enable admin user creation)
ADMIN_PASSWORD=your_secure_password_here
Behavior¶
When ADMIN_PASSWORD is set (non-empty):
- If the admin user doesn't exist: Creates a new user with
ROLE_USERandROLE_ADMINroles - If the admin user exists: Updates the password and ensures admin roles are set
This "upsert" behavior allows admin recovery if you lose access—just set the environment variable and restart the container.
Security Notes¶
- Never commit
ADMIN_PASSWORDto version control - Use strong, unique passwords
- Consider removing
ADMIN_PASSWORDafter initial setup and using the UI for password changes - For multi-instance deployments (Redis HA), use the same
ADMIN_EMAILandADMIN_PASSWORDacross all instances
Reverse proxy & TLS¶
Put eXeLearning behind Nginx or Traefik to terminate TLS and forward to the app.
server {
listen 80;
server_name exelearning.example.org;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name exelearning.example.org;
ssl_certificate /etc/letsencrypt/live/exelearning.example.org/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/exelearning.example.org/privkey.pem;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# WebSocket for Yjs collaboration
location /yjs/ {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 86400; # Keep WebSocket alive
}
}
If TLS is terminated at your proxy: set
TRUSTED_PROXIES=private_ranges,REMOTE_ADDRin your.envand ensure your proxy sendsX-Forwarded-*headers. See Reverse Proxy Configuration below.
Reverse Proxy Configuration¶
When running behind a reverse proxy, eXeLearning needs to know how to construct public URLs (for SSO callbacks, redirects, etc.). Configure these variables in your .env:
| Variable | Description | Example |
|---|---|---|
TRUSTED_PROXIES |
IP ranges allowed to set proxy headers | private_ranges,REMOTE_ADDR |
TRUSTED_HEADERS |
Headers to trust from proxies | x-forwarded-for,x-forwarded-host,x-forwarded-proto |
Example .env for reverse proxy:
# Trust private network ranges (typical for Docker/internal proxies)
TRUSTED_PROXIES=private_ranges,REMOTE_ADDR
TRUSTED_HEADERS=x-forwarded-for,x-forwarded-host,x-forwarded-proto,x-forwarded-port
Why this matters: Without proper configuration, SSO authentication (CAS, OpenID) will fail because callback URLs will use the internal server hostname instead of the public URL.
Common issues:
- CAS/OpenID redirects to wrong host → Check
TRUSTED_PROXIESis set - Protocol mismatch (http vs https) → Ensure proxy sends
X-Forwarded-Proto - Missing BASE_PATH in callbacks → Ensure
BASE_PATHis set correctly
Data & backups¶
- Volumes: Each Compose file declares named volumes for app data and databases.
- Backups: Snapshot volumes regularly (
mariadb-data,postgres-data,exelearning-data) and any external storage used byFILES_DIR. - DB tools:
mysqldump/pg_dumpfor live exports; for SQLite, copy the DB file when the service is stopped.
Custom templates¶
eXeLearning supports project templates that users can select via File → New from Template. Templates are managed through the Admin Panel under the Extensions tab.
Adding templates via Admin Panel¶
- Log in as an administrator
- Navigate to Admin Panel → Extensions → Templates
- Select the target language from the dropdown
- Click Upload Template and select an
.elpxfile - Provide a display name and optional description
- The template will be available to users with that language setting
Template storage¶
Templates uploaded through the admin panel are stored in FILES_DIR/admin/templates/<locale>/ and their metadata is stored in the database.
Enabling/Disabling templates¶
Administrators can enable or disable templates through the admin panel. Disabled templates won't appear in the "New from Template" menu for users.
Creating templates (without admin panel)¶
- Design your project in eXeLearning
- Export it as an
.elpxfile (File → Download as... → eXeLearning content) - Place it in the appropriate language folder in your templates directory
- Templates will automatically appear in the File → New from Template menu
Troubleshooting¶
- Port already in use: Change
APP_PORTin your.envor Compose overrides. - File permissions: Ensure your volumes are writable by the container user.
- Real-time/WebSocket issues: Ensure your reverse proxy supports WebSocket upgrade headers. See Reverse proxy & TLS above.
High Availability¶
For deployments requiring horizontal scaling and high availability with multiple server instances, see:
- High Availability Guide - Multi-instance deployment with Redis synchronization
See also¶
- Real-time configuration: development/real-time.md
Maintenance¶
Temporary files cleanup¶
eXeLearning stores intermediate/temporary files (exports, conversions, etc.) under the configured temporary directory. You can clean up old entries via a console command (recommended for cron).
bun cli tmp-cleanup [--max-age=SECONDS]- Example cron (daily at 03:00, keeping 24h):
0 3 * * * cd /opt/exelearning && bun cli tmp-cleanup --max-age=86400
Asset storage conflicts¶
When the startup migration to the sharded asset layout (see ADR-2250-01) finds a legacy file and a different file already present at the canonical location, it keeps both copies and logs a warning on every boot — it never overwrites data on its own. Such rows stay in the legacy layout until an operator picks a winner:
bun cli assets:conflicts— list unresolved conflicts with both absolute paths, sizes and modification times (--jsonfor scripting).bun cli assets:conflicts resolve <asset-id> --keep-old|--keep-new [--dry-run]— resolve one conflict:--keep-oldkeeps the legacy copy (moving it into the canonical location),--keep-newkeeps the canonical copy. Nothing is deleted without one of these explicit flags.
After resolution the database row points at the canonical sharded location and the next startup tidies the emptied legacy directory.