A cron job that runs pg_dump > backup.sql is not a backup strategy — it’s a hope. Here’s a Ruby wrapper that compresses, rotates, and actually proves your database backups restore.
Most database backup pipelines stop at “the dump command ran and exited 0.” That tells you almost nothing: a truncated dump, a permissions issue that silently skips tables, or a partition that filled up mid-backup can all still exit 0. Meanwhile the backups directory grows forever until a disk fills up, because nobody wired up rotation either.
db_backup_manager.rb wraps pg_dump/mysqldump with three things a real backup strategy needs: streaming gzip compression (so multi-GB databases don’t blow up Ruby’s memory), a retention policy that automatically prunes old backups, and an optional restore test that replays the dump into a scratch database and checks it actually came back.
#!/usr/bin/env ruby
# frozen_string_literal: true
#
# db_backup_manager.rb -- Automated PostgreSQL/MySQL logical backups with
# gzip compression, retention rotation, and restore verification.
#
# Wraps the platform-native dump tools (pg_dump / mysqldump) rather than
# reimplementing dump logic in Ruby -- Ruby's job here is orchestration:
# picking the right command, compressing the result, enforcing a retention
# policy, and optionally proving the backup is actually restorable.
#
# Usage:
# ruby db_backup_manager.rb --engine postgres --db devopsdemo --out /var/backups/db --keep 7
# ruby db_backup_manager.rb --engine mysql --db appdb --out /var/backups/db --keep 14 --verify
#
# Exit codes: 0 = backup (and verify, if requested) succeeded
# 1 = backup failed
# 2 = backup succeeded but verification failed
require 'optparse'
require 'open3'
require 'time'
require 'fileutils'
require 'zlib'
require 'digest'
# ---------------------------------------------------------------------------
# Option parsing
# ---------------------------------------------------------------------------
options = {
engine: nil,
db: nil,
host: 'localhost',
port: nil,
user: ENV['USER'],
out_dir: './db_backups',
keep: 7,
verify: false,
json: false
}
OptionParser.new do |o|
o.banner = 'Usage: db_backup_manager.rb --engine postgres|mysql --db NAME [options]'
o.on('--engine ENGINE', %w[postgres mysql], 'Database engine: postgres or mysql') { |v| options[:engine] = v }
o.on('--db NAME', 'Database name to back up') { |v| options[:db] = v }
o.on('--host HOST', 'Database host (default: localhost)') { |v| options[:host] = v }
o.on('--port PORT', Integer, 'Database port (defaults: 5432/3306)') { |v| options[:port] = v }
o.on('--user USER', 'Database user (default: $USER)') { |v| options[:user] = v }
o.on('--out DIR', 'Directory to write backups into') { |v| options[:out_dir] = v }
o.on('--keep N', Integer, 'Number of most-recent backups to retain (default: 7)') { |v| options[:keep] = v }
o.on('--verify', 'Restore-test the backup into a scratch database before trusting it') { options[:verify] = true }
o.on('--json', 'Emit a JSON summary line instead of human text') { options[:json] = true }
o.on('-h', '--help', 'Show this help') { puts o; exit 0 }
end.parse!
if options[:engine].nil? || options[:db].nil?
warn 'Error: --engine and --db are required (see --help)'
exit 1
end
# PGPASSWORD / MYSQL_PWD are read from the environment by the underlying
# tools themselves -- we never accept a password on the command line, since
# that would leak it into `ps` output and shell history.
class DumpFailed < StandardError; end
class VerifyFailed < StandardError; end
# ---------------------------------------------------------------------------
# Engine-specific command builders. Each returns the argv array to run via
# Open3 (never a shell string -- avoids injection through db/user names).
# ---------------------------------------------------------------------------
def pg_dump_cmd(opts)
cmd = ['pg_dump', '--no-password', '--format=plain']
cmd += ['--host', opts[:host]]
cmd += ['--port', opts[:port].to_s] if opts[:port]
cmd += ['--username', opts[:user]] if opts[:user]
cmd << opts[:db]
cmd
end
def mysqldump_cmd(opts)
cmd = ['mysqldump', '--single-transaction', '--routines']
cmd += ['--host', opts[:host]]
cmd += ['--port', opts[:port].to_s] if opts[:port]
cmd += ['--user', opts[:user]] if opts[:user]
cmd << opts[:db]
cmd
end
# ---------------------------------------------------------------------------
# Run the dump command, streaming stdout straight into a gzip writer so a
# multi-GB database never has to sit fully in Ruby memory at once.
# ---------------------------------------------------------------------------
def run_dump(cmd, dest_path)
Zlib::GzipWriter.open(dest_path) do |gz|
stdout_thread_err = nil
Open3.popen3(*cmd) do |stdin, stdout, stderr, wait_thr|
stdin.close
err_reader = Thread.new { stderr.read }
begin
IO.copy_stream(stdout, gz)
rescue IOError => e
stdout_thread_err = e
end
exit_status = wait_thr.value
stderr_output = err_reader.value
unless exit_status.success?
raise DumpFailed, "#{cmd.first} exited #{exit_status.exitstatus}: #{stderr_output.strip}"
end
raise DumpFailed, stdout_thread_err.message if stdout_thread_err
end
end
end
# ---------------------------------------------------------------------------
# Retention: keep only the N most recent backups for this db+engine.
# ---------------------------------------------------------------------------
def rotate_backups(out_dir, db, keep)
pattern = File.join(out_dir, "#{db}_*.sql.gz")
existing = Dir.glob(pattern).sort
removed = []
while existing.length > keep
victim = existing.shift
File.delete(victim)
removed << victim
end
removed
end
# ---------------------------------------------------------------------------
# Verification: restore the dump into a throwaway scratch database and run a
# cheap sanity check (row count on a system catalog / information_schema
# query) so a truncated or corrupt dump fails loudly instead of silently.
# ---------------------------------------------------------------------------
def verify_postgres(dest_path, opts)
scratch_db = "#{opts[:db]}_verify_#{Process.pid}"
create_cmd = ['createdb', '--host', opts[:host]]
create_cmd += ['--username', opts[:user]] if opts[:user]
create_cmd << scratch_db
_out, err, status = Open3.capture3(*create_cmd)
raise VerifyFailed, "could not create scratch db: #{err}" unless status.success?
begin
restore_cmd = ['psql', '--host', opts[:host], '--quiet', '--set', 'ON_ERROR_STOP=1']
restore_cmd += ['--username', opts[:user]] if opts[:user]
restore_cmd += ['--dbname', scratch_db]
Zlib::GzipReader.open(dest_path) do |gz|
_out, err, status = Open3.capture3(*restore_cmd, stdin_data: gz.read)
raise VerifyFailed, "restore failed: #{err}" unless status.success?
end
count_cmd = ['psql', '--host', opts[:host], '--tuples-only', '--no-align',
'--command', "SELECT count(*) FROM information_schema.tables WHERE table_schema='public'"]
count_cmd += ['--username', opts[:user]] if opts[:user]
count_cmd += ['--dbname', scratch_db]
out, err, status = Open3.capture3(*count_cmd)
raise VerifyFailed, "post-restore check failed: #{err}" unless status.success?
{ tables_restored: out.strip.to_i }
ensure
drop_cmd = ['dropdb', '--host', opts[:host]]
drop_cmd += ['--username', opts[:user]] if opts[:user]
drop_cmd << scratch_db
Open3.capture3(*drop_cmd)
end
end
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
FileUtils.mkdir_p(options[:out_dir])
timestamp = Time.now.utc.strftime('%Y%m%dT%H%M%SZ')
filename = "#{options[:db]}_#{timestamp}.sql.gz"
dest_path = File.join(options[:out_dir], filename)
result = { db: options[:db], engine: options[:engine], file: dest_path, verified: false }
begin
cmd = options[:engine] == 'postgres' ? pg_dump_cmd(options) : mysqldump_cmd(options)
start = Time.now
run_dump(cmd, dest_path)
result[:duration_s] = (Time.now - start).round(2)
result[:size_bytes] = File.size(dest_path)
result[:sha256] = Digest::SHA256.file(dest_path).hexdigest
rescue DumpFailed => e
File.delete(dest_path) if File.exist?(dest_path)
if options[:json]
puts({ db: options[:db], error: e.message }.to_json)
else
warn "BACKUP FAILED: #{e.message}"
end
exit 1
end
removed = rotate_backups(options[:out_dir], options[:db], options[:keep])
result[:rotated_out] = removed
exit_code = 0
if options[:verify]
begin
if options[:engine] == 'postgres'
verify_info = verify_postgres(dest_path, options)
result[:verified] = true
result[:verify_info] = verify_info
else
warn 'NOTE: --verify is only implemented for postgres in this script; see README for the MySQL approach.'
end
rescue VerifyFailed => e
result[:verified] = false
result[:verify_error] = e.message
exit_code = 2
end
end
if options[:json]
require 'json'
puts result.to_json
else
puts "Backup OK: #{dest_path} (#{result[:size_bytes]} bytes, #{result[:duration_s]}s)"
puts "SHA256: #{result[:sha256]}"
puts "Rotated out #{removed.length} old backup(s): #{removed.join(', ')}" unless removed.empty?
if options[:verify]
if result[:verified]
puts "VERIFY OK: restored #{result[:verify_info][:tables_restored]} table(s) into a scratch database"
else
puts "VERIFY FAILED: #{result[:verify_error]}"
end
end
end
exit exit_code
Argv arrays, never shell strings. pg_dump_cmd/mysqldump_cmd build the command as an array passed straight to Open3 — database and user names can never break out into shell injection, because there’s no shell in between.
Streaming, not buffering. Open3.popen3 hands back the dump tool’s stdout pipe, which gets piped directly into Zlib::GzipWriter via IO.copy_stream. The whole dump is never fully in Ruby’s memory — a 40 GB database compresses just fine on a box with 2 GB of RAM. stderr is drained on a separate thread at the same time so a chatty dump tool can’t deadlock the pipe.
Retention is just a glob and a sort. Every backup filename embeds a UTC timestamp, so rotation is Dir.glob + sort + delete-the-oldest-past-N. No database of its own, no extra state file to get out of sync.
Verification closes the loop. --verify creates a real (if throwaway) database, restores the compressed dump into it with psql, and runs a cheap information_schema.tables count. The scratch database is dropped in an ensure block, so a restore failure still cleans up after itself.
$ ruby db_backup_manager.rb --engine postgres --db devopsdemo --host 127.0.0.1 --user postgres --out /var/backups/db --keep 3 --verify Backup OK: /var/backups/db/devopsdemo_20260923T182737Z.sql.gz (803 bytes, 0.1s) SHA256: c0d48be66204c0ab1f285e20540e0a4d219a14d50bd83e0de6278a256b98583d VERIFY OK: restored 1 table(s) into a scratch database $ ruby db_backup_manager.rb --engine postgres --db devopsdemo --host 127.0.0.1 --user postgres --out /var/backups/db --keep 3 Backup OK: /var/backups/db/devopsdemo_20260923T182751Z.sql.gz (804 bytes, 0.09s) Rotated out 2 old backup(s): devopsdemo_20260923T182747Z.sql.gz, devopsdemo_20260923T182749Z.sql.gz $ ruby db_backup_manager.rb --engine postgres --db doesnotexist123 --host 127.0.0.1 --user postgres --out /var/backups/db --keep 2 BACKUP FAILED: pg_dump exited 1: pg_dump: error: connection to server at "127.0.0.1", port 5432 failed: FATAL: database "doesnotexist123" does not exist $ echo "exit code: $?" exit code: 1
Full script + README on GitHub: ruby-devops-toolkit/db-backup-manager
Prerequisites
- Ruby ≥ 3.0 — this script uses only the standard library (
optparse,open3,zlib,digest,fileutils). Nogem installrequired. pg_dump/psql/createdb/dropdbonPATHfor PostgreSQL, ormysqldumpfor MySQL.- Credentials supplied the way the underlying tool expects — a
~/.pgpassfile orPGPASSWORDenv var, never a command-line flag (that leaks intopsoutput and shell history).
Full script for reference
#!/usr/bin/env ruby
# frozen_string_literal: true
#
# db_backup_manager.rb -- Automated PostgreSQL/MySQL logical backups with
# gzip compression, retention rotation, and restore verification.
#
# Wraps the platform-native dump tools (pg_dump / mysqldump) rather than
# reimplementing dump logic in Ruby -- Ruby's job here is orchestration:
# picking the right command, compressing the result, enforcing a retention
# policy, and optionally proving the backup is actually restorable.
#
# Usage:
# ruby db_backup_manager.rb --engine postgres --db devopsdemo --out /var/backups/db --keep 7
# ruby db_backup_manager.rb --engine mysql --db appdb --out /var/backups/db --keep 14 --verify
#
# Exit codes: 0 = backup (and verify, if requested) succeeded
# 1 = backup failed
# 2 = backup succeeded but verification failed
require 'optparse'
require 'open3'
require 'time'
require 'fileutils'
require 'zlib'
require 'digest'
# ---------------------------------------------------------------------------
# Option parsing
# ---------------------------------------------------------------------------
options = {
engine: nil,
db: nil,
host: 'localhost',
port: nil,
user: ENV['USER'],
out_dir: './db_backups',
keep: 7,
verify: false,
json: false
}
OptionParser.new do |o|
o.banner = 'Usage: db_backup_manager.rb --engine postgres|mysql --db NAME [options]'
o.on('--engine ENGINE', %w[postgres mysql], 'Database engine: postgres or mysql') { |v| options[:engine] = v }
o.on('--db NAME', 'Database name to back up') { |v| options[:db] = v }
o.on('--host HOST', 'Database host (default: localhost)') { |v| options[:host] = v }
o.on('--port PORT', Integer, 'Database port (defaults: 5432/3306)') { |v| options[:port] = v }
o.on('--user USER', 'Database user (default: $USER)') { |v| options[:user] = v }
o.on('--out DIR', 'Directory to write backups into') { |v| options[:out_dir] = v }
o.on('--keep N', Integer, 'Number of most-recent backups to retain (default: 7)') { |v| options[:keep] = v }
o.on('--verify', 'Restore-test the backup into a scratch database before trusting it') { options[:verify] = true }
o.on('--json', 'Emit a JSON summary line instead of human text') { options[:json] = true }
o.on('-h', '--help', 'Show this help') { puts o; exit 0 }
end.parse!
if options[:engine].nil? || options[:db].nil?
warn 'Error: --engine and --db are required (see --help)'
exit 1
end
# PGPASSWORD / MYSQL_PWD are read from the environment by the underlying
# tools themselves -- we never accept a password on the command line, since
# that would leak it into `ps` output and shell history.
class DumpFailed < StandardError; end
class VerifyFailed < StandardError; end
# ---------------------------------------------------------------------------
# Engine-specific command builders. Each returns the argv array to run via
# Open3 (never a shell string -- avoids injection through db/user names).
# ---------------------------------------------------------------------------
def pg_dump_cmd(opts)
cmd = ['pg_dump', '--no-password', '--format=plain']
cmd += ['--host', opts[:host]]
cmd += ['--port', opts[:port].to_s] if opts[:port]
cmd += ['--username', opts[:user]] if opts[:user]
cmd << opts[:db]
cmd
end
def mysqldump_cmd(opts)
cmd = ['mysqldump', '--single-transaction', '--routines']
cmd += ['--host', opts[:host]]
cmd += ['--port', opts[:port].to_s] if opts[:port]
cmd += ['--user', opts[:user]] if opts[:user]
cmd << opts[:db]
cmd
end
# ---------------------------------------------------------------------------
# Run the dump command, streaming stdout straight into a gzip writer so a
# multi-GB database never has to sit fully in Ruby memory at once.
# ---------------------------------------------------------------------------
def run_dump(cmd, dest_path)
Zlib::GzipWriter.open(dest_path) do |gz|
stdout_thread_err = nil
Open3.popen3(*cmd) do |stdin, stdout, stderr, wait_thr|
stdin.close
err_reader = Thread.new { stderr.read }
begin
IO.copy_stream(stdout, gz)
rescue IOError => e
stdout_thread_err = e
end
exit_status = wait_thr.value
stderr_output = err_reader.value
unless exit_status.success?
raise DumpFailed, "#{cmd.first} exited #{exit_status.exitstatus}: #{stderr_output.strip}"
end
raise DumpFailed, stdout_thread_err.message if stdout_thread_err
end
end
end
# ---------------------------------------------------------------------------
# Retention: keep only the N most recent backups for this db+engine.
# ---------------------------------------------------------------------------
def rotate_backups(out_dir, db, keep)
pattern = File.join(out_dir, "#{db}_*.sql.gz")
existing = Dir.glob(pattern).sort
removed = []
while existing.length > keep
victim = existing.shift
File.delete(victim)
removed << victim
end
removed
end
# ---------------------------------------------------------------------------
# Verification: restore the dump into a throwaway scratch database and run a
# cheap sanity check (row count on a system catalog / information_schema
# query) so a truncated or corrupt dump fails loudly instead of silently.
# ---------------------------------------------------------------------------
def verify_postgres(dest_path, opts)
scratch_db = "#{opts[:db]}_verify_#{Process.pid}"
create_cmd = ['createdb', '--host', opts[:host]]
create_cmd += ['--username', opts[:user]] if opts[:user]
create_cmd << scratch_db
_out, err, status = Open3.capture3(*create_cmd)
raise VerifyFailed, "could not create scratch db: #{err}" unless status.success?
begin
restore_cmd = ['psql', '--host', opts[:host], '--quiet', '--set', 'ON_ERROR_STOP=1']
restore_cmd += ['--username', opts[:user]] if opts[:user]
restore_cmd += ['--dbname', scratch_db]
Zlib::GzipReader.open(dest_path) do |gz|
_out, err, status = Open3.capture3(*restore_cmd, stdin_data: gz.read)
raise VerifyFailed, "restore failed: #{err}" unless status.success?
end
count_cmd = ['psql', '--host', opts[:host], '--tuples-only', '--no-align',
'--command', "SELECT count(*) FROM information_schema.tables WHERE table_schema='public'"]
count_cmd += ['--username', opts[:user]] if opts[:user]
count_cmd += ['--dbname', scratch_db]
out, err, status = Open3.capture3(*count_cmd)
raise VerifyFailed, "post-restore check failed: #{err}" unless status.success?
{ tables_restored: out.strip.to_i }
ensure
drop_cmd = ['dropdb', '--host', opts[:host]]
drop_cmd += ['--username', opts[:user]] if opts[:user]
drop_cmd << scratch_db
Open3.capture3(*drop_cmd)
end
end
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
FileUtils.mkdir_p(options[:out_dir])
timestamp = Time.now.utc.strftime('%Y%m%dT%H%M%SZ')
filename = "#{options[:db]}_#{timestamp}.sql.gz"
dest_path = File.join(options[:out_dir], filename)
result = { db: options[:db], engine: options[:engine], file: dest_path, verified: false }
begin
cmd = options[:engine] == 'postgres' ? pg_dump_cmd(options) : mysqldump_cmd(options)
start = Time.now
run_dump(cmd, dest_path)
result[:duration_s] = (Time.now - start).round(2)
result[:size_bytes] = File.size(dest_path)
result[:sha256] = Digest::SHA256.file(dest_path).hexdigest
rescue DumpFailed => e
File.delete(dest_path) if File.exist?(dest_path)
if options[:json]
puts({ db: options[:db], error: e.message }.to_json)
else
warn "BACKUP FAILED: #{e.message}"
end
exit 1
end
removed = rotate_backups(options[:out_dir], options[:db], options[:keep])
result[:rotated_out] = removed
exit_code = 0
if options[:verify]
begin
if options[:engine] == 'postgres'
verify_info = verify_postgres(dest_path, options)
result[:verified] = true
result[:verify_info] = verify_info
else
warn 'NOTE: --verify is only implemented for postgres in this script; see README for the MySQL approach.'
end
rescue VerifyFailed => e
result[:verified] = false
result[:verify_error] = e.message
exit_code = 2
end
end
if options[:json]
require 'json'
puts result.to_json
else
puts "Backup OK: #{dest_path} (#{result[:size_bytes]} bytes, #{result[:duration_s]}s)"
puts "SHA256: #{result[:sha256]}"
puts "Rotated out #{removed.length} old backup(s): #{removed.join(', ')}" unless removed.empty?
if options[:verify]
if result[:verified]
puts "VERIFY OK: restored #{result[:verify_info][:tables_restored]} table(s) into a scratch database"
else
puts "VERIFY FAILED: #{result[:verify_error]}"
end
end
end
exit exit_code
Step-by-step: what happens on a run
- 1. Build the argv array for
pg_dumpormysqldumpbased on--engine, host, port, and user. - 2. Stream the dump through
Open3.popen3straight into aZlib::GzipWriter, computing a SHA-256 of the finished file. - 3. Rotate old backups for that database down to
--keepmost-recent files. - 4. Optionally verify by restoring into a scratch database and counting tables, always dropping the scratch DB afterward.
- 5. Report either human-readable text or a single JSON line, and exit 0/1/2 depending on what happened.
Troubleshooting
Extending it
- Add an S3/GCS upload step once a backup is verified.
- Pipe the gzip stream through
ageoropenssl encfor at-rest encryption. - Implement the MySQL restore-test using the same scratch-database pattern.
- Add WAL-archiving awareness for point-in-time recovery on Postgres, rather than relying solely on periodic logical dumps.