What this covers

What to back up, how to take a backup that is internally consistent, and how to restore from one. Scriptor does not ship an automatic backup mechanism; this topic covers the manual recipe and the cron pattern that wraps it.

Walkthrough

What to back up

Three paths cover everything that is not in the source tree:

Path What it is Loss =
data/imanager.db SQLite database: pages, users, settings, file metadata. WAL companion files (data/imanager.db-wal, data/imanager.db-shm) are part of the database too. All content.
public/uploads/ Uploaded files + generated thumbnails. Per-image titles travel in the database, not here. All images.
data/settings/custom.scriptor-config.php Operator overrides for site settings, theme, plugins.disabled list. Site config reverts to defaults.

You do not need to back up:

  • vendor/ (rebuildable with composer install).
  • data/cache/ (regenerated on demand).
  • data/logs/ (rotates anyway; backing them up is a separate decision).
  • data/sessions/ (active logins lost on restore is fine; a re-login is faster than restoring a partial session).
  • public/editor-assets/ (shipped with Scriptor; rebuildable from the source tree).
  • The Scriptor source itself (live in git; tag-and-pull is the faster recovery).

Take a consistent backup

The trap with SQLite is that copying the .db file while a write is in flight can give you a corrupt copy. Two options.

Option A: stop writes, then copy (downtime, simple)

  1. Stop the web server, or put it behind a maintenance page that returns 503 for everything that is not GET.

  2. Copy the three paths:

    tar czf scriptor-backup-$(date +%Y%m%d-%H%M).tgz \
      data/imanager.db data/imanager.db-wal data/imanager.db-shm \
      public/uploads/ \
      data/settings/custom.scriptor-config.php
    
  3. Restart the web server.

Downtime is the time of the tar: seconds for small sites, minutes for sites with many MB of uploads.

Option B: use SQLite's online backup API (zero downtime, SQL-level)

The sqlite3 CLI has a .backup command that takes a consistent snapshot while the database is being written to:

sqlite3 data/imanager.db ".backup '/tmp/imanager.db.snapshot'"
tar czf scriptor-backup-$(date +%Y%m%d-%H%M).tgz \
  /tmp/imanager.db.snapshot \
  public/uploads/ \
  data/settings/custom.scriptor-config.php
rm /tmp/imanager.db.snapshot

The snapshot is taken at a transactionally-consistent point; the running site sees no interruption. The public/uploads/ copy is still file-level; on a busy site, an upload mid-tar could land in a half-state in the archive (rare in practice because uploads are atomic per file, but the metadata row in the snapshot might point at a file that did not make it into the archive). Re-uploading is the fix when it happens.

Automate it with cron

A nightly backup script using Option B:

#!/usr/bin/env bash
# /etc/cron.daily/scriptor-backup
set -euo pipefail

PROJECT=/opt/scriptor
BACKUP_DIR=/var/backups/scriptor
SNAPSHOT=/tmp/imanager.db.snapshot.$$

mkdir -p "$BACKUP_DIR"

sqlite3 "$PROJECT/data/imanager.db" ".backup '$SNAPSHOT'"

tar czf "$BACKUP_DIR/scriptor-$(date +%Y%m%d).tgz" \
  -C "$PROJECT" \
  --transform "s|$SNAPSHOT|data/imanager.db|" \
  "$SNAPSHOT" \
  public/uploads/ \
  data/settings/custom.scriptor-config.php

rm "$SNAPSHOT"

# Keep 14 days.
find "$BACKUP_DIR" -name "scriptor-*.tgz" -mtime +14 -delete

For Docker, keep two things apart. The script, the cron entry, and the backup artifacts belong on the host: the container's filesystem layer is wiped by every rebuild, and the container usually has no cron at all. Reaching the data through the container is fine, though, and with named volumes it is the supported path, because the host cannot read the volume contents directly.

A minimal Docker variant of the nightly script. Everything lands in $BACKUP_DIR on the host; the containers are only data bridges:

#!/usr/bin/env bash
# Runs on the HOST (host cron, host backup dir).
set -euo pipefail

BACKUP_DIR=/var/backups/scriptor
ts=$(date +%Y%m%d)
mkdir -p "$BACKUP_DIR"

# 1. DB dump: the CLI runs inside the container, but stdout
#    is redirected to a host file. Consistent without
#    stopping the site.
docker exec scriptor-demo vendor/bin/imanager dump \
  --db=/var/www/scriptor/data/imanager.db \
  > "$BACKUP_DIR/db-$ts.sql"

# 2. Uploads: a throwaway helper container mounts the named
#    volume read-only and tars it onto a host bind-mount.
docker run --rm \
  -v scriptor_scriptor-uploads:/u:ro \
  -v "$BACKUP_DIR:/out" \
  alpine tar czf "/out/uploads-$ts.tar.gz" -C /u .

# 3. Settings override: a single file, docker cp is enough.
docker cp \
  scriptor-demo:/var/www/scriptor/data/settings/custom.scriptor-config.php \
  "$BACKUP_DIR/custom.scriptor-config-$ts.php"

# Keep 14 days.
find "$BACKUP_DIR" -type f -mtime +14 -delete

The volume name carries the compose project as a prefix (scriptor_scriptor-uploads here); docker volume ls shows the real one on your host.

What would not survive, and is what the warning above is about: redirecting the dump to a path inside the container, e.g. docker exec scriptor-demo sh -c 'vendor/bin/imanager dump ... > /backups/db.sql'. That file lives in the container's writable layer and is gone after the next docker compose up --build.

Restore from a backup

  1. Stop the web server (or take the maintenance page live).

  2. Move the existing state aside instead of overwriting it (insurance against a bad backup):

    mv data/imanager.db data/imanager.db.before-restore-$(date +%s)
    mv public/uploads public/uploads.before-restore-$(date +%s)
    
  3. Extract the backup at the project root:

    tar xzf scriptor-backup-20260520-0300.tgz
    

    The archive paths are relative to the project root; the data/imanager.db, public/uploads/, and data/settings/custom.scriptor-config.php land in the right places.

  4. Fix ownership (web server's user needs to be able to read the files and write to data/, public/uploads/):

    chown -R www-data:www-data data/ public/uploads/
    
  5. Restart the web server. Visit the site and the editor; confirm that the content you expected to see is there.

  6. If the restore looks right, remove the *.before-restore-* directories. Until then, they are your fallback if the backup turned out to be inconsistent.

What to check after

After a backup:

  • The archive exists at the expected path.
  • tar tzf <archive> lists data/imanager.db, public/uploads/, data/settings/custom.scriptor-config.php.
  • A test extract into /tmp/restore-test/ yields readable files (sqlite3 /tmp/restore-test/data/imanager.db "SELECT count(*) FROM items" returns a number).

After a restore:

  • The frontend renders the pages you expected.
  • The editor loads; logging in with the credentials in the restored database succeeds.
  • Images on pages render (the upload directories are present).
  • The Plugins module's table looks the way you expected it to.

Troubleshooting

Restored site shows zero pages

Two checks:

  1. The wrong file was restored. The archive contained an older imanager.db that predates the content you expected. Look at the archive timestamp; restore a newer one.
  2. WAL was not restored. SQLite holds writes in data/imanager.db-wal until the next checkpoint. If the -wal file was not in the backup, the restored database is missing the most recent writes. Backups taken via the sqlite3 .backup command include the WAL automatically; tar-while-stopped backups need to include both files explicitly.

Restored site shows pages but no images

public/uploads/ was not in the backup, or was extracted to the wrong path. Check the archive contents and re-extract.

"Database is locked" after restore

The restored imanager.db-wal is from a different process and SQLite is confused. Delete the WAL file:

rm data/imanager.db-wal data/imanager.db-shm

The next request reconstructs them. (This works because the backup's checkpoint should have already flushed the WAL into the main database; if it did not, you would have a partial restore visible in step 1 above.)

Restore brings back a deleted plugin's modules

vendor/ is not part of the backup (intentionally), but data/settings/custom.scriptor-config.php is. If the override file references a plugin that is no longer in vendor/, the PluginManager will fail to find the class on the next request and log an error. Either restore the plugin via composer require to match what the override expects, or remove the disabled-list entry from the override file.

See also