Skip to content

Backups and Data Management

Glassy provides automatic and on-demand backup options so your second brain is always safe.

Automatic daily backups

Glassy creates a plain SQLite backup of your database every day at 02:00 (server time). Backups are stored in /app/data/backups inside the glassy-data Docker volume, and approximately 7 days of backups are retained automatically. No configuration is required. On-demand dumps are written to the same directory (fully timestamped filenames) — you can trigger one yourself with the CLI below.

The automatic backups share the volume with the database. /app/data/backups lives in the same glassy-data volume as notes.db, so a volume-level failure (damaged volume, host disk loss) takes the database and every automatic backup together. The daily schedule covers "I overwrote something yesterday", not "the volume died". Keep an off-volume copy of the newest artifact (the full volume snapshot below, or a scheduled docker cp glassy:/app/data/backups/notes-<latest>.db pull) — and drill it (see Verify that a restore works).

A missed backup also fails silently by default: the schedule's log carries no alarm you would read day to day, and a file-level drift check on the host stays green while a broken off-volume job dies on every fire (a systemd unit whose ExecStart path was retired exits 203/EXEC forever, for example). If your off-volume copy is driven by a timer of your own, verify the UNIT runs — check the artifact timestamps, not just the file bytes.

Freshness has a witness (v2.53.0+)

Two checks run without you:

  • scripts/check-backup-freshness.js ages the newest nightly notes-* backup in each backup directory with the fleet's 0/1/2 exit contract. An event backup (pre-restore, pre-migration) cannot fake nightly freshness. A missing directory is skipped, unless you pass --strict.
  • /api/health reports a backup block — {newest, ageHours, stale} — so an appliance can see its own backup state instead of assuming it.

If you drive an off-volume copy with your own timer, assert the artifact timestamps, not just that the unit is enabled. A retired ExecStart path exits 203/EXEC on every fire and changes nothing.

A recovery point before every migration

Any migration pass with pending work begins with one consistent VACUUM INTO snapshot taken from the live connection (WAL-complete). It is named for the last pending migration, encrypted when BACKUP_ENCRYPTION_KEY is set, and retained to three. A snapshot failure is loud and non-blocking — transactions still cover table rebuilds — and a routine boot with nothing pending takes no snapshot.

Verify that a restore works (drill)

"Backups run" and "backups can be restored" are different claims. When you need the second one is the wrong time to learn that they differ, so run a drill that keeps your live instance untouched:

  1. Pull the newest artifact out of the volume onto the host:
docker cp glassy:/app/data/backups/notes-<latest>.db ./restore-drill.db
  1. Check it opens and its content is real (SQLite integrity + row counts, run on the host where sqlite3 is installed):
sqlite3 restore-drill.db 'PRAGMA integrity_check;'        # → ok
sqlite3 restore-drill.db 'SELECT COUNT(*) FROM notes;'    # → a plausible, non-zero count
  1. For the encrypted (.db.enc) artifacts, the drill needs the backup key — decrypt with BACKUP_ENCRYPTION_KEY in reach (the restore command in Restore below handles .db.enc directly and transparently, so the drill can equally be "restore + read-back into a scratch container").
  2. Repeat on a schedule that matches your risk — a monthly drill is the difference between "backups run" and "backups can be trusted". A backup that has never been restored is a hypothesis, not a recovery plan.

On-demand backups (CLI)

For encrypted off-machine copies, use the backup CLI. The runtime image has no npm, so call node directly:

# Create a backup
docker compose exec glassy node server/utils/backup.js

# List available backups
docker compose exec glassy ls -1 /app/data/backups

# Restore from a backup
docker compose exec glassy node server/utils/backup.js --restore <backup-file>

Encryption

Set BACKUP_ENCRYPTION_KEY in .env to encrypt on-demand CLI backups (AES-256-GCM). Backups are saved as .db.enc files; the plain .db is removed after encryption.

# Generate a key (run once, save the output in a safe place OFF the machine):
openssl rand -hex 32

When BACKUP_ENCRYPTION_KEY is set, the automatic daily backup scheduler also encrypts its output using the same key and format.

Important: Store BACKUP_ENCRYPTION_KEY somewhere safe outside the machine. Without it, encrypted backups cannot be restored.

Full volume snapshot

To capture everything — the database, uploads, logs, and generated backups — in a single archive:

docker run --rm -v glassy-data:/data -v $(pwd):/backup alpine \
  tar czf /backup/glassy-backup.tar.gz -C /data .

Restore

The restore command auto-detects .db.enc (encrypted) and .db (plain) files, takes a pre-restore safety copy, and waits 5 seconds before overwriting the live database (press Ctrl+C to abort):

docker compose exec glassy node server/utils/backup.js --restore <backup-file>

For a volume restore from a snapshot:

docker compose down
docker volume rm glassy-selfhost_glassy-data
docker volume create glassy-selfhost_glassy-data
docker run --rm \
  -v glassy-selfhost_glassy-data:/data \
  -v $(pwd):/backup \
  alpine tar xzf /backup/glassy-backup.tar.gz -C /data
docker compose up -d

Database migrations run automatically on start after a restore.

Storage caps

Storage limits are configurable per tier via environment variables:

Variable Default
FREE_STORAGE_MB 10
PRO_STORAGE_MB 200
CLEAR_STORAGE_MB 200
ENTERPRISE_STORAGE_MB 51200

Learn more