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}:8080"
    restart: unless-stopped
    volumes:
      - exelearning-data:/mnt/data:rw

    environment:
      # Symfony settings
      APP_ENV: prod
      APP_DEBUG: 0
      APP_SECRET: "${APP_SECRET:-ChangeThisToASecretForSQLiteDeployment}"
      APP_PORT: "${APP_PORT:-8080}"
      APP_ONLINE_MODE: 1
      XDEBUG_MODE: off

      # 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
      APP_AUTH_METHODS: password
      AUTH_CREATE_USERS: true

      # Test users
      TEST_USER_EMAIL: "${TEST_USER_EMAIL:-user@exelearning.net}"
      TEST_USER_USERNAME: "${TEST_USER_USERNAME:-user}"
      TEST_USER_PASSWORD: "${TEST_USER_PASSWORD:-1234}"

      # Mercure settings
      MERCURE_URL: "http://localhost:8080/.well-known/mercure"
      MERCURE_PUBLIC_URL:
      MERCURE_JWT_SECRET_KEY: "${MERCURE_JWT_SECRET_KEY:-!ChangeThisMercureHubJWTSecretKey!}"
      MERCURE_PUBLISHER_JWT_KEY: "${MERCURE_JWT_SECRET_KEY:-!ChangeThisMercureHubJWTSecretKey!}"
      MERCURE_SUBSCRIBER_JWT_KEY: "${MERCURE_JWT_SECRET_KEY:-!ChangeThisMercureHubJWTSecretKey!}"

      API_JWT_SECRET: "${API_JWT_SECRET:-ChangeThisToASecretForSQLiteDeployment}"

      # Post-configuration commands
      POST_CONFIGURE_COMMANDS: |
        echo "Setting up eXeLearning with SQLite..."
        php bin/console app:create-user ${TEST_USER_EMAIL:-user@exelearning.net} ${TEST_USER_PASSWORD:-1234} ${TEST_USER_USERNAME:-user} --no-fail

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}:8080"
    restart: unless-stopped
    volumes:
      - exelearning-data:/mnt/data:rw
    environment:
      # Symfony settings
      APP_ENV: prod
      APP_DEBUG: 0
      APP_SECRET: "${APP_SECRET:-ChangeThisToASecretForMariaDBDeployment}"
      APP_PORT: "${APP_PORT:-8080}"
      APP_ONLINE_MODE: 1
      XDEBUG_MODE: off

      # 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
      APP_AUTH_METHODS: password
      AUTH_CREATE_USERS: true

      # Test users
      TEST_USER_EMAIL: "${TEST_USER_EMAIL:-user@exelearning.net}"
      TEST_USER_USERNAME: "${TEST_USER_USERNAME:-user}"
      TEST_USER_PASSWORD: "${TEST_USER_PASSWORD:-1234}"

      # Mercure settings
      MERCURE_URL: "http://localhost:8080/.well-known/mercure"
      MERCURE_PUBLIC_URL:
      MERCURE_JWT_SECRET_KEY: "${MERCURE_JWT_SECRET_KEY:-!ChangeThisMercureHubJWTSecretKey!}"
      MERCURE_PUBLISHER_JWT_KEY: "${MERCURE_JWT_SECRET_KEY:-!ChangeThisMercureHubJWTSecretKey!}"
      MERCURE_SUBSCRIBER_JWT_KEY: "${MERCURE_JWT_SECRET_KEY:-!ChangeThisMercureHubJWTSecretKey!}"

      API_JWT_SECRET: "${API_JWT_SECRET:-ChangeThisToASecretForMariaDBDeployment}"

      # Post-configuration commands
      POST_CONFIGURE_COMMANDS: |
        echo "Setting up eXeLearning with MariaDB..."
        php bin/console app:create-user ${TEST_USER_EMAIL:-user@exelearning.net} ${TEST_USER_PASSWORD:-1234} ${TEST_USER_USERNAME:-user} --no-fail
    depends_on:
      - mariadb

  mariadb:
    image: mariadb:12.0
    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}:8080"
    restart: unless-stopped
    volumes:
      - exelearning-data:/mnt/data:rw
    environment:
      # Symfony settings
      APP_ENV: prod
      APP_DEBUG: 0
      APP_SECRET: "${APP_SECRET:-ChangeThisToASecretForPostgresDeployment}"
      APP_PORT: "${APP_PORT:-8080}"
      APP_ONLINE_MODE: 1
      XDEBUG_MODE: off

      # 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: 17

      # Files directory
      FILES_DIR: "/mnt/data/"

      # Authentication settings
      APP_AUTH_METHODS: password
      AUTH_CREATE_USERS: true

      # Test user
      TEST_USER_EMAIL: "${TEST_USER_EMAIL:-user@exelearning.net}"
      TEST_USER_USERNAME: "${TEST_USER_USERNAME:-user}"
      TEST_USER_PASSWORD: "${TEST_USER_PASSWORD:-1234}"

      # Mercure settings
      MERCURE_URL: "http://localhost:8080/.well-known/mercure"
      MERCURE_PUBLIC_URL:
      MERCURE_JWT_SECRET_KEY: "${MERCURE_JWT_SECRET_KEY:-!ChangeThisMercureHubJWTSecretKey!}"
      MERCURE_PUBLISHER_JWT_KEY: "${MERCURE_JWT_SECRET_KEY:-!ChangeThisMercureHubJWTSecretKey!}"
      MERCURE_SUBSCRIBER_JWT_KEY: "${MERCURE_JWT_SECRET_KEY:-!ChangeThisMercureHubJWTSecretKey!}"

      API_JWT_SECRET: "${API_JWT_SECRET:-ChangeThisToASecretForPostgresDeployment}"

      # Post-configuration commands
      POST_CONFIGURE_COMMANDS: |
        echo "Setting up eXeLearning with PostgreSQL..."
        php bin/console app:create-user ${TEST_USER_EMAIL:-user@exelearning.net} ${TEST_USER_PASSWORD:-1234} ${TEST_USER_USERNAME:-user} --no-fail
    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/data

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.


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, plus optional test user (TEST_USER_*)
  • Real-time (Mercure): MERCURE_URL, MERCURE_*_JWT_*
  • Post-configure hooks: POST_CONFIGURE_COMMANDS (e.g., auto-create a user)

(See the embedded Compose files for the full set.)

Important: Always set strong secrets (APP_SECRET, MERCURE_JWT_SECRET_KEY, 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.
  • 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 subdir.conf.template by 02-configure-symfony.sh 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.

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;
    }

    # Mercure (SSE) must disable buffering
    location ^~ /.well-known/mercure {
        proxy_pass http://127.0.0.1:8080;
        proxy_http_version 1.1;
        proxy_set_header Connection "";
        proxy_buffering off; # critical for SSE
        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;
    }
}

If TLS is terminated at your proxy: set USE_FORWARDED_HEADERS=1 and ensure X-Forwarded-* headers are sent.


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.

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/SSE stalls: In Nginx, set proxy_buffering off for /.well-known/mercure. See Reverse proxy & TLS above.

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 either via a console command (recommended for cron) or an HTTP endpoint (for environments where only HTTP access is available).

  • Command (recommended):
  • php bin/console app:tmp-files:cleanup [--max-age=SECONDS]
  • Composer shortcut: composer tmp-cleanup
  • Example cron (daily at 03:00, keeping 24h):

    • 0 3 * * * cd /opt/exelearning && php bin/console app:tmp-files:cleanup --max-age=86400
  • HTTP endpoint (GET or POST):

  • Path: /maintenance/tmp/cleanup
  • Query/body parameter: key
  • Example:
    • curl -fsS "https://exelearning.example.org/maintenance/tmp/cleanup?key=$TMP_CLEANUP_KEY"
  • Response: 200 OK with a JSON summary; 207 Multi-Status when some deletions fail.

  • Security and configuration:

  • Set TMP_CLEANUP_KEY in your environment (also present in .env.dist). The endpoint validates ?key=... against this value.
  • If TMP_CLEANUP_KEY is empty or unset, the endpoint is inert and returns 204 No Content without performing any action (silent exit).
  • Expose the endpoint only over HTTPS and/or restrict by IP as needed.