Data management
SQLite and PostgreSQL storage, backups, restore, and the files that do not follow DATA_DIR.
Persistent state lives under DATA_DIR. Unset, that is ~/.9router on macOS and Linux, or %APPDATA%\9router on Windows. Docker images set DATA_DIR=/app/data and expect a volume there. A Unix-style path on Windows is ignored and the default is used.
Stop DurinDoor before a backup or restore. SQLite can be mid-write. Never delete ~/.9router without confirming it is not the live data directory.
Identify what to back up
DATA_DIR/
├── db/
│ ├── data.sqlite
│ ├── data.sqlite-wal
│ ├── data.sqlite-shm
│ ├── proxy-timeline.sqlite
│ └── backups/
├── auth/
├── logs/mitm/
├── mitm/
├── machine-id
├── master-key
├── headroom/
├── api-key-secret
├── jwt-secret # legacy, read if JWT_SECRET unset
├── durindoor-database.env # managed PostgreSQL startup override
└── durindoor-secrets.json # legacy Postgres URLFor SQLite, the main database and its backups live under DATA_DIR/db. PostgreSQL cutover snapshots use that backup directory. Keep credential directories and database files owner-only.
PostgreSQL installations keep the main store, usage, and proxy timeline in the selected PostgreSQL database. Copying DATA_DIR alone does not back up those rows. Take a full custom-format dump with pg_dump -Fc, verify its table listing with pg_restore -l, and rehearse the restore into an isolated database. Keep the dump owner-only and preserve the matching master key, machine ID, JWT secret, and protected service environment alongside it. A database dump without its encryption keys cannot recover provider credentials.
Files that do not follow DATA_DIR
Usage is stored in the active database.
DATA_DIR/usage.json is a one-time legacy import. After startup marks it migrated, runtime writes stay in the active database.
There is no DATA_DIR/log.txt. That name is not created.
ENABLE_REQUEST_LOGS=true writes metadata-only session folders under logs/ in process.cwd(), not under DATA_DIR. MITM dumps use DATA_DIR/logs/mitm. Dashboard Console log is an in-memory ring of 2000 lines.
If you still have a 9router tree and need provider-section labels rewritten in TOML or JSON configs, run the one-shot cutover:
node scripts/migrate-from-9router.mjs --dry-run
node scripts/migrate-from-9router.mjsIt tars ~/.9router to ~/.9router-backup-<stamp>.tar before any move. It does not rewrite API key secrets. SQLite files are not walked. Default --target-dir is ~/.durindoor. Runtime DATA_DIR still defaults to ~/.9router; set DATA_DIR if you pointed the script at another tree.
SQLite recovery
If SQLite reports corruption, stop every writer. Keep the database, WAL, and shared-memory files together. Restore only a verified backup; perform repair or salvage only on a copy.
Backing up a host directory
Set DATA_DIR to the verified absolute live path. Stop the source service or CLI first; the command below stops a Docker instance. Include the protected environment file in a separate private backup.
docker stop durindoor
BACKUP="$DATA_DIR.backup-$(date +%Y%m%d%H%M%S)"
cp -a -- "$DATA_DIR" "$BACKUP"$DATA_DIR must be an existing absolute path, not / and not empty. -- stops option parsing. -a keeps permissions and dotfiles. Store the copy on another disk.
Restoring a host directory
docker stop durindoor
BACKUP="$DATA_DIR.backup-YYYYMMDD"
TARGET="$DATA_DIR"
if [ -z "$TARGET" ] || [ "$TARGET" = "/" ] || [ ! -d "$TARGET" ]; then
echo "ERROR: TARGET must be a non-root existing directory." >&2; exit 1
fi
mv -- "$TARGET" "$TARGET.pre-restore-$(date +%Y%m%d%H%M%S)"
cp -a -- "$BACKUP" "$TARGET"Test a restore without touching production:
cp -a -- "$DATA_DIR.backup-YYYYMMDD" /tmp/durindoor-test-restore
DATA_DIR=/tmp/durindoor-test-restore durindoor --host 127.0.0.1 --port 20129Use the matching protected secrets. Open http://localhost:20129/dashboard and confirm providers, API keys, combos, and usage before swapping the live path. Stop the test instance when finished. Do not admit production traffic during the rehearsal.
Backing up a Docker named volume
Compose names the volume durindoor-data. Stop first, then archive from a helper container. Run umask 077 before creating archives so provider credentials remain private:
docker stop durindoor
docker run --rm \
-v durindoor-data:/data \
-v "$PWD:/backup" \
alpine tar czf /backup/durindoor-data-backup-$(date +%Y%m%d).tar.gz -C /data .Restoring a Docker named volume
This procedure removes the current container and data volume after making a safety archive. Stop any watchdog that can restart the app. Recreate the container afterward using the original volume name and protected environment file.
ARCHIVE="$PWD/durindoor-data-backup-YYYYMMDD.tar.gz"
if [ ! -f "$ARCHIVE" ]; then echo "Backup archive not found: $ARCHIVE"; exit 1; fi
if ! docker run --rm -v "$PWD:/backup" alpine tar tzf "/backup/$(basename "$ARCHIVE")" >/dev/null 2>&1; then
echo "ERROR: Archive is not a valid tar file: $ARCHIVE"; exit 1
fi
docker stop durindoor
docker run --rm \
-v durindoor-data:/data \
-v "$PWD:/backup" \
alpine tar czf /backup/durindoor-data-pre-restore-$(date +%Y%m%d%H%M%S).tar.gz -C /data .
docker rm -f durindoor
docker volume rm durindoor-data
docker volume create durindoor-data
docker run --rm \
-v durindoor-data:/data \
-v "$PWD:/backup" \
alpine tar xzf "/backup/$(basename "$ARCHIVE")" -C /datatar tzf first. Recreate the volume so leftover files from an earlier restore cannot remain.
Copy a named volume to a host path
docker run --rm \
-v durindoor-data:/data \
-v "$HOME/.durindoor:/host" \
alpine cp -a /data/. /host/Uninstall
docker stop durindoor && docker rm durindoor
docker volume rm durindoor-data
docker rmi ghcr.io/bloodf/durindoor:latestnpm uninstall -g durindoorRemoving DATA_DIR is a separate operator action. Confirm which path is live. ~/.9router is the same directory as DATA_DIR when the env var is unset.