Skip to content
Lumaft documentation contents
Lumaft documentation

Docker Compose and systemd

A production Compose file, a systemd unit that starts Lumaft on boot, and a nightly cold-backup timer for any Linux VM.

Docker Compose and systemd

The EC2, Azure VM, and VMware guides all end with the same long docker run command. This page replaces it with three files you keep under /srv/lumaft: a Compose file, a systemd unit, and a backup script. They apply to any Linux virtual machine with Docker Engine.

Install Docker with the Compose plugin

Ubuntu ships both in one step:

sudo apt-get update && sudo apt-get install -y docker.io docker-compose-v2
sudo systemctl enable --now docker

Amazon Linux 2023 ships Docker Engine without the Compose plugin; add it from the Compose release, pinned:

sudo dnf install -y docker
sudo mkdir -p /usr/local/lib/docker/cli-plugins
sudo curl -fsSL "https://github.com/docker/compose/releases/download/v5.5.1/docker-compose-linux-$(uname -m)" \
  -o /usr/local/lib/docker/cli-plugins/docker-compose
sudo chmod +x /usr/local/lib/docker/cli-plugins/docker-compose
sudo systemctl enable --now docker
docker compose version

Layout

/srv/lumaft/
├── compose.yaml          the service definition below
├── .env                  LUMAFT_IMAGE and LUMAFT_HOSTNAME
├── caddy/Caddyfile       TLS termination
├── run/                  admin-password, backends.json (mode 0600, owner 1000)
├── data/                 the SQLite database, on its own block volume
├── backups/              cold backups
└── bin/backup.sh         the backup script below

data/ is the mounted data volume from the environment guide, owned by UID 1000 with mode 0700. run/ holds the two secret files from the same guide.

The Compose file

# /srv/lumaft/compose.yaml
services:
  lumaft:
    image: ${LUMAFT_IMAGE}
    restart: unless-stopped
    init: true
    read_only: true
    tmpfs:
      - /tmp:rw,noexec,nosuid,size=64m
    ports:
      - '127.0.0.1:8080:8080' # loopback only; Caddy reaches Lumaft over the compose network
    volumes:
      - /srv/lumaft/data:/data
      - /srv/lumaft/run:/run/lumaft:ro
    environment:
      LUMAFT_LOCAL_ADMIN_PASSWORD_FILE: /run/lumaft/admin-password
      LUMAFT_BACKENDS_FILE: /run/lumaft/backends.json
      LUMAFT_PUBLIC_ORIGIN: https://${LUMAFT_HOSTNAME}
    # Only when the platform has no workload identity (Azure VM, VMware):
    # env_file:
    #   - /srv/lumaft/s3-credentials.env
    logging:
      driver: json-file
      options:
        max-size: 50m
        max-file: '5'
    stop_grace_period: 30s

  caddy:
    image: caddy:2
    restart: unless-stopped
    environment:
      LUMAFT_HOSTNAME: ${LUMAFT_HOSTNAME}
    ports:
      - '80:80'
      - '443:443'
    volumes:
      - /srv/lumaft/caddy/Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy-data:/data
    depends_on:
      - lumaft

volumes:
  caddy-data:
# /srv/lumaft/.env
LUMAFT_IMAGE=ghcr.io/dekglas/lumaft@sha256:REPLACE_WITH_THE_RELEASE_DIGEST
LUMAFT_HOSTNAME=lumaft.example.com
# /srv/lumaft/caddy/Caddyfile
{$LUMAFT_HOSTNAME} {
    reverse_proxy lumaft:8080
}

Caddy reads {$LUMAFT_HOSTNAME} from the environment the Compose file passes it, obtains a certificate for that name, and renews it. Port 80 is for the certificate authority's validation and the HTTPS redirect.

What the file settles, once:

Setting Why
image: ${LUMAFT_IMAGE} The digest lives in .env; upgrading is a one-line change there
restart: unless-stopped Comes back after a crash or reboot, stays down after a deliberate stop
init, read_only, tmpfs The same hardening as the reference docker run
stop_grace_period: 30s Gives SQLite time to checkpoint the WAL on shutdown
logging limits The container log cannot fill the root disk
Caddy on the compose network Lumaft is reachable only from Caddy and the host loopback

After the first sign-in, remove the LUMAFT_LOCAL_ADMIN_PASSWORD_FILE line, delete run/admin-password, and docker compose up --detach to apply.

Start on boot with systemd

# /etc/systemd/system/lumaft.service
[Unit]
Description=Lumaft
Requires=docker.service
After=docker.service network-online.target
Wants=network-online.target

[Service]
Type=oneshot
RemainAfterExit=yes
WorkingDirectory=/srv/lumaft
ExecStart=/usr/bin/docker compose up --detach --remove-orphans
ExecStop=/usr/bin/docker compose stop --timeout 30
TimeoutStopSec=90

[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now lumaft.service
systemctl status lumaft.service
curl -fsS http://127.0.0.1:8080/api/v1/readiness

Type=oneshot with RemainAfterExit is the right shape for Compose: systemd runs up once, considers the unit active, and runs stop with the 30-second grace on shutdown. Docker's own restart policy handles crashes in between.

Nightly cold backup

Backups are cold — the container is stopped for the copy — so schedule them for a quiet time.

#!/usr/bin/env bash
# /srv/lumaft/bin/backup.sh
set -euo pipefail

cd /srv/lumaft
source .env

stamp="$(date -u +%Y%m%dT%H%M%SZ)"
target="/srv/lumaft/backups/lumaft-${stamp}.db"

docker compose stop --timeout 30 lumaft
trap 'docker compose start lumaft' EXIT

cp /srv/lumaft/data/lumaft.db "$target"
chmod 600 "$target"

# Verify: same size as the source, and SQLite reports the copy intact.
[ "$(stat -c %s "$target")" -eq "$(stat -c %s /srv/lumaft/data/lumaft.db)" ]
docker run --rm --entrypoint node \
  --mount "type=bind,source=${target},target=/backup.db,readonly" \
  "$LUMAFT_IMAGE" \
  -e "const r = require('better-sqlite3')('/backup.db', { readonly: true, fileMustExist: true }).pragma('integrity_check'); if (r[0].integrity_check !== 'ok') { console.error(r); process.exit(1); } console.log('integrity ok');"

# Keep the newest 14 local copies.
ls -1t /srv/lumaft/backups/lumaft-*.db | tail -n +15 | xargs -r rm --

echo "backup complete: $target"
# /etc/systemd/system/lumaft-backup.service
[Unit]
Description=Lumaft cold backup
Requires=lumaft.service
After=lumaft.service

[Service]
Type=oneshot
ExecStart=/srv/lumaft/bin/backup.sh
# /etc/systemd/system/lumaft-backup.timer
[Unit]
Description=Nightly Lumaft cold backup

[Timer]
OnCalendar=*-*-* 02:30:00 UTC
Persistent=true

[Install]
WantedBy=timers.target
sudo chmod 700 /srv/lumaft/bin/backup.sh
sudo systemctl daemon-reload
sudo systemctl enable --now lumaft-backup.timer
sudo systemctl start lumaft-backup.service   # run one now and read its output
journalctl -u lumaft-backup.service -n 20

Copy backups/ somewhere off the machine — a versioned object-store bucket with its own access policy, or your existing backup system. A backup on the same disk as the database protects against nothing that takes the disk with it. Restore steps are in Backup and disaster recovery.

Everyday commands

Task Command
Status docker compose ps
Logs docker compose logs --follow lumaft
Restart docker compose restart lumaft
Stop for maintenance sudo systemctl stop lumaft.service
Upgrade Take a backup, edit LUMAFT_IMAGE in .env, docker compose pull && docker compose up --detach
Read the startup failure line `docker compose logs lumaft

Before an upgrade, read Upgrades and versioning; a refused start naming a gated migration is expected behavior, not a failed upgrade.