Port Igniter
Get Started

Getting Started

Install PortIgniter LMS with Docker Compose, configure it, sign in, and create your first course.

LMS Support Home

This guide walks you through installing PortIgniter LMS, signing in as the administrator, and creating your first course. The full configuration reference is at the end of this page.

Prerequisites

Docker and Docker Compose are the only requirements. The web server, the application, the background scheduler, and the database all ship as images. If you are not comfortable with Docker, I offer a free course that covers everything you need to know to get up and running fast: Docker Basics.

PortIgniter LMS is designed to run comfortably on a small server, such as 2 vCPUs and 4 GB of memory. Plan your disk space around your course videos, since uploaded media is usually the largest thing the LMS stores.

1. Log in to the PortIgniter registry

PortIgniter LMS images are pulled from the PortIgniter registry. Log in with the email address you used when you purchased your license. Your password is the license key itself.

docker login registry.portigniter.com

If you skip this step, image pulls will fail with an authentication error.

2. Configure your environment

Create a folder for PortIgniter LMS, and in it create a .env file. At a minimum, set the following:

Variable What it’s for
MARIADB_ROOT_PASSWORD The database root password. Choose a long random value.
MARIADB_DATABASE / MARIADB_USER The database name and user, for example lms and lms.
MARIADB_PASSWORD The database user’s password. Choose a long random value.
APP_BASE_URL The exact address people use to reach the site, ending in /. For a first local test, use http://localhost:7080/.
lms.adminEmail / lms.adminPassword The administrator account created on first start. The password needs 12 or more characters.
The stack itself only speaks plain HTTP. Use an https:// address in APP_BASE_URL only when a reverse proxy in front of the stack handles HTTPS. If APP_BASE_URL says https:// but you open the site over http://, the page loads with no styling, because its stylesheets and scripts are requested from an address that doesn't answer. See Running behind HTTPS.
lms.adminPassword must be at least 12 characters. A shorter password doesn't stop the site from starting. The administrator account is simply not created, and you'll have nothing to sign in with. The only sign is a warning in docker compose logs php: lms.adminPassword must be at least 12 characters.

You can generate a strong password with openssl rand -hex 32. The database settings have no defaults on purpose: Compose refuses to start until you set them.

The four MARIADB_* values are applied only when the database is first created. Changing a password in .env later does not change it inside an existing database.

Below is the complete example .env file. Only the REQUIRED section must be filled in. Everything else has a working default, and each setting is explained in the Configuration reference below.

#======================================================================
# PortIgniter LMS: configuration template
#
#   cp .env.example .env      then edit .env and start with: docker compose up -d
#
# .env is read by Docker Compose AND passed into the containers, so it holds
# everything: database, site address, admin account, Stripe, email. It is
# git-ignored and never baked into the image; keep it private and back it up
# somewhere safe (it holds your Stripe keys and database passwords).
#
# Rules of thumb
#   * Lines starting with # are examples. Remove the # to use one.
#   * A value containing spaces must be quoted:  email.fromName = 'My Academy'
#   * Changing .env needs a restart to take effect:  docker compose up -d
#   * Do NOT copy these settings into docker-compose.yml `environment:`
#     (Compose lets it override this file silently).
#
# Only the "REQUIRED" section must be filled in. Everything else has a
# working default and can be left commented out.
#======================================================================


#======================================================================
# REQUIRED
#======================================================================

# ---- Database (the bundled MariaDB) ----------------------------------
# Choose your own long random passwords. There are deliberately no defaults:
# `docker compose up` refuses to start without them. The application uses the
# same values to connect, so there is nothing else to set for the database.
# NOTE: these are read when the database is FIRST created. Changing a password
# here later does not change it inside an existing database volume.
MARIADB_ROOT_PASSWORD=
MARIADB_DATABASE=lms
MARIADB_USER=lms
MARIADB_PASSWORD=

# ---- The address people use to reach the site ------------------------
# Must end in "/". A wrong value shows up as a page with no styling (CSS and
# JavaScript are requested from the wrong address) and as broken links in emails.
APP_BASE_URL=https://lms.example.com/

# production hides error details from visitors. Leave it on production for
# anything public.
CI_ENVIRONMENT=production

# ---- Your administrator account --------------------------------------
# Created when the container starts, ONLY if no active administrator exists yet.
# After that it is ordinary data managed under Admin > Users, and changing or
# removing these lines never edits or deletes anything. The password needs 12+
# characters; you can delete the password line once you have signed in.
# Sign in at /login with the EMAIL address.
lms.adminEmail    = admin@example.com
lms.adminPassword =
lms.adminName     = 'Administrator'


#======================================================================
# RUNNING BEHIND HTTPS (do this for a real site)
#======================================================================

# Only send the session cookie over HTTPS. Turn on once the site is served
# over https:// (with it on, signing in over plain http will not stick).
# cookie.secure = true

# Redirect http:// requests to https://. Use only when HTTPS ends at this stack's
# nginx, or your proxy forwards X-Forwarded-Proto; otherwise it can loop.
# app.forceGlobalSecureRequests = true

# Another proxy in front of this stack (Traefik, Caddy, Cloudflare, a load
# balancer)? List its address(es) so the app sees each visitor's real IP
# (sign-in throttling is per IP) and trusts its X-Forwarded-Proto. Comma list of
# IPs or CIDR ranges; optional =Header (default X-Forwarded-For). Leave empty
# when visitors reach this stack's nginx directly.
# app.proxyIPs = 172.16.0.0/12

# HSTS: browsers refuse plain http for this site for this many seconds once
# they've seen it over https. Sent only when the base URL is https://. Set 0
# before ever moving the site back to http.
# lms.hstsMaxAge = 31536000

# Encrypts two-step sign-in secrets in the database. Generate once and keep it:
# losing or changing it means everyone using two-step sign-in must have it reset
# by an administrator. Generate with:  php -r 'echo "hex2bin:".bin2hex(random_bytes(32)),"\n";'
# encryption.key = hex2bin:...


#======================================================================
# EMAIL (strongly recommended: nobody can register, reset a password, or
# receive a purchase/certificate email without it)
#======================================================================

# email.protocol   = smtp
# email.SMTPHost   = smtp.example.com
# email.SMTPPort   = 587
# tls for port 587, ssl for port 465
# email.SMTPCrypto = tls
# email.SMTPUser   = ''
# email.SMTPPass   = ''
# email.fromEmail  = noreply@example.com
# email.fromName   = 'My Academy'


#======================================================================
# SELLING COURSES AND MEMBERSHIPS (Stripe). Skip if everything is free.
#======================================================================
# One switch picks the key pair, so you can keep both filled in:
#   stripe.testMode = true   -> sandbox keys, no real money moves
#   stripe.testMode = false  -> LIVE keys, real payments
# With no key for the active mode, course pages say purchasing is unavailable.
#
# Webhook (Stripe Dashboard > Developers > Webhooks): endpoint
#     https://YOUR-SITE/webhook/stripe
# subscribed to these events:
#     checkout.session.completed
#     checkout.session.async_payment_succeeded
#     charge.refunded
#     charge.dispute.created
#     customer.subscription.updated
#     customer.subscription.deleted
#     invoice.paid
# Test mode and live mode each have their own webhook and their own whsec_ secret.
# Promotion codes are created in the Stripe Dashboard (Products > Coupons).

# stripe.testMode          = true
# stripe.publicTestKey     = pk_test_xxx
# stripe.privateTestKey    = sk_test_xxx
# stripe.testWebhookSecret = whsec_xxx
# stripe.publicLiveKey     = pk_live_xxx
# stripe.privateLiveKey    = sk_live_xxx
# stripe.liveWebhookSecret = whsec_xxx

# Currency for prices that don't name one (ISO code, lowercase).
# stripe.currency          = usd


#======================================================================
# SITE DEFAULTS (all optional)
#======================================================================
# Starting values for what administrators edit under Admin > Settings. Once an
# administrator saves that page, the saved values win and these are ignored.

# lms.siteName     = 'PortIgniter LMS'
# lms.tagline      = 'Written lessons, video, hands-on labs and knowledge checks.'
# lms.footerText   = 'Practical, hands-on training.'
# Also printed on certificates.
# lms.companyName  = 'My Company'
# lms.companyUrl   = 'https://www.example.com'
# lms.supportEmail = 'support@example.com'
# Largest logo / hero image upload, in KB.
# lms.logoMaxKb    = 512

# Largest video upload for course authors, in MB.
# lms.videoMaxMb = 500

# Let nginx deliver course videos/files after the app checks access (faster,
# handles seeking). Needs this nginx location (the compose file already mounts
# the writable volume read-only at /var/www/lms-writable):
#   location /_protected/ { internal; alias /var/www/lms-writable/; }
# lms.accelRedirect = false
# lms.accelPrefix   = /_protected/

# Daily backups made by the scheduler service (php spark lms:backup), kept in
# writable/backups (same volume as the data: copy them off the host too).
# Restore: docker compose exec php php spark lms:restore <file>
# lms.backupDaily = false
# lms.backupHour  = 3
# lms.backupKeep  = 7

# May visitors create their own accounts? (Buying a course or holding an
# invitation key always lets a person create one.)
# lms.allowRegistration = true
# lms.passwordMinLength = 10

# Show a promotion-code box on the Stripe payment page.
# lms.allowPromoCodes = true

# Custom CSS and tracking snippets, added to every page except the admin area.
# Easier to paste them in Admin > Settings > Custom code.
# lms.customCss    = ''
# lms.customHead   = ''
# lms.customFooter = ''


#======================================================================
# DOCKER / COMPOSE (all optional)
#======================================================================

# LMS release to run (default: the release the compose file was published
# with). Set a newer one and run `docker compose up -d` to upgrade.
# LMS_TAG=2026.10.25

# Host port for the site (plain HTTP; put HTTPS on your reverse proxy). Use 80
# if this stack faces the internet directly, or 127.0.0.1:7080 so only a proxy
# on the same host can reach it.
# HTTP_PORT=7080

# How long the app container waits for the database on start (seconds).
# LMS_DB_WAIT_SECONDS=60
Your .env file holds your database passwords and Stripe keys. Keep it private, and back it up somewhere safe, separately from the server. Leave CI_ENVIRONMENT=production on any site other people can reach.

Two formatting rules trip people up:

  • Quote values that contain spaces, for example email.fromName = 'My Academy'. An unquoted space stops the application from starting.
  • Don’t put comments after a value on the same line. Keep comments on their own lines.

3. Create your compose file

Save the file below as docker-compose.yml in the same folder as your .env file. Settings belong in .env, not in this file.

# PortIgniter LMS
#
#   1. docker login registry.portigniter.com      (your license email + license key)
#   2. Put this file and your .env in the same folder
#   3. docker compose up -d
#
# Settings live in .env, not in this file. Variables this file reads from .env:
#
#   MARIADB_ROOT_PASSWORD  required  database root password (choose a long random one)
#   MARIADB_DATABASE       required  database name, e.g. lms
#   MARIADB_USER           required  database user, e.g. lms
#   MARIADB_PASSWORD       required  database user's password
#                                    (the four MARIADB_* values are applied only when the
#                                    database is first created; changing them later does
#                                    not change an existing database)
#   LMS_TAG                optional  LMS release to run (default below). Change it to upgrade.
#   HTTP_PORT              optional  host port for the site, default 7080. Use 80 when this
#                                    stack faces the internet directly, or 127.0.0.1:7080 to
#                                    accept connections only from a reverse proxy on this host.
#   LMS_DB_WAIT_SECONDS    optional  how long the app waits for the database on start, default 60
#
# Every application setting (site address APP_BASE_URL, admin account lms.admin*, email.*,
# stripe.*, lms.*, cookie.*, app.*, ...) is also set in .env and passed to the app as is.
# Don't add them under `environment:` below: Compose would let those override .env.
# Any change to .env applies after: docker compose up -d
#
# The site speaks plain HTTP on HTTP_PORT. For HTTPS, put your reverse proxy or load
# balancer in front of it (and set APP_BASE_URL to the https:// address).
#
# Your data is in two named volumes; back up both:
#   lms_writable   uploads, courses, certificates, backups, sessions, logs
#   db_data        the database

x-logging: &logging
  driver: json-file
  options:
    max-size: "10m"
    max-file: "5"

networks:
  lms:

volumes:
  db_data:
  lms_writable:
  public_assets:

services:
  # One-shot job on each start: makes the data volumes writable by the app (uid 1000).
  writable-init:
    image: registry.portigniter.com/lms:${LMS_TAG:-2026.10.25}
    user: root
    restart: "no"
    entrypoint: ["sh", "-c"]
    command:
      - >-
        mkdir -p /writable/cache /writable/logs /writable/session /writable/debugbar /writable/uploads/branding
        && find /writable /public ! -user 1000 -exec chown 1000:1000 {} +
    volumes:
      - lms_writable:/writable
      - public_assets:/public
    networks:
      - lms

  nginx:
    image: registry.portigniter.com/nginx-base:2026.10.22
    restart: unless-stopped
    logging: *logging
    ports:
      - "${HTTP_PORT:-7080}:80"
    volumes:
      - public_assets:/var/www/html/public:ro
      - lms_writable:/var/www/lms-writable:ro
    depends_on:
      - php
    networks:
      - lms

  # The application. Applies database updates and creates the first administrator on start.
  php:
    image: registry.portigniter.com/lms:${LMS_TAG:-2026.10.25}
    restart: unless-stopped
    logging: *logging
    env_file:
      - .env
    volumes:
      - lms_writable:/var/www/html/writable
      - public_assets:/public-export
    depends_on:
      writable-init:
        condition: service_completed_successfully
      mariadb:
        condition: service_healthy
    networks:
      - lms

  # Background jobs every 2 minutes: finishes timed quizzes nobody came back to, and makes
  # the daily backup when lms.backupDaily = true in .env (see lms.backupHour, lms.backupKeep).
  scheduler:
    image: registry.portigniter.com/lms:${LMS_TAG:-2026.10.25}
    restart: unless-stopped
    logging: *logging
    env_file:
      - .env
    environment:
      LMS_AUTO_MIGRATE: "0"
    command: ["sh", "-c", "while true; do php spark quiz:finalize-expired --no-header || true; php spark lms:backup --if-due --no-header || true; sleep 120; done"]
    volumes:
      - lms_writable:/var/www/html/writable
    depends_on:
      - php
    networks:
      - lms

  mariadb:
    image: registry.portigniter.com/mariadb:12.3
    restart: unless-stopped
    logging: *logging
    environment:
      MARIADB_ROOT_PASSWORD: ${MARIADB_ROOT_PASSWORD:?Set MARIADB_ROOT_PASSWORD in .env}
      MARIADB_DATABASE: ${MARIADB_DATABASE:?Set MARIADB_DATABASE in .env}
      MARIADB_USER: ${MARIADB_USER:?Set MARIADB_USER in .env}
      MARIADB_PASSWORD: ${MARIADB_PASSWORD:?Set MARIADB_PASSWORD in .env}
    healthcheck:
      test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
      interval: 10s
      timeout: 5s
      retries: 12
      start_period: 30s
    volumes:
      - db_data:/var/lib/mysql
    networks:
      - lms
The site speaks plain HTTP on port 7080 by default. For a real site, put a reverse proxy such as Caddy, Traefik, or nginx in front of it to handle HTTPS, and see Running behind HTTPS below.

4. Start PortIgniter LMS

From the folder that holds both files, start the stack:

docker compose up -d

On first start, PortIgniter LMS:

  1. Prepares the data volumes so the application can write to them.
  2. Waits for the database, then creates all of its tables.
  3. Creates your administrator account from lms.adminEmail and lms.adminPassword.
  4. Publishes its stylesheets, scripts, and images for nginx to serve.
  5. Starts the scheduler, which checks every 2 minutes for timed quizzes to finish and backups to make.

You can follow along with docker compose logs -f php. When the log shows ready to handle connections, the site is ready.

5. Sign in

Open the address you set in APP_BASE_URL. For a quick local test, set APP_BASE_URL=http://localhost:7080/ and open that address. You should see the front page of your new course catalog, which is empty for now.

Click Sign in and use the email address and password from your .env file.

If your credentials are refused, check docker compose logs php for a warning that the administrator wasn’t created. The usual cause is a password shorter than 12 characters. See Troubleshooting.

PortIgniter LMS sign-in page

After signing in, you will land on your dashboard. The administrator menus in the header give you access to Courses, Reviews, Reports, Users, and Settings.

Administrator dashboard and header menus after first sign-in

Once you have signed in, you can delete the lms.adminPassword line from .env. The account is now ordinary data, and changing or removing these lines never edits or deletes it. Manage it under Admin > Users from now on.

6. Make it yours

Open Admin > Settings and set your site name, tagline, footer text, support email, and logo. Upload a front-page image if you want one. See Site Settings for every option.

If you plan to let people register, reset passwords, or buy courses, configure email now. Without it, nobody receives account links.

7. Create your first course

Open Admin > Courses, give your course a title, choose A first module and lesson, and click Create. The course builder opens with a module and a lesson ready to edit. Add a text block, write a few lines, then click Publish.

That’s a published course. Building Courses covers the builder in depth.

Running behind HTTPS

For any real site, terminate HTTPS at a reverse proxy in front of the stack, then update .env:

  1. Set APP_BASE_URL to your https:// address.
  2. Set cookie.secure = true so session cookies are only sent over HTTPS.
  3. If your proxy runs on the same host, set HTTP_PORT=127.0.0.1:7080 so only the proxy can reach the site.
  4. If your proxy is a separate system, such as Traefik in another network, Cloudflare, or a load balancer, list its address in app.proxyIPs. This lets PortIgniter LMS see each visitor’s real IP address, which sign-in throttling depends on.

Apply the changes with docker compose up -d.

Configuration reference

All settings live in your .env file. Everything not listed as required in step 2 has a working default. After any change, run docker compose up -d to apply it.

Required

Variable Default Meaning
MARIADB_ROOT_PASSWORD None Database root password. Applied only when the database is first created.
MARIADB_DATABASE None Database name, such as lms.
MARIADB_USER None Database user, such as lms.
MARIADB_PASSWORD None Database user’s password.
APP_BASE_URL None The site address, ending in /. A wrong value shows up as a page with no styling and as broken links in emails.
CI_ENVIRONMENT production Leave it on production for anything public. It hides error details from visitors.
lms.adminEmail None The first administrator’s email address, used to sign in.
lms.adminPassword None The first administrator’s password, 12 or more characters. Delete it after you sign in.
lms.adminName Administrator The first administrator’s display name.

The administrator account is created only when no active administrator exists yet.

HTTPS and proxies

Variable Default Meaning
cookie.secure false Send the session cookie only over HTTPS. Turn it on once the site uses https://, or signing in over plain HTTP won’t stick.
app.forceGlobalSecureRequests false Redirect http:// requests to https://. Use only when your proxy forwards X-Forwarded-Proto, or it can loop.
app.proxyIPs Empty Comma-separated IP addresses or CIDR ranges of proxies in front of the stack. Add =Header to use a header other than X-Forwarded-For. Leave it empty when visitors reach nginx directly.
lms.hstsMaxAge 31536000 How long browsers should insist on HTTPS, in seconds. Sent only when APP_BASE_URL is https://. Set it to 0 before ever moving the site back to HTTP.
encryption.key None Encrypts two-step sign-in secrets in the database. Generate it once and keep it. See Users & Sign-In.

Email

PortIgniter LMS sends account links, purchase confirmations, review results, and certificates by email. Every message goes out as branded HTML with a plain-text version.

Variable Default Meaning
email.protocol None Set to smtp.
email.SMTPHost None Your SMTP server.
email.SMTPPort None Usually 587 (with tls) or 465 (with ssl).
email.SMTPCrypto None tls or ssl, matching the port.
email.SMTPUser / email.SMTPPass None SMTP credentials.
email.fromEmail None The address emails come from.
email.fromName None The sender name. Quote it if it contains spaces.

Payments (Stripe)

Variable Default Meaning
stripe.testMode true true uses your test keys and moves no real money. false uses your live keys.
stripe.publicTestKey / stripe.privateTestKey None Your Stripe test-mode publishable and secret keys.
stripe.testWebhookSecret None The signing secret of your test-mode webhook.
stripe.publicLiveKey / stripe.privateLiveKey None Your Stripe live-mode keys.
stripe.liveWebhookSecret None The signing secret of your live-mode webhook.
stripe.currency usd The currency for prices that don’t name one, as a lowercase ISO code.

See Selling Courses for the full Stripe setup, including the webhook events to subscribe to.

Site defaults

These are starting values for what administrators edit under Admin > Settings. Once an administrator saves that page, the saved values win.

Variable Default Meaning
lms.siteName PortIgniter LMS Shown in the header, page titles, and emails.
lms.tagline Built in Shown on the front page.
lms.footerText Built in Shown in the site footer.
lms.companyName / lms.companyUrl None Your company. The name is also printed on certificates.
lms.supportEmail None Shown in email footers.
lms.logoMaxKb 512 Largest logo or front-page image upload, in KB.
lms.allowRegistration true Whether visitors can create their own accounts.
lms.passwordMinLength 10 Minimum password length for learners.
lms.allowPromoCodes true Show a promotion-code box on the Stripe payment page.
lms.customCss / lms.customHead / lms.customFooter Empty Custom CSS and snippets. Easier to paste in Admin > Settings > Custom code.

Media delivery

Variable Default Meaning
lms.videoMaxMb 500 Largest video upload for course authors, in MB.
lms.accelRedirect false Let nginx deliver course videos and files after the application checks access. Faster for heavy video traffic, and requires an extra nginx location. See How PortIgniter LMS Works.
lms.accelPrefix /_protected/ The internal nginx location used with lms.accelRedirect.

Backups

Variable Default Meaning
lms.backupDaily false Make a backup every day.
lms.backupHour 3 The hour (UTC) the daily backup is made.
lms.backupKeep 7 How many daily backups to keep.

See Backups & Upgrades.

Docker and Compose

Variable Default Meaning
LMS_TAG 2026.10.25 The release to run. Set a newer one and run docker compose up -d to upgrade.
HTTP_PORT 7080 The host port for the site. Use 80 if the stack faces the internet directly, or 127.0.0.1:7080 so only a proxy on the same host can reach it.
LMS_DB_WAIT_SECONDS 60 How long the application waits for the database on start.

Next steps

Top