Installing HiLMS with Docker
The preferred way. One image holds the application, the front-end build and every tool it needs; a Compose stack adds Horizon, the scheduler, MySQL and Redis. HiLMS binds to a loopback port and the machine’s own reverse proxy terminates TLS in front of it.
Read requirements.md first for what every key of .env means, storage.md if media will
live in a bucket rather than on the server, languages.md if this installation will speak more
than one language, mcp.md if AI agents will author courses here, and theming.md if this
installation runs a theme of its own.
What you need on the machine
Section titled “What you need on the machine”- Docker Engine 27 or newer with the Compose plugin.
- A reverse proxy that already owns 80 and 443 (Caddy, nginx, OpenLiteSpeed, Traefik).
- A GitHub personal access token with
read:packages: the image is private. - A checkout of the HiLMS repository on your own machine: the stack files come out of the tag being installed, and the repository is private.
- About 3 GB of disk for the image and the volumes, plus room for the media and backups.
Install
Section titled “Install”On the server, make the directory and log in to the registry:
sudo mkdir -p /srv/hilmssudo chown "$USER" /srv/hilms
# The image is private, so log in once.echo "$GHCR_TOKEN" | docker login ghcr.io -u <github-user> --password-stdinThe stack files come out of the tag you are installing, not out of main: the repository is
private, and main is often already ahead of the published image. Take them from a checkout
on your own machine and copy them over:
# In your checkout of the HiLMS repository:git fetch --tagsgit show v0.6.0:compose.yaml > /tmp/compose.yamlgit show v0.6.0:.env.production.example > /tmp/hilms.envscp /tmp/compose.yaml <user>@<server>:/srv/hilms/compose.yamlscp /tmp/hilms.env <user>@<server>:/srv/hilms/.envBack on the server, edit /srv/hilms/.env:
APP_URL— the public https address.DB_PASSWORDandDB_ROOT_PASSWORD— long random strings; Compose refuses to start without them.HILMS_PORT— the loopback port the stack publishes; the proxy snippets below use8090.HILMS_VERSION— uncomment it and pin the tag you are installing, without the leadingv(HILMS_VERSION=0.6.0). Never leave it onlatest: a rollback and the question “which version broke?” both need to know what is running.HILMS_ADMIN_EMAILandHILMS_ADMIN_PASSWORD— the first administrator, used once.MAIL_*— a real SMTP server; without it nobody can set a password.OPS_NOTIFICATION_EMAIL— where failures are reported.HEALTH_SECRET_TOKEN— a random string, so/healthis not public.TRUSTED_PROXIES=*is correct here: the application only listens on a loopback port that the host’s proxy owns, so no one else can forge the forwarded headers.
Then bring it up:
cd /srv/hilmsdocker compose up -ddocker compose logs -f appEvery start of the app container runs hilms:deploy, which looks at the database and says
which of two things it is doing. On the first boot the database holds no installation, so it
runs hilms:install: it checks the machine, migrates, seeds the roles and permissions, creates
the administrator, links public storage, generates the API keys, warms the caches and runs the
health checks. It runs with --no-interaction, so it asks nothing and leaves the installation
on UTC; Settings → Region and time is where the clock is chosen afterwards (languages.md).
On every later start the database holds an installation — an administrator exists — so it runs
hilms:upgrade instead (see Upgrading). Horizon and the scheduler start once the
application reports healthy.
Watch for HiLMS is ready in the log, then check it from the machine itself:
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8090/upcurl -s -H "X-Secret-Token: $HEALTH_SECRET_TOKEN" http://127.0.0.1:8090/health | head -c 200The reverse proxy
Section titled “The reverse proxy”The application speaks plain HTTP on 127.0.0.1:${HILMS_PORT} and trusts the forwarded
headers, so the proxy must send them.
lms.example.com { reverse_proxy 127.0.0.1:8090}Caddy sets X-Forwarded-For, X-Forwarded-Proto and X-Forwarded-Host itself.
server { listen 443 ssl http2; server_name lms.example.com;
ssl_certificate /etc/letsencrypt/live/lms.example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/lms.example.com/privkey.pem;
client_max_body_size 100m;
location / { proxy_pass http://127.0.0.1:8090; 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_set_header X-Forwarded-Host $host; proxy_set_header X-Forwarded-Port 443; }}OpenLiteSpeed
Section titled “OpenLiteSpeed”Add an external application of type “Web Server” pointing at 127.0.0.1:8090, then a virtual
host context / that proxies to it. Set Add Default Charset off and raise
Max Request Body Size to 100M. OpenLiteSpeed forwards X-Forwarded-For and
X-Forwarded-Proto by default.
What runs where
Section titled “What runs where”| Container | Runs | Notes |
|---|---|---|
app |
nginx and PHP-FPM | The only one with a published port, on loopback |
horizon |
artisan horizon |
Mail, image conversions and media moves; 90 seconds to finish a job on stop |
scheduler |
artisan schedule:work |
Health heartbeats, nightly backups, log and audit cleanup |
mysql |
MySQL 8.4 | Named volume mysql |
redis |
Redis 7, append-only, 256 MB, noeviction |
Named volume redis |
The named volume storage holds storage/: the uploaded media — the public files under
storage/app/public and the course material under storage/app/private/lessons — the
Passport keys, the backups and the logs. It survives a new image.
Media may live on S3-compatible buckets instead (Cloudflare R2, Amazon S3, Backblaze B2,
Hetzner, DigitalOcean Spaces or any other), set up in the panel under Settings → Media and
storage with a guide that checks the bucket before it saves; the volume then keeps only what is
still local, the keys, the backups and the logs, while every upload passes through it on the
way to the bucket. storage.md says what each provider needs — a key limited to the
buckets, a CORS rule for APP_URL, a public bucket of its own for public media — how to move
the files already uploaded, and how to check the result. A container that starts never moves
files: hilms:deploy only counts the ones waiting. A bucket’s contents are not in the
backups: they are the provider’s to keep.
The image already carries the nginx snippet that serves the private lesson disk, so
MEDIA_SENDFILE=nginx in .env is all it takes to have nginx stream those files instead of
PHP. Leave it at php unless the machine is serving a lot of video.
Day to day
Section titled “Day to day”docker compose exec app php artisan about # version, database, Horizon, scheduledocker compose exec app php artisan health:check # every check, printeddocker compose exec app php artisan backup:run # an extra backup nowdocker compose exec app php artisan hilms:user:create [email protected] "Ala Kowalska" --role=editordocker compose logs -f horizonThe panel is at /admin. Horizon is at /horizon, Pulse at /pulse and the health page and
the backups page are in the panel under Monitoring; all of them need a permission an
administrator can grant under Roles.
Upgrading
Section titled “Upgrading”See upgrade.md: move the HILMS_VERSION pin, docker compose pull and up -d. The new
app container finds an installed database and runs hilms:upgrade by itself as it starts:
it migrates, seeds new roles and permissions, republishes the assets, counts the
files not yet on the disks their owners name, warms the caches, asks the workers to
restart, runs the health checks and counts the blocks a new version asks more of. Watch for
HiLMS is up to date in docker compose logs -f app. If the log shows the upgrade stopping,
fix what it names and run docker compose exec app php artisan hilms:upgrade by hand — it is
safe to run twice.
Upload sizes
Section titled “Upload sizes”Four keys in .env decide how large an upload may be, and they must agree:
UPLOAD_MAX_KILOBYTES (what the panel enforces and shows) and PHP_UPLOAD_MAX_FILE_SIZE,
PHP_POST_MAX_SIZE, NGINX_CLIENT_MAX_BODY_SIZE (what the image’s PHP and nginx accept). The
template ships all four at 100 MB, which is the largest request Cloudflare’s free and Pro plans
pass. Change them together and docker compose up -d. Videos larger than that belong on a video
host (the Video block plays Vimeo, YouTube and Bunny Stream) rather than in an upload.
Social login
Section titled “Social login”Google and Facebook sign-in need a client id and secret from each provider’s console;
social-login.md walks through both consoles and the four .env keys. The buttons appear only
once a provider is configured.
A machine that also runs a Lando app called hilms
Section titled “A machine that also runs a Lando app called hilms”Compose identifies a project by name, and this stack is called hilms. On a development
machine where Lando runs the same project name, start the stack under a different one so the
two do not adopt each other’s containers:
docker compose -p hilms-compose-test up -dHiLMS is MIT-licensed. No replicants were harmed in the writing of these books.