I’ve been running SQLite in production on a few Rails apps. No Postgres process to manage, no connection pool to tune, backups are just file copies. But one thing caught me off guard: how do you replace the database file on a running server?
The problem
Say you have a content-heavy Rails app. You build the SQLite database locally (import from a CMS, run transforms, generate search indexes) then ship that file to production. With Postgres you’d pg_restore into the running database. With SQLite, the database is a file. You need to swap it.
The naive approach is scp the file over and restart the server. That works, but it means downtime. Your app is offline while Puma restarts, reconnects, and warms up. For a content update that should take milliseconds, you’re paying seconds.
You could try mv new.db production.db while the app is running, but that’s a race condition. ActiveRecord might be mid-query when you yank the file out from under it. You’ll get BusyException or worse, a corrupted read.
I wanted something like pg_restore but for SQLite: push a new database to the running server, validate it, pause requests for a few microseconds while it swaps, then resume.
What Hotswap does
Hotswap adds a Unix socket server to your Rails app. You send it a new SQLite database, it validates the file, then atomically swaps it into place. Incoming requests queue for microseconds during the swap, then resume on the new database.
gem "hotswap"
The railtie handles everything: it inserts a Rack middleware that wraps requests in a mutex, starts the socket server when Puma boots, and discovers your SQLite databases from database.yml.
Pushing a database
bin/hotswap cp ./new.sqlite3 db/production.sqlite3
Here’s what happens:
- The CLI connects to the socket server inside your running Rails process
- The database streams to a temp file on the server
PRAGMA integrity_checkverifies the file isn’t corrupt- Schema is compared against the running database
- Swap lock acquired, incoming requests queue
- ActiveRecord disconnects, temp file is atomically renamed, ActiveRecord reconnects
- Lock releases, queued requests resume on the new database
If the integrity check or schema check fails, the running database is untouched. Nothing gets swapped.
Pulling a database
bin/hotswap cp db/production.sqlite3 ./backup.sqlite3
This uses SQLite’s backup API, so it’s safe to run while the app is serving requests, even in WAL mode. You get a consistent snapshot without stopping writes.
Pipes
Since cp supports stdin/stdout with -, you can stream databases between servers:
# Push from stdin
cat new.sqlite3 | bin/hotswap cp - db/production.sqlite3
# Pull to stdout
bin/hotswap cp db/production.sqlite3 - > backup.sqlite3
# Stream between servers
ssh prod 'cd app && bin/hotswap cp db/production.sqlite3 -' | \
bin/hotswap cp - db/production.sqlite3
On Fly.io:
# Push
cat new.sqlite3 | fly ssh console -C "/rails/bin/hotswap cp - /rails/db/production.sqlite3"
# Pull
fly ssh console -C "/rails/bin/hotswap cp /rails/db/production.sqlite3 -" > backup.sqlite3
Schema safety
This is the part that bit me before I added it. I accidentally pushed a database with completely different tables. Hotswap swapped it in, the app started serving 500s, and I had to scramble to push the correct file back.
Now Hotswap compares the schema of the incoming database against the running one before swapping. It extracts every CREATE statement from sqlite_master and diffs them. If they don’t match, you get an error:
ERROR: schema mismatch
- CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT)
+ CREATE TABLE products (id INTEGER PRIMARY KEY, title TEXT, price REAL)
The running database stays untouched. If you’re intentionally changing the schema (say you added a column and want to push the new version), you can skip the check:
bin/hotswap cp ./new.sqlite3 db/production.sqlite3 --skip-schema-check
You can also skip the integrity check if you’ve already verified the file:
bin/hotswap cp ./new.sqlite3 db/production.sqlite3 --skip-integrity-check
Why only cp?
I thought about adding mv, ln, and rm commands but decided against all of them.
cp is the only operation that’s safe by default. The file is written to a temp location, validated, then atomically renamed. If anything fails, the original database is untouched. The worst case is an orphaned temp file.
ln would bypass validation. Symlinks cause WAL/SHM files to end up in the wrong place, and the source file stays mutable outside the swap lock. mv risks data loss if someone accidentally moves the running database. rm is just destructive.
The atomic rename that cp does under the hood is a link/unlink at the filesystem level. Exposing those primitives directly would just be cp without the safety.
When I use this
Content databases. I have apps where the content lives in SQLite: articles, product catalogs, configuration data. A CI pipeline builds the database and Hotswap deploys it to the running server without downtime.
Database migrations on small apps. Build the new database locally with the updated schema and data, push it to production, done. No migration scripts, no db:migrate in production. For apps where the database is small enough to rebuild from scratch, this is simpler than incremental migrations.
Backups. bin/hotswap cp db/production.sqlite3 - piped through SSH gives me a consistent snapshot without stopping the app.
How it works internally
The socket server uses a framed protocol over Unix sockets. When the CLI connects, it sends the command and arguments as frames, then streams stdin if needed. The server runs the command through Thor with IO wired to the socket.
The swap lock is a Mutex shared between the Rack middleware and the push command. Every request acquires the mutex before hitting your app. During a swap, the push command acquires it first, so requests queue until the rename is done. In practice this takes microseconds because it’s just a File.rename call.
Here’s what the swap looks like:
Middleware::SWAP_LOCK.synchronize do
ActiveRecord::Base.connection_handler.clear_all_connections!
File.rename(temp.path, @path)
end
ActiveRecord::Base.establish_connection
The middleware releases the lock, queued requests get the new database, and nobody notices.
Try it
gem "hotswap"
That’s it. Boot your Rails app, push a database, watch it swap. The source is on GitHub.