Skip to content

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/exelearning
  • ghcr.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:

deploy/docker-compose.sqlite.yml
# 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

deploy/docker-compose.mariadb.yml
# 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

deploy/docker-compose.postgres.yml
# 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_VERSION and 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:

  1. With a .env living next to your docker-compose.yml, or
  2. 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 .env or 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 /healthcheck working: requests to /healthcheck are redirected to %BASE_PATH%/healthcheck when BASE_PATH is set.
  • Inside the container, Nginx rewrites ^$BASE_PATH/(.*)$ to /$1 so the app sees clean paths. The rewrite is generated automatically from the container configuration when BASE_PATH is set.

Verification:

  • Visit https://your-host/%BASE_PATH%/healthcheck and expect { "status": "ok" }.
  • If you hit /healthcheck without the prefix while BASE_PATH is 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 provider with 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 to PROVIDER_URLS and 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_URLS used 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):

  1. If the admin user doesn't exist: Creates a new user with ROLE_USER and ROLE_ADMIN roles
  2. 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_PASSWORD to version control
  • Use strong, unique passwords
  • Consider removing ADMIN_PASSWORD after initial setup and using the UI for password changes
  • For multi-instance deployments (Redis HA), use the same ADMIN_EMAIL and ADMIN_PASSWORD across all instances

Reverse proxy & TLS

Put eXeLearning behind Nginx or Traefik to terminate TLS and forward to the app.

Example: Nginx reverse proxy with TLS
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_ADDR in your .env and ensure your proxy sends X-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_PROXIES is set
  • Protocol mismatch (http vs https) → Ensure proxy sends X-Forwarded-Proto
  • Missing BASE_PATH in callbacks → Ensure BASE_PATH is 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 by FILES_DIR.
  • DB tools: mysqldump / pg_dump for 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

  1. Log in as an administrator
  2. Navigate to Admin Panel → Extensions → Templates
  3. Select the target language from the dropdown
  4. Click Upload Template and select an .elpx file
  5. Provide a display name and optional description
  6. 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)

  1. Design your project in eXeLearning
  2. Export it as an .elpx file (File → Download as... → eXeLearning content)
  3. Place it in the appropriate language folder in your templates directory
  4. Templates will automatically appear in the File → New from Template menu

Troubleshooting

  • Port already in use: Change APP_PORT in your .env or 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:


See also


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 (--json for scripting).
  • bun cli assets:conflicts resolve <asset-id> --keep-old|--keep-new [--dry-run] — resolve one conflict: --keep-old keeps the legacy copy (moving it into the canonical location), --keep-new keeps 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.