SQLite Production
Lean CMS is built for SQLite in production. This is not a compromise — 37signals runs HEY and Basecamp on SQLite. For a marketing site CMS, it is the right choice.
Why SQLite for marketing sites:
- No separate database server to provision or pay for
- No network latency between app and database
- A single-file backup with
VACUUM INTO - Rails 8 is designed for it
- Read-heavy traffic patterns are SQLite’s sweet spot
Configuration
# config/database.yml
production:
adapter: sqlite3
database: storage/production.sqlite3
pool: 5
timeout: 5000
pragmas:
journal_mode: wal
synchronous: normal
mmap_size: 134217728
journal_size_limit: 67108864
cache_size: 2000
foreign_keys: true
WAL mode initializer
Add this to ensure WAL mode is active on every boot:
# config/initializers/sqlite3.rb
if Rails.env.production?
Rails.application.config.after_initialize do
ActiveRecord::Base.connection.execute("PRAGMA journal_mode = WAL")
ActiveRecord::Base.connection.execute("PRAGMA synchronous = NORMAL")
ActiveRecord::Base.connection.execute("PRAGMA mmap_size = 134217728")
ActiveRecord::Base.connection.execute("PRAGMA journal_size_limit = 67108864")
ActiveRecord::Base.connection.execute("PRAGMA cache_size = 2000")
end
end
File storage to S3
Store uploads (images) in S3, not alongside the database:
# config/storage.yml
amazon:
service: S3
access_key_id: <%= ENV['AWS_ACCESS_KEY_ID'] %>
secret_access_key: <%= ENV['AWS_SECRET_ACCESS_KEY'] %>
region: <%= ENV['AWS_REGION'] %>
bucket: <%= ENV['AWS_BUCKET'] %>
# config/environments/production.rb
config.active_storage.service = :amazon
This keeps your deployment clean: the SQLite file is the database, S3 is the media library, and you can replace either independently.
Backup strategy
Automated daily backups
# lib/tasks/backup.rake
namespace :db do
desc "Backup production database"
task backup: :environment do
timestamp = Time.now.strftime("%Y%m%d%H%M%S")
backup_dir = Rails.root.join("backups")
FileUtils.mkdir_p(backup_dir)
db_file = Rails.root.join("storage", "production.sqlite3")
backup_file = backup_dir.join("production_#{timestamp}.sqlite3")
# VACUUM INTO is a hot backup — doesn't lock the database
ActiveRecord::Base.connection.execute("VACUUM INTO '#{backup_file}'")
if ENV["AWS_BACKUP_BUCKET"]
s3_key = "database-backups/production_#{timestamp}.sqlite3"
`aws s3 cp #{backup_file} s3://#{ENV["AWS_BACKUP_BUCKET"]}/#{s3_key}`
File.delete(backup_file)
end
puts "Database backed up"
end
end
Cron schedule
# Daily at 2 AM
0 2 * * * cd /app && /usr/local/bin/rails db:backup
Restore
# Stop the app
systemctl stop your-app
# Restore from backup
cp backups/production_20250111020000.sqlite3 storage/production.sqlite3
# Or from S3
aws s3 cp s3://your-backup-bucket/database-backups/production_20250111020000.sqlite3 storage/production.sqlite3
# Start the app
systemctl start your-app
Maintenance tasks
# lib/tasks/maintenance.rake
namespace :db do
desc "Optimize SQLite database"
task optimize: :environment do
ActiveRecord::Base.connection.execute("VACUUM")
ActiveRecord::Base.connection.execute("ANALYZE")
puts "Database optimized"
end
desc "Checkpoint WAL file"
task checkpoint: :environment do
ActiveRecord::Base.connection.execute("PRAGMA wal_checkpoint(TRUNCATE)")
puts "WAL checkpointed"
end
end
# Weekly optimization (Sunday 3 AM)
0 3 * * 0 cd /app && /usr/local/bin/rails db:optimize
# Daily WAL checkpoint (4 AM)
0 4 * * * cd /app && /usr/local/bin/rails db:checkpoint
Useful commands
# Check database size
du -h storage/production.sqlite3
# Check WAL size
du -h storage/production.sqlite3-wal
# Run backup manually
rails db:backup
# Optimize database
rails db:optimize
# Check database stats
rails db:stats
When to consider Postgres
You’ll know it’s time when you hit one of these:
- Multiple app servers — SQLite is single-writer; it doesn’t work behind a load balancer
- High write concurrency — multiple editors constantly publishing at the same time
- Database > 100GB — unlikely for a CMS, but possible
- Postgres-specific features — PostGIS, full-text search, JSONB queries
Switching is straightforward: change database.yml, run rails db:setup, and redeploy. Rails abstracts most of the differences.
Production checklist
- WAL mode enabled
- Daily backups to S3 configured
- Health check endpoint working
- Database optimization cron job scheduled
- File uploads going to S3 (not local disk)
- Fragment caching enabled
- Test database restore process
Troubleshooting
“Database is locked”
- Confirm WAL mode is active:
rails db:stats - Increase timeout in
database.yml:timeout: 10000 - Check for long-running transactions
WAL file growing too large
rails db:checkpoint
Or check the journal_size_limit pragma in database.yml.