The canonical recovery procedure when this stack's database, uploaded files,
or configuration is lost, corrupted, or needs rolling back to a known-good
snapshot. Backups are produced nightly by the backup service (phpbu); the
job is defined in config/phpbu/backup.json and fires at 03:00 UTC (the
scheduler service sets no TZ, so ofelia evaluates 0 0 3 * * * in UTC).
glpicrypt.key lives on the glpi-config volume (/etc/glpi) and is
GLPI's AES key for every encrypted database field — LDAP/AD bind
passwords, mail-collector passwords, external-DB and API credentials, and
plugin secrets. It is created once at install and never rotates.
Restoring the database without the matching key leaves all of those fields as undecryptable ciphertext. GLPI keeps running and the rows still exist, but every stored credential is unusable and must be re-entered by hand. There is no command to "re-derive" the plaintext — the key is the secret.
This is why backup.json archives the config directory (kept 90 days, longer
than the others), and why the entrypoint refuses to mint a new key when
config_db.php is present but the key is missing — it warns loudly instead, so
a "successful" DB restore can't silently become an unreadable one. A DB
restore is only complete with the config archive from the same era.
Stored on the glpi-backups volume (mounted at /backups in the backup
container), in three subdirectories:
| Path | What | Cadence | Retention |
|---|---|---|---|
/backups/db/glpi-db-*.sql.gz.enc |
openssl-encrypted (aes-256-cbc/pbkdf2) gzip of mysqldump --single-transaction --quick of the GLPI database, gzip'd. Taken with --databases, so the dump is self-contained (CREATE DATABASE IF NOT EXISTS + USE) and includes DROP TABLE IF EXISTS per table |
nightly 03:00 | capacity 5 GiB (oldest dropped) |
/backups/files/glpi-files-*.tar.gz.enc |
openssl-encrypted gzip tar of the glpi-var volume (/var/lib/glpi/files: uploads, pictures, inventories, dumps, sessions, cache) |
nightly 03:00 | outdated 30 days |
/backups/config/glpi-config-*.tar.gz.enc |
openssl-encrypted gzip tar of the glpi-config volume (/etc/glpi: config_db.php + glpicrypt.key + local config) |
nightly 03:00 | outdated 90 days |
/backups/phpbu.log |
JSON log of every phpbu run (success and failure) | per-run append | by phpbu |
Decryption is required. Every archive is encrypted at rest with phpbu's openssl crypt step, so the plaintext DB dump and
glpicrypt.keynever sit on the backups volume. You need the sameBACKUP_CRYPT_PASSWORDthat produced them (from the source stack's.env— store it separately; it is not in any backup). Decrypt with:openssl enc -d -a -aes-256-cbc -pbkdf2 -pass pass:"$BACKUP_CRYPT_PASSWORD" -in FILE.enc -out -The restore commands below pipe through it. Thebackupcontainer already hasopensslandBACKUP_CRYPT_PASSWORDin its env.
Both tar artefacts are created by phpbu from the absolute paths /snapshot/files
and /snapshot/config (the volumes are mounted there read-only). GNU tar
strips the leading /, so the archives store entries as snapshot/files/...
and snapshot/config/... — hence the --strip-components=2 in the restore
commands below.
make backup-list # or:
docker run --rm -v glpi-backups:/b:ro alpine sh -c 'ls -la /b/db /b/files /b/config'Pick a timestamp and pin it for the rest of the procedure:
export TS=20260627-030001 # the YYYYMMDD-HHMMSS in the filenames you chosedocker compose stop app web scheduler
# Leave db, valkey and backup up: we restore *into* db and read dumps via backup.The dump is self-contained, so importing it drops + recreates every table it contains:
docker compose exec -T backup sh -c \
'openssl enc -d -a -aes-256-cbc -pbkdf2 -pass pass:"$BACKUP_CRYPT_PASSWORD" \
-in "/backups/db/glpi-db-'"${TS}"'.sql.gz.enc" | zcat' \
| docker compose exec -T db sh -c 'mariadb -uroot -p"$MARIADB_ROOT_PASSWORD"'For a guaranteed-pristine restore (drops tables that exist now but weren't in the dump), drop the database first, then import:
docker compose exec -T db sh -c '
mariadb -uroot -p"$MARIADB_ROOT_PASSWORD" -e "
DROP DATABASE IF EXISTS \`$MARIADB_DATABASE\`;
CREATE DATABASE \`$MARIADB_DATABASE\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"'
# then run the import above ($MARIADB_DATABASE is set inside the db container)A large dump can take minutes — let it finish.
The app is stopped, so nothing is writing the volume. Use a throwaway container that mounts the target volume read-write and the backups read-only:
docker compose exec -T backup sh -c \
'openssl enc -d -a -aes-256-cbc -pbkdf2 -pass pass:"$BACKUP_CRYPT_PASSWORD" \
-in "/backups/files/glpi-files-'"${TS}"'.tar.gz.enc"' \
| docker run --rm -i -v glpi-var:/restore alpine sh -c '
cd /restore
rm -rf ./* ./.[!.]* 2>/dev/null || true
tar xzf - --strip-components=2'Same pattern, but into glpi-config. This is the step that re-instates
glpicrypt.key.
docker compose exec -T backup sh -c \
'openssl enc -d -a -aes-256-cbc -pbkdf2 -pass pass:"$BACKUP_CRYPT_PASSWORD" \
-in "/backups/config/glpi-config-'"${TS}"'.tar.gz.enc"' \
| docker run --rm -i -v glpi-config:/restore alpine sh -c '
cd /restore
rm -rf ./* ./.[!.]* 2>/dev/null || true
tar xzf - --strip-components=2'
# confirm both files are present
docker run --rm -v glpi-config:/c:ro alpine ls -l /c/config_db.php /c/glpicrypt.keyCredential consistency: the restored
config_db.phpcarries the DB host and credentials from when the backup was taken. If you are restoring into a fresh stack whosedbwas initialised with differentmake initpasswords, the app won't be able to connect. Either keep the originalGLPI_DB_*values in.env, or rewrite the connection settings without touching the schema:docker compose run --rm --user www-data --entrypoint sh app -c ' php bin/console database:configure --reconfigure \ --db-host=db --db-port=3306 \ --db-name="$GLPI_DB_NAME" --db-user="$GLPI_DB_USER" \ --db-password="$GLPI_DB_PASSWORD" --no-interaction'
database:configureonly writesconfig_db.php; it never drops or installs the schema, and it leavesglpicrypt.keyalone.
docker compose up -d
docker compose logs -f appThe entrypoint sees the restored config_db.php + the glpi_configs table and
runs database:update (bringing the restored schema up to the image's GLPI
version). It must not print the glpicrypt.key MISSING warning — if it
does, step 4 didn't land.
# Valkey may hold stale entries from before the restore
docker compose exec -u www-data app php bin/console cache:clear
docker compose ps # every service healthy
curl -sI "http://127.0.0.1:${GLPI_HTTP_PORT:-8080}/" | head -1Then log in and test a stored credential — e.g. an LDAP/AD directory
connection under Setup > Authentication > LDAP directories. A successful
bind proves glpicrypt.key was restored correctly; a failure means the key
didn't match the data.
For "undo a bad bulk change" incidents: do steps 1, 2, 5, 6 and skip the file and config restores. The app keeps its current files + key.
Caveat: files referenced by the restored DB that were deleted later become missing (404s), and files added after the snapshot become orphans the DB no longer references. For point-in-time fidelity, restore all three.
If the database is intact but glpicrypt.key went missing (the glpi-config
volume was wiped), restore only the config archive from the same era as the
data — step 4, then docker compose restart app. Without the original key
there is no way to recover the encrypted fields; you would have to re-enter
every stored credential by hand.
The db service runs with --log-bin=mariadb-bin --binlog-format=ROW --binlog_expire_logs_seconds=1209600 (14 days) and --sync_binlog=1, so PITR
within the last 14 days is possible. Binlogs live at
/var/lib/mysql/mariadb-bin.* on the glpi-db-data volume.
The phpbu dump uses
--single-transaction --quick(not--source-data), so it does not embed a binlog coordinate. Replay is therefore anchored by timestamp just after the dump, not by exact position — accept a small imprecision around the dump boundary and verify row counts afterward.
mariadb-binloginterprets--start/--stop-datetimein thedbcontainer's timezone (set fromTZin.env), which is not the 03:00 UTC the dump was triggered at. Don't assume "03:xx" — read the dump's actual completion time from/backups/phpbu.log(or the dump file's mtime) and express your window in that same timezone.
# 1. Restore the dump taken BEFORE the bad event (steps 1-2 above).
# 2. Replay binlog events from just after that dump up to just before the event.
# Substitute the real datetimes (db-container timezone) for the placeholders:
docker compose exec -T db sh -c '
mariadb-binlog --database="$MARIADB_DATABASE" \
--start-datetime="2026-06-27 05:01:00" \
--stop-datetime="2026-06-27 14:29:59" \
/var/lib/mysql/mariadb-bin.* \
| mariadb -uroot -p"$MARIADB_ROOT_PASSWORD"'--start-datetime ≈ just after the dump finished; --stop-datetime = the
instant before the bad event. --database filters to the GLPI database
(reliable with ROW format). Narrow the window iteratively if needed.
If the problem is a broken image, not lost data, pin a previous dated, immutable tag instead of restoring data:
# .env: GLPI_IMAGE_TAG=11.0.8-20260520
docker compose pull
docker compose up -dGLPI migrations are forward-only: don't put an older image on a database already migrated by a newer one — that errors. In that case do a full DB + image restore from the matching day. See runbook-day2-ops.md for the tag scheme.
make backup-verify # last night's dump is on disk and non-zero — does NOT restorebackup-verify proves the pipeline ran; it does not prove the artefacts are
restorable. Periodically run a real restore drill into a throwaway stack — a
backup you have never restored is a hypothesis, not a backup.
glpi-backups is a local Docker volume: it survives container restarts but not
host loss. For real disaster recovery, push the artefacts off-host — and pay
special attention to the config archive, which holds glpicrypt.key:
losing it means losing every encrypted field even if the DB survives.
Either bind-mount the backup target onto a NAS/NFS share:
# compose.override.yml
services:
backup:
volumes:
- /mnt/nas/glpi-backups:/backups # phpbu still writes /backups; now on the NAS…or run your existing tool (restic, rclone, Borg) on the host against the volume's mountpoint. The phpbu config is unchanged either way.