Skip to content

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.

  • 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.

On the server, make the directory and log in to the registry:

sudo mkdir -p /srv/hilms
sudo chown "$USER" /srv/hilms
# The image is private, so log in once.
echo "$GHCR_TOKEN" | docker login ghcr.io -u <github-user> --password-stdin

The 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 --tags
git show v0.6.0:compose.yaml > /tmp/compose.yaml
git show v0.6.0:.env.production.example > /tmp/hilms.env
scp /tmp/compose.yaml <user>@<server>:/srv/hilms/compose.yaml
scp /tmp/hilms.env <user>@<server>:/srv/hilms/.env

Back on the server, edit /srv/hilms/.env:

  • APP_URL — the public https address.
  • DB_PASSWORD and DB_ROOT_PASSWORD — long random strings; Compose refuses to start without them.
  • HILMS_PORT — the loopback port the stack publishes; the proxy snippets below use 8090.
  • HILMS_VERSION — uncomment it and pin the tag you are installing, without the leading v (HILMS_VERSION=0.6.0). Never leave it on latest: a rollback and the question “which version broke?” both need to know what is running.
  • HILMS_ADMIN_EMAIL and HILMS_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 /health is 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/hilms
docker compose up -d
docker compose logs -f app

Every 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/up
curl -s -H "X-Secret-Token: $HEALTH_SECRET_TOKEN" http://127.0.0.1:8090/health | head -c 200

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

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.

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.

docker compose exec app php artisan about # version, database, Horizon, schedule
docker compose exec app php artisan health:check # every check, printed
docker compose exec app php artisan backup:run # an extra backup now
docker compose exec app php artisan hilms:user:create [email protected] "Ala Kowalska" --role=editor
docker compose logs -f horizon

The 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.

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.

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.

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 -d

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