Why SQLite on a VPS?
Most self-hosting tutorials push PostgreSQL or MySQL, but if you’re just running a personal blog, note-taking app, API service, or small internal tool, SQLite is often the better choice:
- Zero configuration: No service installation, no user permissions to manage, no connection pool tuning
- Zero cost: Single file storage, near-zero memory footprint, no need for extra 512MB+ RAM
- Easy backups: The entire database is one
.dbfile—cpis your backup strategy - Excellent performance: For read-heavy, low-concurrency workloads, SQLite’s read/write latency beats networked databases
Typical self-hosted scenarios where SQLite shines:
- Home Assistant (local smart home hub)
- Ghost / WordPress (after static export)
- Miniflux / FreshRSS (RSS readers)
- Plausible Analytics (privacy-first analytics)
- Various lightweight REST API services
When NOT to use SQLite: High-write concurrency (>100 QPS), multi-node replication, financial systems requiring strict transactional guarantees.
Core Architecture: How SQLite Works
┌─────────────────────────────────────────────────────────────┐
│ SQLite Self-Hosted Architecture │
│ │
│ ┌──────────┐ ┌──────────────┐ ┌─────────────────┐ │
│ │ App │───▶│ WAL File │───▶│ Main Data File │ │
│ │ Process │ │ (writes) │ │ (main.db) │ │
│ └──────────┘ └──────────────┘ └─────────────────┘ │
│ ▲ │ │
│ │ ┌──────────────────────────┘ │
│ │ │ │
│ ┌────▼────┐ ┌───▼──────────┐ ┌─────────────────────┐ │
│ │ Backup │ │ Read Queries │──▶│ Snapshot Backup │ │
│ │ Script │ │ (VACUUM / │ │ (rsync / cron) │ │
│ │ (cron) │ │ PRAGMA) │ └─────────────────────┘ │
│ └─────────┘ └──────────────┘ │
│ │
│ Key Features: │
│ • WAL mode: Read/write non-blocking, 5-10x concurrency gain│
│ • Single-file architecture: Migration/backup/versioning is │
│ trivially simple │
│ • Lock-free reads: Many concurrent readers don't block │
│ writers │
└─────────────────────────────────────────────────────────────┘
Step 1: Enable WAL Mode (Performance Critical)
By default, SQLite uses the DELETE journal mode where reads and writes block each other. Enabling WAL (Write-Ahead Logging) allows concurrent reads during writes, delivering significant performance improvements.
2.1 Enable WAL When Creating a Database
-- Create database with WAL enabled immediately
PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL; -- Balance between performance and safety
PRAGMA cache_size = -64000; -- 64MB cache
PRAGMA temp_store = MEMORY; -- Temp tables in memory
2.2 Enable WAL on Existing Database
-- Online switch (no downtime required)
PRAGMA journal_mode = WAL;
-- Verify
PRAGMA journal_mode; -- Returns 'wal' on success
2.3 Docker Deployment with WAL Enabled
services:
myapp:
image: your-app:latest
environment:
DATABASE_URL: "sqlite:///data/app.db?journal_mode=WAL&synchronous=NORMAL"
volumes:
- ./data:/data # Persistent volume
restart: unless-stopped
Note: WAL mode produces three files:
main.db,main.db-wal,main.db-shm. Backups must include all three files.
Step 2: Concurrency Control & Lock Optimization
SQLite uses a database-level exclusive lock for writes, meaning only one writer at a time. For self-hosted scenarios this is usually fine, but these techniques help:
3.1 Connection Pool Configuration
# Python + SQLAlchemy example
from sqlalchemy import create_engine
engine = create_engine(
"sqlite:///./data/app.db",
connect_args={
"check_same_thread": False, # Thread-safe
"timeout": 30, # 30s timeout
},
pool_size=5, # Limit connections
max_overflow=10,
)
3.2 Batch Write Optimization
-- ❌ Inefficient: Individual inserts
INSERT INTO events (user_id, action, timestamp) VALUES (1, 'login', NOW());
INSERT INTO events (user_id, action, timestamp) VALUES (2, 'login', NOW());
-- ...
-- ✅ Efficient: Wrapped in a transaction
BEGIN;
INSERT INTO events (user_id, action, timestamp) VALUES (1, 'login', NOW());
INSERT INTO events (user_id, action, timestamp) VALUES (2, 'login', NOW());
-- ... 1000 rows ...
COMMIT;
3.3 Read-Write Splitting Strategy
For read-heavy workloads (like dashboard stats), open a read-only copy immediately after snapshot:
# Scheduled snapshot (3 AM daily)
cp main.db main.db.bak
# Read queries use the snapshot
sqlite3 main.db.bak "SELECT COUNT(*) FROM events WHERE date = '2026-09-14';"
Step 3: Automated Backup Strategy
SQLite’s single-file nature makes backups trivially simple, but you must ensure consistency—directly cp-ing a database being written to can corrupt the file.
4.1 Use SQLite’s Built-in Backup API (Recommended)
#!/bin/bash
# backup.sh — Safe online SQLite backup
DB_PATH="./data/app.db"
BACKUP_DIR="./backups"
DATE=$(date +%Y%m%d_%H%M%S)
# Hot backup using sqlite3 (no table locks)
sqlite3 "$DB_PATH" ".backup '$BACKUP_DIR/app_$DATE.db'"
# Compress backup
gzip "$BACKUP_DIR/app_$DATE.db"
# Clean backups older than 30 days
find "$BACKUP_DIR" -name "app_*.db.gz" -mtime +30 -delete
echo "[$(date)] Backup completed: app_$DATE.db.gz"
4.2 Cron Scheduled Backups
# crontab -e
# Daily backup at 3 AM
0 3 * * * /opt/scripts/backup.sh >> /var/log/sqlite-backup.log 2>&1
# Weekly integrity check
0 4 * * 0 sqlite3 /opt/app/data/app.db "PRAGMA integrity_check;"
4.3 Multi-Version Retention Strategy
# backup-config.yaml
retention:
daily: 7 # Keep last 7 days of daily backups
weekly: 4 # Keep last 4 weeks of weekly backups
monthly: 12 # Keep last 12 months of monthly backups
storage:
local: /opt/backups/sqlite
remote: s3://your-bucket/sqlite-backups/ # S3 / R2 object storage
# Push to S3/R2 (with restic or rclone)
rclone copy ./backups/ remote:sqlite-backups/ --progress
Step 4: Query Performance Optimization
5.1 Indexing Strategy
-- Add indexes for high-frequency query fields
CREATE INDEX idx_events_user_time ON events(user_id, created_at);
CREATE INDEX idx_sessions_token ON sessions(token) WHERE token IS NOT NULL;
-- Check index usage
PRAGMA index_list('events');
EXPLAIN QUERY PLAN SELECT * FROM events WHERE user_id = 42;
-- Should show "SEARCH TABLE events USING INDEX idx_events_user_time"
5.2 VACUUM & Database Shrinking
SQLite doesn’t automatically reclaim space after deletions—periodic VACUUM is needed:
-- Check current database size
SELECT page_count * page_size AS size_bytes
FROM pragma_page_count(), pragma_page_size();
-- Reclaim unused space (locks table, run during low-traffic)
VACUUM;
-- Online VACUUM (no lock, but slower)
-- Use sqlite-vacuum-ext extension or copy to new database
5.3 Essential Optimization PRAGMAs
-- Performance tuning checklist
PRAGMA journal_mode = WAL; -- Enable WAL
PRAGMA synchronous = NORMAL; -- Balance perf & safety (2=safe, 0=fastest)
PRAGMA cache_size = -64000; -- 64MB cache
PRAGMA temp_store = MEMORY; -- Temp objects in memory
PRAGMA mmap_size = 268435456; -- 256MB memory-mapped I/O
PRAGMA page_size = 4096; -- Standard page size
PRAGMA automatic_index = ON; -- Auto-create temp indexes
PRAGMA query_only = OFF; -- Allow writes (default)
-- Monitoring
PRAGMA locking_mode; -- Current lock mode
PRAGMA wal_checkpoint(RESTART); -- Manual checkpoint
5.4 Slow Query Analysis
-- Enable query planning log
.timer on
.explain on
-- Run suspicious query and observe
SELECT * FROM events WHERE created_at > '2026-01-01' ORDER BY created_at DESC LIMIT 100;
-- Disable
.explain off
.timer off
Step 5: Monitoring & Health Checks
6.1 Key Metrics
| Metric | Command | Healthy Threshold |
|---|---|---|
| Database size | ls -lh *.db | < 10GB (partition if larger) |
| Connections | PRAGMA database_list; | Active < 20 |
| Lock wait | PRAGMA busy_timeout; | timeout < 5s |
| Integrity | PRAGMA integrity_check; | Returns ok |
| WAL status | PRAGMA wal_checkpoint(PASSIVE); | frame < 1000 |
6.2 Automated Health Check Script
#!/bin/bash
# health-check.sh
DB="./data/app.db"
WARN_THRESHOLD=1073741824 # 1GB
# Size check
SIZE=$(stat -c%s "$DB")
if [ "$SIZE" -gt "$WARN_THRESHOLD" ]; then
echo "WARN: Database size ${SIZE} bytes exceeds 1GB threshold"
fi
# Integrity check
RESULT=$(sqlite3 "$DB" "PRAGMA integrity_check;")
if [ "$RESULT" != "ok" ]; then
echo "ERROR: Integrity check failed: $RESULT"
exit 1
fi
# WAL checkpoint
sqlite3 "$DB" "PRAGMA wal_checkpoint(TRUNCATE);"
echo "OK: All checks passed at $(date)"
6.3 Alert Integration
# Works with Uptime Kuma or custom monitoring
- type: http
name: "SQLite Health Check"
url: "https://monitor.yourdomain.com/api/push/sqlite-health"
interval: 300 # Every 5 minutes
method: POST
headers:
Content-Type: application/json
body: |
{"status":"{{now}}","db_size":{{db_size}},"integrity":"{{integrity}}"}
Step 6: Migration & Upgrades
7.1 SQLite Version Upgrade
# Check current version
sqlite3 --version
# Backup before upgrading
cp app.db app.db.bak.$(date +%s)
# After upgrading SQLite binary, open old database with new sqlite3
# SQLite maintains backward compatibility—newer versions read older databases
sqlite3 app.db "PRAGMA integrity_check;"
7.2 Migrating from PostgreSQL/MySQL to SQLite
-- PostgreSQL → SQLite migration steps
-- 1. Export schema (strip PG-specific syntax)
pg_dump -s -d mydb > schema.sql
-- 2. Convert data types
-- serial → INTEGER PRIMARY KEY
-- text → TEXT
-- boolean → INTEGER
-- timestamp without time zone → DATETIME
-- 3. Export data
pg_dump -t events -d mydb --data-only > data.sql
-- 4. Recreate and import in SQLite
sqlite3 app.db < schema.sql
sqlite3 app.db ".import data.sql events"
Performance Comparison: SQLite vs PostgreSQL
| Scenario | SQLite | PostgreSQL | Winner |
|---|---|---|---|
| Single-user blog | ✅ Lightweight, zero overhead | ❌ Needs 200MB+ RAM | SQLite |
| < 10 concurrent users | ✅ Sufficient | ✅ Also sufficient | Tie |
| Million-row queries | ✅ < 50ms with indexes | ✅ Better | Depends on indexes |
| High-write concurrency | ❌ Lock bottleneck | ✅ Row-level locks | PostgreSQL |
| Backup & restore | ✅ Single file copy | ❌ Needs pg_dump | SQLite |
| Deployment complexity | ✅ Zero config | ❌ Requires service maintenance | SQLite |
Verdict: For most self-hosted personal projects (DAU < 1000, concurrency < 10), SQLite is the more economical and simpler choice. Only consider migrating to PostgreSQL when you hit a clear concurrency bottleneck.
Conclusion
SQLite isn’t a “second-best” fallback—it’s a battle-tested, excellent database engine perfectly suited for self-hosted scenarios. With WAL mode, proper indexing, and automated backups, it delivers reliable storage service at minimal resource cost.
Key takeaways:
- Enable WAL mode—non-negotiable for production self-hosted deployments
- Wrap batch inserts in transactions to avoid individual disk writes
- Run
PRAGMA integrity_checkregularly to catch corruption early - Back up all three WAL files for consistent recovery
- Consider PostgreSQL migration when exceeding 10GB or high concurrency — know when to switch tools
Keep your self-hosted applications running stably at zero extra cost!
