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 withcomposer 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)
-
Stop the web server, or put it behind a maintenance page that returns 503 for everything that is not GET.
-
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 -
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
-
Stop the web server (or take the maintenance page live).
-
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) -
Extract the backup at the project root:
tar xzf scriptor-backup-20260520-0300.tgzThe archive paths are relative to the project root; the
data/imanager.db,public/uploads/, anddata/settings/custom.scriptor-config.phpland in the right places. -
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/ -
Restart the web server. Visit the site and the editor; confirm that the content you expected to see is there.
-
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>listsdata/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
Pluginsmodule's table looks the way you expected it to.
Troubleshooting
Restored site shows zero pages
Two checks:
- The wrong file was restored. The archive contained an
older
imanager.dbthat predates the content you expected. Look at the archive timestamp; restore a newer one. - WAL was not restored. SQLite holds writes in
data/imanager.db-waluntil the next checkpoint. If the-walfile was not in the backup, the restored database is missing the most recent writes. Backups taken via thesqlite3 .backupcommand 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
- Updating Scriptor: backup before every update is the recommended workflow
- Users and access: password recovery via SQLite UPDATE references the same backup hygiene
- Reorder, move, and delete pages: the wrong-delete recovery path that uses this topic
- Site settings and theme switch: what
the
custom.scriptor-config.phpfile in the backup contains docs/install.mdin the Scriptor repo: which paths the install command creates (the inverse of what to back up)