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.