- SQLCipher-encrypted backup creation via VACUUM INTO - Backup history with auto-pruning (keeps last 5) - Pre-restore automatic backup for safety - Restore from history or uploaded file - Stats display with table breakdown - Download hook for client-side file download - SECRET_KEY_DB config for encryption at rest Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
10 KiB
Database encryption at rest
Status: Complete (awaiting production deployment) Tier: 2 (Security / Infrastructure)
Goal
The entire Berrypod shop is a single encrypted SQLite file. Portable, private, encrypted. Copy the file, set your encryption key, and host anywhere.
Why
- True encryption at rest — not just sensitive fields, the entire database
- Safe backups — can store backup files anywhere without additional encryption
- Simple migration — copy file + set env var = working shop on new server
- Privacy by design — even if someone gets the file, data is protected
Current state
- Standard SQLite 3.51.1 (no encryption)
- Sensitive fields (API keys, TOTP secrets) encrypted with Cloak.Ecto using
SECRET_KEY_BASE - exqlite 0.34.0 compiled without SQLCipher
Target state
- SQLCipher-encrypted database file
- Encryption key via
SECRET_KEY_DBenvironment variable - Existing Cloak encryption remains (defence in depth for secrets)
- Safe backup via
VACUUM INTOworks on encrypted database - Admin backup page with database stats and restore
Security model
Two independent secrets, defence in depth:
| Secret | Purpose | Protects against |
|---|---|---|
SECRET_KEY_BASE |
Phoenix sessions, Cloak field encryption | SQL access without app secret |
SECRET_KEY_DB |
SQLCipher whole-database encryption | File access without DB key |
Both are required for production. If one is compromised, the other layer still protects.
SQLCipher spec:
- AES-256 in CBC mode
- HMAC-SHA512 per page (tamper detection)
- PBKDF2 key derivation (256,000 iterations)
- Each page independently encrypted
Implementation
Phase 1: Install SQLCipher and recompile exqlite
Dev machine (Debian/Ubuntu):
# Debian bookworm: use backports for SQLCipher 4.6.1 (stable has 3.4.1 which is too old)
sudo apt install -t bookworm-backports libsqlcipher-dev
# Ubuntu 24.04+: standard repos have a recent enough version
sudo apt install libsqlcipher-dev
Dev machine (macOS):
brew install sqlcipher
Set build environment and recompile:
# Tell exqlite to use system SQLCipher instead of bundled SQLite
export EXQLITE_USE_SYSTEM=1
export EXQLITE_SYSTEM_CFLAGS="-I/usr/include/sqlcipher"
export EXQLITE_SYSTEM_LDFLAGS="-lsqlcipher"
# Force recompile
mix deps.clean exqlite --build
mix deps.compile exqlite
Verify SQLCipher is active:
{:ok, conn} = Exqlite.Basic.open(":memory:")
{:ok, _q, result, _c} = Exqlite.Basic.exec(conn, "PRAGMA cipher_version;")
# Should return [["4.x.x"]] — if empty, SQLCipher not linked
Phase 2: Configure encryption key
Generate keys:
mix phx.gen.secret # → SECRET_KEY_BASE
mix phx.gen.secret # → SECRET_KEY_DB
Configure exqlite (runtime.exs):
# config/runtime.exs
# Database encryption (optional for dev, required for production)
db_key = System.get_env("SECRET_KEY_DB")
config :berrypod, Berrypod.Repo,
database: database_path,
key: db_key # nil = unencrypted, string = SQLCipher encryption
The :key option is native to exqlite — it handles the PRAGMA key automatically on connection.
Dev mode: No SECRET_KEY_DB set = unencrypted database (easier local development).
Production mode: SECRET_KEY_DB required = encrypted database.
Phase 3: Fresh database with encryption
Since we're starting fresh (no migration needed):
# Delete old unencrypted database
rm berrypod_dev.db berrypod_dev.db-shm berrypod_dev.db-wal
# Start with encryption enabled
SECRET_KEY_DB="$(mix phx.gen.secret)" mix ecto.create
SECRET_KEY_DB="your-key" mix ecto.migrate
SECRET_KEY_DB="your-key" mix phx.server
Phase 4: Fly.io deployment
Update Dockerfile:
# Install SQLCipher
RUN apt-get update -y && \
apt-get install -y libsqlcipher-dev && \
apt-get clean && rm -rf /var/lib/apt/lists/*
# Build exqlite with system SQLCipher
ENV EXQLITE_USE_SYSTEM=1
ENV EXQLITE_SYSTEM_CFLAGS="-I/usr/include/sqlcipher"
ENV EXQLITE_SYSTEM_LDFLAGS="-lsqlcipher"
Set the secret:
fly secrets set SECRET_KEY_DB="$(mix phx.gen.secret)"
Deploy:
fly deploy
Admin backup page
Route: /admin/backup
Database stats display
Show useful context before backup/restore:
Overview section:
- Total database size (formatted: "12.3 MB")
- Encryption status (SQLCipher version or "Unencrypted")
- Database created date
- Last backup date (if tracked)
Table breakdown:
| Table | Rows | Size |
|---|---|---|
| products | 16 | 45 KB |
| product_variants | 142 | 28 KB |
| product_images | 89 | 12 KB |
| orders | 23 | 18 KB |
| images | 156 | 8.2 MB |
| settings | 42 | 4 KB |
| ... |
Key counts:
- Products: 16
- Orders: 23
- Media files: 156
- Newsletter subscribers: 89
Queries for stats:
-- Total database size
SELECT page_count * page_size as size
FROM pragma_page_count(), pragma_page_size();
-- Row counts per table
SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%';
-- Then COUNT(*) each
-- Table sizes (via dbstat virtual table)
SELECT name, SUM(pgsize) as size
FROM dbstat
GROUP BY name
ORDER BY size DESC;
-- SQLCipher version
PRAGMA cipher_version;
Download backup
Use SQLite's VACUUM INTO for a safe, consistent backup:
def create_backup do
timestamp = DateTime.utc_now() |> Calendar.strftime("%Y%m%d-%H%M%S")
backup_path = Path.join(System.tmp_dir!(), "berrypod-backup-#{timestamp}.db")
Ecto.Adapters.SQL.query!(Repo, "VACUUM INTO ?", [backup_path])
{:ok, backup_path}
end
The backup file is encrypted with the same key — portable to any server with that key.
UI:
- "Download backup" button
- Shows estimated file size
- Filename:
berrypod-backup-YYYYMMDD-HHMMSS.db
Restore backup
- Upload encrypted backup file
- Validate it opens with the current key
- Show comparison: current vs uploaded (row counts, size)
- Confirm with explicit action ("Replace current database")
- Stop accepting requests (maintenance mode)
- Replace database file
- Restart application
UI:
- File upload dropzone
- Validation feedback (valid/invalid/wrong key)
- Side-by-side comparison before restore
- Confirmation modal with warnings
Task breakdown
| # | Task | Est | Notes |
|---|---|---|---|
| 1 | ✓ | apt install -t bookworm-backports libsqlcipher-dev (4.6.1) |
|
| 2 | ✓ | Env vars, mix deps.clean/compile |
|
| 3 | PRAGMA cipher_version |
✓ | Returns "4.6.1 community" |
| 4 | :key config to runtime.exs |
✓ | Required in prod, optional in dev |
| 5 | ✓ | Verified encryption works | |
| 6 | ✓ | Install package, set build flags | |
| 7 | Deploy encrypted to Fly.io | 15m | Set secret, deploy, verify |
| 8 | ✓ | Berrypod.Backup with sizes, counts, encryption status |
|
| 9 | ✓ | /admin/backup LiveView |
|
| 10 | ✓ | VACUUM INTO, JS download hook | |
| 11 | ✓ | Upload, validation, comparison | |
| 12 | ✓ | Maintenance mode, swap, restart | |
| 13 | ✓ | Document backup procedures |
Total: ~8-9 hours
Security notes
- Key length: 256-bit minimum.
mix phx.gen.secretproduces 512-bit which is fine. - Key storage: Environment variables only. Never commit to code.
- Key rotation: Requires re-encrypting entire database. Rare operation.
- Lost key = lost data: No recovery possible. Document key backup procedures clearly.
- Defence in depth: Keep Cloak encryption for API keys even with DB encryption.
Dev workflow
For convenience, add to .envrc (direnv) or shell profile:
# Build flags (needed once per machine after installing SQLCipher)
export EXQLITE_USE_SYSTEM=1
export EXQLITE_SYSTEM_CFLAGS="-I/usr/include/sqlcipher"
export EXQLITE_SYSTEM_LDFLAGS="-lsqlcipher"
# Optional: dev database encryption (or omit for unencrypted dev)
# export SECRET_KEY_DB="dev-only-key-not-for-production"
Encrypted dev database
If you want to test encryption locally:
export SECRET_KEY_DB="dev-test-key-12345"
mix ecto.reset # recreates with encryption
mix phx.server
Unencrypted dev database
For simpler local development, just don't set SECRET_KEY_DB. The database will be unencrypted but otherwise identical.
Compatibility
- Litestream: Works with SQLCipher. Replicates encrypted bytes to S3.
- sqlite3 CLI: Use
sqlcipherCLI to open encrypted databases. - DB Browser for SQLite: Supports SQLCipher — enter key when opening.
- Tests: Run unencrypted (faster) unless specifically testing encryption.
Verification checklist
After implementation, verify:
# 1. SQLCipher is linked
mix run -e '{:ok, c} = Exqlite.Basic.open(":memory:"); {:ok, _, r, _} = Exqlite.Basic.exec(c, "PRAGMA cipher_version;"); IO.inspect(r.rows)'
# Should print [["4.x.x"]]
# 2. Encrypted database is unreadable without key
file berrypod_prod.db
# Should show "data" not "SQLite 3.x database"
# 3. Encrypted database opens with key
SECRET_KEY_DB="your-key" mix run -e 'Berrypod.Repo.query!("SELECT 1")'
# Should succeed
# 4. Encrypted database fails without key
mix run -e 'Berrypod.Repo.query!("SELECT 1")'
# Should fail with "file is not a database"
Files to modify
| File | Change |
|---|---|
config/runtime.exs |
Add :key option to Repo config |
Dockerfile |
Install SQLCipher, set build env vars |
fly.toml |
(no change, key via secrets) |
lib/berrypod/backup.ex |
New — backup/restore context |
lib/berrypod_web/live/admin/backup_live.ex |
New — backup admin page |
lib/berrypod_web/router.ex |
Add /admin/backup route |
README.md |
Document key management |