Skip to content

Requirements

What a machine must offer before HiLMS runs on it. Hilms\Ops\Support\Requirements is the same list in code: the health checks and hilms:install read it, and app-modules/ops/tests/Feature/RequirementsTest.php fails when this page and the code drift apart.

The quickest way to check a machine is to install and let it tell you:

php artisan hilms:install
php artisan health:check

PHP 8.5, newest patch release. Every extension below is a require entry in composer.json, so composer install refuses to run on a host that misses one.

Extension Extension Extension Extension
ctype curl dom exif
fileinfo filter gd iconv
intl json libxml mbstring
openssl pcntl pcre pdo
pdo_mysql posix redis session
simplexml sodium tokenizer xml
xmlreader xmlwriter zip zlib

Recommended but not required: opcache (much faster) and bcmath. imagick and ffmpeg are not used.

Setting Value Why
upload_max_filesize at least UPLOAD_MAX_KILOBYTES, 100M recommended The panel caps uploads at the smallest of this, post_max_size, Livewire’s rule and the media library setting
post_max_size the same See above
memory_limit 256M for the web, 512M for the workers Image conversions
max_execution_time 60 Uploads and conversions run on the queue
date.timezone UTC HiLMS stores everything in UTC
opcache.enable 1
opcache.validate_timestamps 0 in production Restart PHP-FPM after a deployment
Binary Needed for Notes
mysqldump or mariadb-dump Nightly backups The one that matches DB_CONNECTION; set DB_DUMP_BINARY_PATH when it is not on the PATH
jpegoptim, optipng, pngquant, cwebp Smaller images Optional; the media library skips the ones it cannot find

The MariaDB client provides both names and dumps MySQL 8.4 as well, which is what the Docker image ships.

Service Version Notes
MySQL 8.0 or newer, 8.4 tested DB_CONNECTION=mysql
MariaDB 10.11 or newer, 11 tested DB_CONNECTION=mariadb
Redis 6.2 or newer, 7 tested Cache, queue and Horizon; maxmemory set (256 MB is plenty) with maxmemory-policy noeviction, no cluster. Sessions live in the database, and the site keeps serving while Redis is away
Web server nginx preferred Document root public/; Caddy, Apache and OpenLiteSpeed work too
Queue worker one php artisan horizon process under a supervisor
Scheduler php artisan schedule:run every minute, or one schedule:work process
SMTP outbound Account setup links, enrolment mail and operational alerts
Node and npm 24 Build time only; a release ships public/build
Composer 2

HTTPS is terminated by the host’s reverse proxy. Set APP_URL to the https address, TRUSTED_PROXIES to the proxy, and SESSION_SECURE_COOKIE=true.

Path Needs
storage/ Writable by the web user, and by the user the workers run as
bootstrap/cache/ Writable
public/storage A relative symlink to storage/app/public, made by php artisan storage:link --relative (hilms:install writes it). An absolute one names the path the command ran at, which a container serving the application from somewhere else cannot follow
storage/app/keys The Passport key pair; included in every backup
storage/app/backups Where the nightly archives land
storage/app/private/lessons Course material — the media library’s files only a lesson may show — on the local disk; never under public/, served only through signed links; included in every backup

Disk: about 0.4 GB for the code and its dependencies, plus the uploaded media, plus roughly twice the size of one database dump and the media for every retained backup. The default retention keeps everything for a week, then daily copies for sixteen days, weekly for eight weeks and monthly for four months.

The clock runs in UTC. APP_KEY must be set once and never change: it decrypts the stored OAuth tokens and the secrets kept in the panel’s Settings.

Where uploads are kept is chosen in the panel, under Settings → Media and storage (administrators only):

  • This server keeps course material in storage/app/private/lessons and everything else in storage/app/public. Nothing else to configure; both directories are in every backup.
  • An S3-compatible bucket (Cloudflare R2, Backblaze B2, Wasabi, Amazon S3) takes the course material, under lessons/ in the lesson bucket. The bucket stays private: a student’s link still goes through HiLMS, which checks lesson access and only then redirects to a presigned address that lives five minutes. Public media — course covers, avatars, the logo, page images — follows only when the page also names a public bucket of its own and its public address (an R2 custom domain or r2.dev address, a CDN in front of it); otherwise it stays on this server. The public bucket may never be the lesson bucket: R2 and B2 open a whole bucket to anyone once it has a public address, lesson files included.

The page’s guide names the provider (Cloudflare R2, Amazon S3, Backblaze B2, Hetzner Object Storage, DigitalOcean Spaces, or any other S3-compatible service) and checks the bucket before it saves. For Other S3-compatible, a field left blank falls back to MEDIA_S3_* in .env, and the page is seeded from them (and from MEDIA_LESSONS_DISK) when hilms:install or hilms:upgrade first migrates, so an installation configured in .env keeps working. A change applies to new uploads at once; files already uploaded keep working where they are and move only when the page’s move button is pressed (storage.md). The health check MediaStorage asks the bucket about one file every run.

Livewire’s temporary uploads stay on the local disk (FILESYSTEM_DISK=local) whatever is chosen: every upload rule — the content sniffing, the video and audio checks, the caption parser, the SVG sanitiser — reads a local file, and the file goes to the bucket when the form is saved. The server therefore still needs room for the largest upload in flight.

What the buckets need — storage.md walks through creating them with Cloudflare R2 and Backblaze B2, fills in the page and checks the result:

  • An access key limited to the two buckets, allowed to list, read, write and delete objects. Every named provider needs the key and the secret; only Other S3-compatible may leave them to the environment, or to a server whose role already grants access (an EC2 instance profile).

  • A CORS rule allowing GET and HEAD from APP_URL, with the Range header, on the lesson bucket and on the public bucket when there is one. Once a file lives in a bucket, the panel’s upload field fetches it across origins to preview it, and the video player asks for the video and its caption tracks in CORS mode (crossorigin="anonymous", or the tracks would be thrown away); without the rule a video from the bucket does not play at all. The guide hands out the rule with the site’s address filled in, in the shape the provider takes; for R2 and S3:

    [
    {
    "AllowedOrigins": ["https://lms.example.com"],
    "AllowedMethods": ["GET", "HEAD"],
    "AllowedHeaders": ["Range"],
    "MaxAgeSeconds": 3600
    }
    ]
  • Nothing public on the lesson bucket: no public address, no public-read policy.

  • Both buckets under one account and one endpoint, since the page has one of each.

The content security policy lets the browser follow to the configured buckets’ addresses on its own; nothing needs adding to it.

Every key of .env.production.example, which is the file to copy when installing.

Key Default What it does
APP_NAME HiLMS Shown in the panel, the mail and the backup directory name
APP_ENV production production turns on the production-only health checks and turns off strict Eloquent
APP_KEY — Generated once by hilms:install; changing it makes stored OAuth tokens unreadable
APP_DEBUG false Never true in production
APP_URL — The public https address; every generated link uses it
APP_LOCALE pl The language hilms:install seeds as the installation’s first and default one; Settings → Languages is the list from then on (languages.md)
APP_FALLBACK_LOCALE en Used for missing translations; stays en, because the code’s own language is English
APP_FAKER_LOCALE pl_PL Demo data only
APP_MAINTENANCE_DRIVER file How artisan down remembers it is down
LOG_CHANNEL stack
LOG_STACK daily One file per day under storage/logs
LOG_DAILY_DAYS 14 How many days of logs to keep
LOG_DEPRECATIONS_CHANNEL null
LOG_LEVEL info
DB_CONNECTION mysql mysql or mariadb
DB_HOST, DB_PORT, DB_DATABASE, DB_USERNAME, DB_PASSWORD — The database
DB_ROOT_PASSWORD — The MySQL root password the Compose stack starts the database with; read only by compose.yaml, never by the application
DB_DUMP_BINARY_PATH empty Directory of the dump binary, when it is not on the PATH
DB_DUMP_SKIP_SSL true in the Docker stack Skips TLS for the backup dump; only right when the database is on a private network
DB_DUMP_SSL_FLAG skip-ssl skip-ssl, ssl-mode=DISABLED or ssl-mode=PREFERRED, whichever the dump client understands
SESSION_DRIVER database Sessions outlive a Redis outage
SESSION_LIFETIME 120 Minutes
SESSION_ENCRYPT false
SESSION_PATH, SESSION_DOMAIN / , null
SESSION_SECURE_COOKIE true Requires https
FILESYSTEM_DISK local The framework’s default disk, and where Livewire keeps an upload until the form is saved. It stays local: every upload rule reads the file from the local disk. Media never goes there (see MEDIA_DISK and MEDIA_LESSONS_DISK)
UPLOAD_MAX_KILOBYTES 102400 The upload ceiling the panel enforces and shows; every upload field is capped to it
PHP_UPLOAD_MAX_FILE_SIZE, PHP_POST_MAX_SIZE, NGINX_CLIENT_MAX_BODY_SIZE 100M Read by the Docker image’s PHP and nginx, not by the application; must allow at least UPLOAD_MAX_KILOBYTES. A manual installation sets the same values in php.ini and the web server
QUEUE_CONNECTION failover Redis for Horizon; while Redis cannot answer, a job runs right after the response. An assistant request and a file move wait for a worker or are refused
REDIS_QUEUE_RETRY_AFTER 330 Must stay above the longest timeout of the supervisors on the redis connection (300); media moves have a connection of their own, media-moves, with an hour per file
MEDIA_QUEUE media The queue image conversions run on
QUEUE_CONVERSIONS_BY_DEFAULT true Conversions are queued, not made during the request
CACHE_STORE failover Redis, and the database while Redis cannot answer
SETTINGS_CACHE_ENABLED true Caches the settings table
SETTINGS_CACHE_MEMO true Keeps resolved settings in memory for one request
REDIS_CLIENT phpredis
REDIS_HOST, REDIS_PORT, REDIS_PASSWORD — Redis
REDIS_TIMEOUT 2 Seconds to wait for a connection to Redis; without it an unreachable host holds every call for a minute
MAIL_MAILER smtp
MAIL_SCHEME null smtp or smtps
MAIL_HOST, MAIL_PORT, MAIL_USERNAME, MAIL_PASSWORD — The SMTP server
MAIL_FROM_ADDRESS, MAIL_FROM_NAME — The sender; Settings → Mail in the panel overrides both
BREVO_API_KEY empty The API key of a Brevo account, for the brevo transport. Resend, Postmark, Mailgun and SES keep Laravel’s own names in config/services.php (RESEND_API_KEY, POSTMARK_API_KEY, MAILGUN_DOMAIN with MAILGUN_SECRET and MAILGUN_ENDPOINT, AWS_ACCESS_KEY_ID with AWS_SECRET_ACCESS_KEY and AWS_DEFAULT_REGION), and a key stored on the mail settings page takes precedence over all of them
MAIL_ALWAYS_TO empty While it is set, every message goes to this address instead of the person it names — a pin for a staging installation. Settings → Mail shows it and overrides it
TRUSTED_PROXIES * behind a loopback-only proxy Comma separated addresses, or *; empty ignores forwarded headers
CORS_ALLOWED_ORIGINS empty Browser origins allowed to call /api/v1 and /oauth/token; empty allows none
OPS_NOTIFICATION_EMAIL empty Where Horizon, the health checks and the backups report; empty sends nothing
HEALTH_SECRET_TOKEN empty Required in the X-Secret-Token header of GET /health; while it is empty the endpoint answers 403 to everybody, and in production the configuration health check fails. The endpoint answers 503 only when a check failed, 200 for a warning
PULSE_ENABLED true Performance recording
TELESCOPE_ENABLED false Telescope is a development dependency and never registers in production
ACTIVITYLOG_ENABLED true The audit trail
ACTIVITYLOG_CLEAN_AFTER_DAYS 365 How long audit entries are kept
BACKUP_KEEP_ALL_DAYS 7 Every archive is kept this long
BACKUP_KEEP_DAILY_DAYS 16 Then one a day
BACKUP_KEEP_WEEKLY_WEEKS 8 Then one a week
BACKUP_KEEP_MONTHLY_MONTHS 4 Then one a month
BACKUP_KEEP_YEARLY_YEARS 0 No yearly copies: a deleted account must not live on in a dump
BACKUP_ARCHIVE_PASSWORD empty Encrypts every backup archive when set; empty means unencrypted archives
BACKUP_S3_ENABLED false Copies each backup to S3 as well
BACKUP_S3_KEY, BACKUP_S3_SECRET, BACKUP_S3_BUCKET, BACKUP_S3_ENDPOINT — The S3-compatible target
BACKUP_S3_REGION us-east-1 The bucket’s region; R2 reads us-east-1 as auto
BACKUP_S3_PATH_STYLE false true for a provider that wants the bucket in the path rather than the host name (MinIO, some self-hosted stores)
HILMS_THEME hilms The theme every student page is rendered from, a directory under themes/. A mounted client theme is bound into the container; see theming.md
HILMS_STYLES_ENFORCE_ACCESSIBILITY false Contrast and the accessibility floors are advice in the Styles editor; true makes a style that falls short impossible to activate, and the active style impossible to save into falling short. An operator’s promise, so it is not a panel setting; see theming.md
HILMS_ADMIN_NAME, HILMS_ADMIN_EMAIL, HILMS_ADMIN_PASSWORD — Used once, by the first hilms:install, to create the administrator
HILMS_API_DEMO_SECRET empty Seeds a demo API client locally; leave empty in production
HILMS_PORT 8080 The loopback port the Compose stack publishes and the host’s reverse proxy talks to; read only by compose.yaml
HILMS_VERSION commented out The image tag the Compose stack runs, and — Compose passes .env into the containers — the version artisan about reports. Uncomment it to pin the tag being installed; left commented, the stack runs latest and every container reports the version its own image was built with
HILMS_AUTO_INSTALL set per service Whether the container runs hilms:deploy as it starts: hilms:install on a database that holds no installation, hilms:upgrade on one that does. compose.yaml sets it on each service rather than in .env, which reaches them all: true on app, false on horizon and scheduler. Outside Compose, set it on the one container that deploys
BUNNY_STREAM_TOKEN_KEY empty The Token Authentication key of a Bunny Stream library; with it every video embed carries a token that expires within the hour. The key stored under Settings → Media and storage takes precedence
SCOUT_DRIVER database The catalogue search engine; database searches the courses table itself and needs nothing else
MEDIA_DISK public The disk holding public media (the media library’s public files — course covers and page pictures — avatars and the branding) until Settings → Media and storage is saved; the page decides from then on (see Media storage)
MEDIA_LESSONS_DISK lessons The disk holding course material until the page is saved; private, served only through signed links. remote-lessons seeds the page with a bucket; any other disk seeds it with this server, and a local disk of your own is kept while that is chosen
MEDIA_SENDFILE php php streams a private file through the application, nginx hands a file on the lessons disk to nginx with an internal X-Accel-Redirect (the image ships the snippet). A file in a bucket is always answered with a five-minute presigned address once the access check passes
MEDIA_S3_KEY, MEDIA_S3_SECRET, MEDIA_S3_BUCKET, MEDIA_S3_ENDPOINT — The S3-compatible storage behind remote-lessons (lesson files under lessons/): an access key, its secret, the lesson bucket and the provider’s endpoint (https://<account>.r2.cloudflarestorage.com for R2). Fallbacks for fields left blank on the Media and storage page
MEDIA_S3_PUBLIC_BUCKET empty The bucket of its own that remote-public keeps public media in, under public/; never the lesson bucket
MEDIA_S3_REGION us-east-1 The bucket’s region; R2 reads us-east-1 as auto
MEDIA_S3_PATH_STYLE false true for a provider that wants the bucket in the path rather than the host name
MEDIA_S3_PUBLIC_URL empty The public bucket’s address (an R2 custom domain or r2.dev address, a CDN), which public media is read from
AI_PROVIDER deepseek The AI provider the editor assistants prompt, a key of ai.providers in config/ai.php; the panel’s Settings → AI assistants overrides it
AI_MODEL deepseek-flash The text model the assistants prompt, whichever provider is chosen; Settings → AI assistants overrides it. The SDK’s own per-provider defaults are never used, because they can name a retired model
DEEPSEEK_API_KEY empty The key of the default provider. Every other provider keeps the name config/ai.php reads it under (OPENAI_API_KEY, ANTHROPIC_API_KEY, GEMINI_API_KEY, MISTRAL_API_KEY, GROQ_API_KEY, XAI_API_KEY, OPENROUTER_API_KEY, OLLAMA_API_KEY, OPENAI_COMPATIBLE_API_KEY, and the Azure and Bedrock sets), and each may take a *_URL for a proxy. A key stored under Settings → AI assistants takes precedence over all of them
CSP_ENABLED true The content security policy; turn it off only to prove it is the cause of a problem
CSP_REPORT_ONLY false Sends the front-end policy as report-only, so violations appear in the console without being blocked
FILAMENT_IMPERSONATE_REDIRECT empty Where an administrator lands once they are seeing the site as somebody else; empty means the page the installation chose for its students
PASSKEYS_USER_HANDLE_SECRET empty Derives the WebAuthn user handle of every passkey; empty means APP_KEY, so rotating APP_KEY invalidates every registered passkey
PERSONAL_DATA_EXPORT_DELETE_AFTER_DAYS 5 How long a personal data archive stays downloadable; personal-data-export:clean removes the older ones nightly
GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET empty Google sign-in; the button appears only when the id is set
FACEBOOK_CLIENT_ID, FACEBOOK_CLIENT_SECRET empty Facebook sign-in
VITE_APP_NAME ${APP_NAME} Build time only

HiLMS is MIT-licensed. No replicants were harmed in the writing of these books.