Skip to content
Marolum documentation contents
Marolum documentation

Docker Compose and systemd

A production Compose file, a systemd unit that starts Marolum 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/marolum: 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/marolum/
├── compose.yaml          the service definition below
├── .env                  MAROLUM_IMAGE and MAROLUM_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/marolum/compose.yaml
services:
  marolum:
    image: ${MAROLUM_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 Marolum over the compose network
    volumes:
      - /srv/marolum/data:/data
      - /srv/marolum/run:/run/marolum:ro
    environment:
      MAROLUM_LOCAL_ADMIN_PASSWORD_FILE: /run/marolum/admin-password
      MAROLUM_BACKENDS_FILE: /run/marolum/backends.json
      MAROLUM_PUBLIC_ORIGIN: https://${MAROLUM_HOSTNAME}
    # Only when the platform has no workload identity (Azure VM, VMware):
    # env_file:
    #   - /srv/marolum/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:
      MAROLUM_HOSTNAME: ${MAROLUM_HOSTNAME}
    ports:
      - '80:80'
      - '443:443'
    volumes:
      - /srv/marolum/caddy/Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy-data:/data
    depends_on:
      - marolum

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

Caddy reads {$MAROLUM_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: ${MAROLUM_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 Marolum is reachable only from Caddy and the host loopback

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

Start on boot with systemd

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

[Service]
Type=oneshot
RemainAfterExit=yes
WorkingDirectory=/srv/marolum
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 marolum.service
systemctl status marolum.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/marolum/bin/backup.sh
set -euo pipefail

cd /srv/marolum
source .env

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

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

cp /srv/marolum/data/marolum.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/marolum/data/marolum.db)" ]
docker run --rm --entrypoint node \
  --mount "type=bind,source=${target},target=/backup.db,readonly" \
  "$MAROLUM_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/marolum/backups/marolum-*.db | tail -n +15 | xargs -r rm --

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

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

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

[Install]
WantedBy=timers.target
sudo chmod 700 /srv/marolum/bin/backup.sh
sudo systemctl daemon-reload
sudo systemctl enable --now marolum-backup.timer
sudo systemctl start marolum-backup.service   # run one now and read its output
journalctl -u marolum-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 marolum
Restart docker compose restart marolum
Stop for maintenance sudo systemctl stop marolum.service
Upgrade Take a backup, edit MAROLUM_IMAGE in .env, docker compose pull && docker compose up --detach
Read the startup failure line `docker compose logs marolum

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